Corrected 2026-08-24 — this section previously named a def name: String
and a context: PlatformContext parameter, neither of which exists. It was
copied from an API that predates Command's current shape.
- Options live in the command's companion, with the name as a constant:
object MyCommand { final val cmdName = "mine" case class Options(inputFile: Option[Path] = None) extends CommandOptions { def command: String = cmdName } }
- The class takes
using PlatformContextand passes the name to the base:class MyCommand(using pc: PlatformContext) extends Command[MyCommand.Options](MyCommand.cmdName) - Implement:
override def getOptionsParser: (OParser[Unit, Options], Options)— a scoptcmd(...)plus the defaultOptions()override def run(options: Options, outputDirOverride: Option[Path]): Either[Messages, PassesResult]override def interpretConfig(config: Config): Options— required forriddlc from <conf> <cmd>; read the block named bycommandNameoverride def loadOptionsFrom(...)callingresolveInputFileToConfigFile, andoverride protected def replaceInputFile(...), so a.conf'sinput-fileresolves relative to the.confrather than the cwdoverride def run(args: Array[String], ...)ONLY when the command needs arguments scopt cannot model —finddoes, because its expression is full of bare(,)and;tokens.
- Register it in THREE places, or it is missing on one platform:
commands/src/main/scalajvm/.../CommandLoader.scala—loadCommandNamedand theoptionParsersSeq thatriddlc helprenders fromcommands/src/main/scalanative/.../CommandLoader.scala— the same twocommands/src/main/scala/.../Commands.scala—loadCommandNamed, a third copy
- Add a block to
commands/input/cmdoptions.confsofromworks and the standard options-reading test covers it.
Diagnostics go to STDERR (pc.log, since 2026-08-23); anything a script is
meant to parse goes to stdout with println. A command that prints its result
through pc.log produces a stream whose lines are prefixed [info], which is
invisible to the eye and fatal to a pipe.
The global --dry-run cannot be implemented on top of.
Commands.handleCommandRun short-circuits on it and logs "Would have
executed…" without ever invoking the command. A command needing a real dry run
declares its own flag, as find -dry-run does.
This file provides specific guidance for working with the RIDDL project. For general ossuminc organization patterns, see ../CLAUDE.md (parent directory).
RIDDL documentation has moved to ossum.tech/riddl
The Hugo-based documentation site at riddl.tech has been consolidated into the ossum.tech MkDocs site. Key documentation:
- Language Reference: https://ossum.tech/riddl/references/language-reference/
- EBNF Grammar: https://ossum.tech/riddl/references/ebnf-grammar/
- Tutorials: https://ossum.tech/riddl/tutorials/
- Tools (riddlc): https://ossum.tech/riddl/tools/riddlc/
The doc/ directory in this repository contains legacy Hugo content that
redirects to ossum.tech. Do not add new documentation here.
RIDDL (Reactive Interface to Domain Definition Language) is a specification language for designing distributed, reactive, cloud-native systems using DDD principles. It's a monorepo containing multiple cross-platform Scala modules.
RIDDL is a heavily used library both by Ossum Inc. and external consumers. Never make incompatible changes to public APIs without following this process:
- No removal of public API — Do not remove public methods, classes,
traits, or extension methods. If functionality must be retired, add
@deprecatedannotations with a migration message and the target major version for removal (e.g.,@deprecated("Use flatten() instead", "2.0.0")). - No breaking signature changes — Do not change parameter types, return types, or add required parameters to existing public methods. New parameters must have defaults.
- Deprecation warnings until next major release — Deprecated APIs must remain functional through the current major version (1.x). They may only be removed in the next major release (2.0.0).
- Additive changes only — New methods, extension methods, classes, and traits are always safe. Prefer adding new APIs alongside old ones rather than modifying existing ones.
When in doubt, add, don't change.
2.0 ships when the Computational Model is met — not when the backlog hits zero by attrition. That is the completion criterion, and it is what keeps the process bounded (Reid, 2026-08-15). Practically it means: no over-engineering, no rampant featurism, and "does the CM require this?" as the test for whether something belongs in 2.0 at all.
Distinguish two kinds of completeness, because only one of them is featurism:
- Correctness completeness — making a dispatch total so a construct the language ALREADY admits stops emitting broken output. Not a feature. Leaving it half-done is a defect that every generator inherits.
- Feature completeness — adding constructs or diagnostics because they would be nice. This is the thing to resist.
Code landing, tests green and the entry deleted is not completion. If the
change alters what a conforming generator must preserve, the item is done only
once ../RIDDL-Computational-Model.md says so. "Tests pass, committed" is an
incomplete report for any language change.
The Computational Model records events — what has actually landed. Backlog
items are commands and aspirations; they have not happened, so they have
nothing to say to the CM until they do. Reconciliation therefore runs CM against
the branch (git log), and may produce new backlog entries as output. A
backlog aspiration may well change the CM one day, but not yet, and writing
it in early makes the document describe a language that does not exist.
Everything on BACKLOG.md is 2.0 work. Post-2.0 items get filed after 2.0 ships. Do not create a 2.1 bucket to shorten the current list — that is the same dishonest zero as deleting an entry that was carrying real work.
Many originate in riddlg (../riddl-generator), which keeps discovering
things RIDDL must disambiguate before code generation is well-defined. Those are
CM-relevant almost by construction — that is the CM's whole purpose — so treat a
riddlg-sourced item as in-scope for 2.0 unless there is a specific reason not to.
- Scala 3.9.0 (not Scala 2!) — the RC line is done: 3.9.0 final was
adopted 2026-08-27, having ridden 3.9.0-RC1 → RC4 → RC6 through the 2.0
branch. Pinned via
V.scala+With.Scala3.configure(version = Some(V.scala))on every CrossModule (sbt-ossuminc'sWith.typicalotherwise pins its default 3.8.4, applied afterscalaVersion :=, so the plain setting is a no-op — theWith.Scala3.configureoverride is the real lever). A Scala bump is ~36 sites, not one, because the full version is a build-output PATH SEGMENT:project/Dependencies.scalaplus every hardcodedscala-<version>inscala.yml,release.yml,coverage.yml,.sonarcloud.propertiesandDockerfile. A grep that omits.github/misses ten of them. - Build files are Scala 3 too — since the sbt 2 upgrade,
build.sbtandproject/*.scalacompile with Scala 3 (no more Scala 2.12 build-def rule). - ALWAYS use Scala 3 syntax:
while i < end do ... end while(NOTwhile (i < end) { ... })- No
nullchecks — useOption(x)instead - New control flow syntax with
do/then/end
Current version: 3.0.3 (sbt 2.0.2, projectMatrix-based
CrossModule). Requires sbt 2.0.2+ — pinned in
project/build.properties. sbt 2 credentials live in ~/.sbt/2/.
CrossModule(dir, mod, V.scala)(JVM, JS, Native)takes the Scala version and wraps sbt 2's built-inprojectMatrix. Extract rows with.jvm/.js/.native; wire deps per-row (no cp-level.dependsOn).- Flat source tree (no more
shared/jvm/js/native):<mod>/src/{main,test}/scala(shared),.../scalajvm,.../scalajs,.../scalanative, and.../scala-jvm-native(JVM+Native shared, wired viaunmanagedSourceDirectories). - Build outputs live under a central virtual-FS tree
(
sbt.io.virtual=true, the default):target/out/<platform>/ scala-<fullVersion>/<artifactName>/…— e.g.target/out/jvm/scala-3.9.0/riddl-utils/,target/out/sjs1/scala-3.9.0/riddl-lib/,target/out/native0.5/scala-3.9.0/riddlc/. NOT per-module<mod>/target/…. Platform dirs arejvm/sjs1/native0.5; the path carries the full Scala version, not a-3binary tag. - 3.0.3's CrossModule auto-adds
scalajs-stubs % providedto the JVM/Native rows of any module that also targets JS (so shared@JSExport*code compiles) — no consumer dep needed. - Cross-platform deps use plain
%%(the%%%operator is gone).
// Scala 3.9.0 — override sbt-ossuminc's 3.8.4 default per module:
.configure(With.typical, With.GithubPublishing, With.Scala3.configure(version = Some(V.scala)))
// (plain `scalaVersion := V.scala` is a no-op — With.typical wins over it)
// Scala.js configuration
.jsConfigure(With.ScalaJS(
header = "RIDDL: module-name",
hasMain = false,
forProd = true,
withCommonJSModule = true
))
// Scala Native configuration
.nativeConfigure(With.Native(
mode = "fast", // "debug", "fast", "full", "size", "release"
buildTarget = "static", // or "application"
gc = "none",
lto = "none"
))
// BuildInfo with custom keys
.jvmConfigure(With.BuildInfo.withKeys(
"key1" -> value1,
"key2" -> value2
))utils → language → passes → commands → riddlc
↓
testkit
Note: The diagrams and hugo modules have been moved to the riddl-gen repository.
Purpose: Binary AST serialization for fast module imports. Status: Complete; ~6-10x faster than reparsing source; output ~63-67% of source size on non-trivial inputs.
- Package:
com.ossuminc.riddl.language.bastinlanguage/src/main/scala/com/ossuminc/riddl/language/bast/ - Cross-platform: JVM, JS, Native
- Pass:
passes/shared/.../BASTWriterPass.scala - CLI:
riddlc bastify <file.riddl>(write);riddlc unbastify(read — implemented;UnbastifyCommand, andRiddlModelsRoundTripTestexercises it over the whole corpus. This line said "pending" until 2026-08-11.) - Format docs: live at ossum.tech/riddl, not in this repo
Key files in the bast package:
package.scala— constants and node type tags (NODE_, TYPE_, STREAMLET_*, …)BASTWriter.scala— serialization (extends HierarchyPass)BASTReader.scala— deserializationBASTLoader.scala— import-loading utilityBASTUtils.scala— shared utilitiesStringTable.scala,PathTable.scala— interning tables
HAZARD — disjoint tag sets: readNode() only handles NODE_*
tags; readTypeExpression() only handles TYPE_* tags. Crossing
them causes byte misalignment that surfaces as "Invalid string
table index" errors during deserialization.
HAZARD — one tag per WIRE SHAPE, not per family. Constant and
Method were both written with NODE_FIELD because all three are
"a name and a type". But a Constant appends its literal value and a
Method appends its argument list, so the reader — which read a Field
— left those bytes in the stream and every byte after such a node
was misread. Fixed 2026-08-13 with NODE_CONSTANT (109) /
NODE_METHOD (110) and FORMAT_REVISION 14.
The reader had ADMITTED it in a comment ("This is ambiguous … For now, assume Field. Writer should disambiguate better"), which is the part worth learning from: a known-ambiguous decode is a latent corruption, not a rough edge. The rule is that two node kinds may share a tag only if they write byte-identical payloads.
A BAST error names where the reader DERAILED, never what derailed
it. The same single constant surfaced as Invalid string table index in a 13-node model and as Invalid invariant condition kind: 67 in a 9618-node one, sending both riddl-models and this repo to
bisect an innocent invariant. When diagnosing, bisect toward the
node BEFORE the reported position, and distrust the construct named.
The riddlLib module exports a TypeScript-friendly API via RiddlAPI object.
Key features:
- All method names preserved (not minified) via
@JSExport - JavaScript-friendly return types:
{ succeeded: boolean, value?: object, errors?: Array<object> } - All Scala types converted to plain JS:
List→Array- Case classes → Plain objects
Either→{ succeeded, value, errors }
Building npm packages (via sbt-ossuminc 2.0.1 helpers):
sbt riddlLibJS/npmPrepare # Assemble package (pure sbt)
sbt riddlLibJS/npmPack # Create .tgz tarball
sbt riddlLibJS/npmPublishGithub # Publish to GH Packages
sbt riddlLibJS/npmPublishNpmjs # Publish to npmjs.comCI Workflow: .github/workflows/npm-publish.yml triggers on
release or manual dispatch, uses sbt tasks directly.
Module format: ESModule ("type": "module" in package.json).
Consumers use import { RiddlAPI } from '@ossuminc/riddl-lib'.
Documentation:
NPM_PACKAGING.md- npm build and installation guideTYPESCRIPT_API.md- Complete TypeScript API reference
Published: @ossuminc/riddl-lib on GitHub Packages npm registry
CRITICAL DISTINCTION:
- Can appear anywhere in hierarchy
- Parser rules determined by enclosing container
include "entities.riddl"in a Context → must contain Context-valid content- Already implemented
- Loads BAST-serialized content into RIDDL models
- Full import:
import "file.bast"— loads all Nebula contents - Selective import:
import domain X from "file.bast" - Aliased import:
import type T from "file.bast" as MyT - Allowed locations: Root level, inside domains, inside contexts
- 14 definition kinds supported (domain, context, entity, type, etc.)
- Key files:
CommonParser.scala—bastImport(),selectiveBastImport()TopLevelParser.scala—loadBASTImports()post-parse loadingBASTLoader.scala— BAST file reading and content populationAST.scala—BASTImportcase class
- Tests: 4 passing in
BASTLoaderTest.scala - Validation: Integrated into
ValidationPass
- Wraps
ArrayBuffer[CV]for efficient modification - Extension methods:
.toSeq,.isEmpty,.nonEmpty - Do NOT use:
.toList,.iteratordirectly (not available) - Pattern:
contents.toSeq.map { ... }.toJSArrayfor JS conversion
- Scala 3 enum, not case classes
- Get type name:
token.getClass.getSimpleName.replace("$", "") - Extract text:
token.loc.source.data.substring(token.loc.offset, token.loc.endOffset)
- Fields:
line,col,offset,endOffset,source - Always 1-based (not 0-based)
- Delta encoding for BAST: compress by storing differences
Prefer HierarchyPass for maintaining parent context:
class MyPass extends HierarchyPass {
override def process(value: RiddlValue, parents: ParentStack): Unit = {
value match {
case d: Domain => processDomain(d, parents)
case c: Context => processContext(c, parents)
// ... pattern match all node types
}
}
override def result: MyPassOutput = MyPassOutput(...)
}BAST Writer Pattern:
BASTWriterPass(in passes module) extendsHierarchyPass- Uses
BASTWriterutilities (in language module) for byte writing - Sacrifice write speed for read speed
- String interning for deduplication
Updated: Jan 2026 for improved reliability and performance
- Triggers:
main,developmentbranches - Parallelized: JVM/Native/JS builds using matrix strategy
- Timeout: 60 minutes
- Dependency scanning with SARIF upload
- Auto-triggers on PRs and pushes (not manual-only)
- Timeout: 45 minutes
- Fixed artifact paths (was broken in earlier versions)
- Triggers only on Hugo/doc changes (NOT all .scala files)
- ScalaDoc caching for faster builds
- Timeouts: 30min build, 10min deploy
All workflows use JDK 25 (standardized)
Since the sbt 2 upgrade, build outputs live under a central
virtual-FS tree at the repo root (verified empirically — sbt runs with
sbt.io.virtual=true):
target/out/<platform>/scala-<fullVersion>/<artifactName>/…
<platform>∈jvm,sjs1,native0.5(NOTjs/native).<fullVersion>is the full Scala version (scala-3.9.0), NOT a-3binary tag — so a Scala patch bump (3.8.4 → 3.8.5 / 3.9.x) DOES move every hardcoded path.<artifactName>is themoduleName(riddl-utils,riddl-lib,riddlc, …).
Verified real paths:
- native riddlc:
target/out/native0.5/scala-3.9.0/riddlc/riddlc - native lib:
target/out/native0.5/scala-3.9.0/riddl-lib/libriddl-lib.a - JS opt:
target/out/sjs1/scala-3.9.0/riddl-lib/riddl-lib-opt/main.js - JVM stage:
target/out/jvm/scala-3.9.0/riddlc/universal/stage/bin/riddlc - scoverage:
target/out/jvm/scala-3.9.0/<artifact>/scoverage-report/scoverage.xml
Files that hardcode these (update on any full-Scala-version bump):
scala.yml (RIDDLC_PATH, artifact upload paths), coverage.yml +
.sonarcloud.properties (scoverage), release.yml (native cp + JVM stage
zip), Dockerfile (stage copy).
Quick search: grep -rn "target/out/.*scala-3\." .github/ Dockerfile .sonarcloud.properties
target/out must NOT be cached, and an earlier version of this list wrongly
said scala.yml caches it. Restoring sbt 2 build outputs into a fresh checkout
leaves sbt believing the meta-build is already built, so project/Dependencies .scala never contributes its symbols and build.sbt collapses with dozens of
Not found: V / Not found: Dep plus an Append ambiguity on a line nobody
edited — a cascade pointing everywhere except the cause. A cache written by a
GREEN run is exactly as poisonous as a stale one: the rule that held was not
"stale cache" but "every cold build passed, every cache-restoring build failed",
including on markdown-only commits. Dropping restore-keys does NOT fix it —
that only makes one run cold by accident. scala.yml:165 carries the ban and
its reason; Coursier/ivy2 dependency caches are separate and fine.
sbt-ossuminc Version Policy:
- sbt-ossuminc 3.0.x defaults to Scala 3.8.4; riddl 2.0 overrides to
3.9.0 via
V.scala+With.Scala3.configure(version = Some(V.scala))per module (theCrossModule(...)axis arg alone does NOT change the effective scalaVersion — With.typical overrides it). - A Scala version bump changes the
scala-<fullVersion>path segment everywhere above — grep and update.
Any change to the fastparse parser MUST have a corresponding change to the EBNF grammar.
The EBNF grammar at language/src/main/resources/riddl/grammar/ebnf-grammar.ebnf
is the canonical specification of RIDDL syntax. It is validated by a TatSu-based parser
that runs in CI on all **/input/**/*.riddl test files.
When modifying the fastparse parser:
- Update the corresponding rule(s) in
ebnf-grammar.ebnf - Run the EBNF validator locally:
cd language/src/test/scalajvm/python pip install -r requirements.txt # first time only python ebnf_tatsu_validator.py
- Ensure both parsers accept the same inputs
- CI will fail if the EBNF parser cannot parse test files that fastparse accepts
This ensures the documented grammar stays in sync with the actual implementation.
There is NO GBNF any more. The bundled 258-rule riddl-grammar.gbnf, its
generator (ebnf_to_gbnf.py), its validator and its overrides were deleted
2026-08-20 on Reid's ruling ("We could do without the reflectivity tax, it's
high enough without it"), and Grammar.loadGbnfGrammar* went with them —
legitimate only because 2.0.0 had not shipped, so 2.0 IS the major that may
remove public API. A grammar change now touches TWO artifacts, not three;
any instruction to "regenerate the GBNF" is stale. The evidence was a
measurement riddl-generator had already made and written down: llama.cpp's
grammar engine could not run the full RIDDL grammar at a usable speed — an
8-token constrained generation did not finish in seven minutes against seconds
unconstrained — so it was dropped for PERFORMANCE, not quality, and nothing
consumed the bundled file. Constrained decoding survives via JSON-schema-derived
grammars llama.cpp builds itself, needing no file from this repo. Coverage did
not change: the EBNF stays authoritative and TatSu still gates it.
TatSu's nameguard refuses a bare letter token that touches a digit. An
exponent marker written ("e" | "E") reads fine as prose and fails under the
generated parser for 1e3 specifically — nameguard bounds any word-like quoted
literal to a word boundary, so e followed immediately by a digit looks like the
start of a longer identifier. e+3/e-3 work, which is what makes it look like
a sign bug. Write the marker as an inline regex (/[eE]/), the idiom
mime_type and markdown_line already use.
Adding a .riddl fixture is a GRAMMAR-SURFACE change, not just a test
change. A fixture that is an include fragment or intentionally invalid must be
added to INCLUDE_FRAGMENTS in ebnf_tatsu_validator.py, or the CI
ebnf-grammar-validation job exits 1 — on a commit whose Scala suites are green
on all three platforms, because tJVM/tJS/tNative do not run the Python
validators at all. Run them yourself (.venv/bin/python ebnf_tatsu_validator.py) before calling grammar work verified; a green test run
is a claim about the tests you ran, and the gates outside the test runner are
exactly the ones it cannot speak for. Conversely, a fixture in a SKIPPED file
is not coverage — check the validator's own output for a ✓ on the file.
RIDDL is fully reflective by design and necessity: anything that can be parsed MUST also be emitted. So a change to the AST or parser is only half done until PrettifyPass emits the new/changed construct AND a parse → prettify → re-parse round-trip preserves it. "Parses and validates" is half the contract; emit + round-trip is the other half.
When you add or move a construct (e.g. allowing a definition under a new container):
- Confirm
PrettifyVisitor/RiddlFileEmitteremit it. Traversal (HierarchyPass) and dispatch (VisitingPass,Pass.scala) are generic and type-based, so it often "just works" — but prove it, don't assume it. - Add a round-trip test — parse →
PrettifyPass(flatten=true)→ re-parse — asserting the construct survives at the SAME place (not dropped, not relocated). Template:passes/.../prettify/RepositoryDomainScopeRoundTripTest.scala(andIdentifierQuotingRoundTripTest.scala). - Run the FULL suite on all platforms (
tJVM tJS tNative), not just the module you touched. A green partial suite proves nothing when no existing test exercises the new shape.
Also remember BAST (binary AST) is a second serialization surface: a new
AST node generally needs BASTWriter/BASTReader support and a
FORMAT_REVISION bump (see the BAST section).
When implementing new code:
- Write the code
- ALWAYS run
sbt "project <module>" compile - Fix Scala 3 syntax errors immediately
- Then proceed to next step
- Input test files:
language/input/<category>/<file>.riddl - Examples:
language/input/import/import.riddl
Cause: Using Scala 2 syntax
Fix: Use Scala 3 syntax with do/end
Cause: Token is an enum
Fix: Use token.getClass.getSimpleName
Cause: Contents is opaque type with limited extensions
Fix: Use .toSeq extension method
Cause: sbt-ossuminc 1.0.0 API change
Fix: Use With.ScalaJS instead
Cause: Scala 3.8.x limitation — default parameter values in a case
class's first parameter list cannot resolve given instances from a
subsequent using clause in the generated companion apply method.
Fix: Remove the default value. May be fixed in 3.9.x LTS.
Example:
// This fails in 3.8.x:
case class Foo(x: Bar = Bar())(using PlatformContext)
// Fix: remove default (or provide explicit given)
case class Foo(x: Bar)(using PlatformContext)Cause: @JSExportTopLevel on a case class with (using PlatformContext) in a second parameter list. The JS export sees the
context parameter as a non-default parameter after defaulted params.
Fix: Remove @JSExportTopLevel from internal data structures that
don't need to be constructed from JS code.
Cause: System.lineSeparator() returns \0 in Scala.js
Fix: Use PlatformContext.newline instead. Never use
System.lineSeparator() in shared code. The FileBuilder trait
and its entire hierarchy use (using PlatformContext) for this.
- Create directory:
<moduleName>/src/{main,test}/scala/... - Add to
build.sbtusingCrossModule(passV.scala) - Add variants to root aggregation; wire deps per-row with
pDep() - Add platform-specific dirs as needed (see below)
- Shared code:
<module>/src/{main,test}/scala - Platform-specific:
<module>/src/{main,test}/scala{jvm,js,native} - JVM+Native shared:
<module>/src/{main,test}/scala-jvm-native(custom dir; wire withjvmNativeSrc(...)in build.sbt) - Avoid platform-specific APIs in shared code
- Use
PlatformContextfor platform abstraction
- sbt-dynver generates versions from git tags
- Format:
MAJOR.MINOR.PATCH-commits-hash-YYYYMMDD-HHMM - Clean tag:
git tag -a 1.0.0 -m "Release 1.0.0"(novprefix - it interferes with sbt-dynver) - Always run
sbt publishLocalafter tagging to make the new version available locally
Short description (imperative mood)
Detailed explanation of what changed and why.
Focus on "why" rather than "what".
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
- main is both the working branch and the release branch —
commit directly to it. There is no GitFlow and no permanent
developmentbranch (see../CLAUDE.md"Git Workflow"). - Cut releases by tagging
main; CI builds from the tag. - Reach for a short-lived branch only when you want isolation (a throwaway experiment, or work you'd like to review as a diff), then merge and delete it.
- The
developmentbranch is GONE — deleted local and remote on 2026-08-27, having been 0 commits ahead ofmain.old-developmentwas already gone. Do not recreate either; if you find a reference to one, it is stale text, not a branch you failed to fetch. .claude/skills/ship/SKILL.mdno longer prescribes GitFlow (fixed 2026-08-27). It had told every release to fast-forwardmainfromdevelopmentand to merge back afterwards; both were no-ops or contrary to policy from 1.30.0 on, and were skipped by hand each time. It now says: ship a FINAL release frommain; when the work lives on a release branch, merge that branch intomainand tagmain, never the branch; delete the branch afterwards. Release CANDIDATES remain the documented exception and may be tagged on the branch — see the/rcskill.- A stray
helpgit tag (a typo'dgit tag help, pointing at a 2019 commit) was deleted local and remote the same day. It had sorted to the top ofgit tag --sort=-v:refname, so it LED the tag list whenever anyone worked out the latest release.
# Compile specific module
sbt "project bast" compile
# Run tests for module
sbt "project language" test
# Build npm package
./scripts/pack-npm-modules.sh riddlLib
# Format code
sbt scalafmt
# Check all platforms compile
sbt cJVM cJS cNative
# Run all tests
sbt tJVM tJS tNative
# Package riddlc executable
sbt riddlc/stage
# Result: riddlc/jvm/target/universal/stage/bin/riddlcCore parsing/validation logic lives in RiddlLib (shared trait +
companion object) at riddlLib/shared/.../RiddlLib.scala. This is
usable on JVM, JS, and Native. The JS-only RiddlAPI.scala is a
thin facade that delegates to RiddlLib and converts results to
plain JavaScript objects.
- Cross-platform code: Use
RiddlLib.parseString(...)etc. with agiven PlatformContextin scope (provided by each platform'scom.ossuminc.riddl.utils.pc) - JS facade:
RiddlAPIadds@JSExportmethods,getDomains,inspectRoot, and JS-only helpers likeformatErrorArray
CRITICAL: All methods that accept an origin parameter use
RiddlLib.originToURL() to convert strings to URLs.
def originToURL(origin: String): URL =
if origin.startsWith("/") then
URL.fromFullPath(origin)
else
URL(URL.fileScheme, "", "", origin)
end ifWrong (Scala 2 style):
lines.foreach(pc.log.info) // Error: type mismatchCorrect (Scala 3):
lines.foreach(line => pc.log.info(line))Reason: Scala 3 doesn't automatically convert by-name parameters (=> String) to function parameters (String => Unit).
When code needs to be shared between JVM (riddlc commands) and JS (RiddlAPI), put it in utils/shared/:
Example: InfoFormatter is used by both:
commands/InfoCommand.scala(JVM)riddlLib/RiddlAPI.scala(JS via@JSExport)
// utils/src/main/scala/com/ossuminc/riddl/utils/InfoFormatter.scala
object InfoFormatter {
def formatInfo: String = {
// Build info formatting logic
}
}After staging (sbt riddlc/stage), the riddlc executable provides:
riddlc help # Show all available commands
riddlc version # Version information
riddlc info # Build information
riddlc parse <file> # Parse RIDDL file
riddlc validate <file> # Validate RIDDL fileCommands can load options from HOCON config files.
Executable location: riddlc/jvm/target/universal/stage/bin/riddlc
lazy val mymodule_cp = CrossModule("mymodule", "riddl-mymodule")(JVM, JS, Native)
.dependsOn(cpDep(utils_cp), cpDep(language_cp))
.configure(With.typical, With.GithubPublishing)
.settings(
description := "Description here"
)
.jvmConfigure(With.coverage(50))
.jsConfigure(With.ScalaJS("RIDDL: mymodule", withCommonJSModule = true))
.nativeConfigure(With.Native(mode = "fast"))
lazy val mymodule = mymodule_cp.jvm
lazy val mymoduleJS = mymodule_cp.js
lazy val mymoduleNative = mymodule_cp.nativeThen add to root aggregation: .aggregate(..., mymodule, mymoduleJS, mymoduleNative)
Note: Use With.ScalaJS(...) for sbt-ossuminc 1.0.0+, not With.Javascript(...)
- Extend
Pass,DepthFirstPass, orHierarchyPass - Implement
process()method for each AST node type - Declare dependencies via
def requires(): Seq[Pass] = Seq(...) - Override
result()to return yourPassOutputsubclass - Add to standard passes or invoke explicitly
- Define options:
case class MyOptions(...) extends CommandOptions - Define command:
class MyCommand extends Command[MyOptions] - Implement:
def name: Stringdef getOptionsParser: OptionParser[MyOptions]def run(options: MyOptions, context: PlatformContext): Either[Messages, PassesResult]
- Register with
CommandLoaderif using plugin system
Each subsection is a topic, not a serial number — add new entries to the right group rather than appending to a list.
- VERSION is a single integer (
VERSION: Int = 1) and stays at 1 until the schema is finalized for external users. - FORMAT_REVISION must be incremented whenever a BASTWriter
change produces output that an older BASTReader can't read
correctly: new statement subtypes, wire-format changes,
reordered fields, new node tags. Constant lives in
language/shared/.../bast/package.scala. - Location comparisons use offsets, not
line/col. writeContentswrites a COUNT and trusts an unrelated caller elsewhere to write the ITEMS — a contract that has now failed four times. The reader'sreadContentsDeferredthen consumes N nodes that were never written and the stream desynchronizes. It bites any node holding children the generic traversal cannot reach:BASTImportandInteractionContainer(sequence/parallel/optional) areContainerbut notBranch— noid, so they cannot beDefinitions, soBASTWriterPass.traversefell through to thewm: WithMetaDataarm, which callsprocess()(header + count) and never descends.InvariantBlockis worse: its statements sit in a FIELD of a node that is not even aContainer, andwriteInvariantemits the predicate INLINE, so the deferred items must land afterrequiresrather than after their own count. The tell is a node count going DOWN when a construct is ADDED. Every fix so far has taught the specific missing traversal path rather than making the mismatch structurally impossible; if a fifth instance turns up, that is the signal to change the contract itself (a writer that returns "how many items I still owe", and a chokepoint refusing to finalize a node until it is satisfied). Adjacent, same sweep:writeRelationshipwrote no discriminator byte at all while the shared-tag reader unconditionally reads one, so every relationship misread its own location as its dispatch byte — latent sincerelationshipfirst became serializable.- BAST carries real positions;
positionsKnownis how a consumer detects when it cannot.writeLocationdelta-encodes the REAL offset andAthas always derived line/col lazily fromsource.lineOf(offset)— so the format was never the problem. The defect was the READER attaching aBASTParserInputwhose line index is SYNTHETIC (line L starts at L×10000) and then feeding it real offsets, putting everything under offset 10000 on line 1 at col = offset. Pass real sources viaBASTReader.read's optionalsourcesmap. When they are absent,At.linereturns 0 (At.scala:43) — unrepresentable as a 1-based position, and deliberately so: a confident wrong answer is worse than an absent one, because the old plausible line 1 was good enough for a Problems pane to point at and impossible to detect. - BASTImport in HierarchyPass —
openBASTImport/closeBASTImporthooks plustraverseBASTImportContents(bi). AllPassVisitorimplementors must define these (even as no-ops);BASTImportextendsContainerbut notBranch, so without the hooks it falls through and its contents are never visited.
-
The predefined
Riddlstandard module (language/.../ PredefinedModule.scala) is readable RIDDL held in a string constant, parsed ONCE and cached as a singleton. It holdstype Drain is Anythingplus the two terminatorsBottomlessPit(sink, inlethole) andForeverEmpty(source, outletvoid), directly in the module (no domain/context —ModuleContentsisNebulaContents). NEVER inject it into a user'sRoot.contents. The ONLY seam isSymbolsPass.postProcess, which seedspredefinedSymTab/predefinedParentage— separate maps onSymbolsOutputthat lookups fall back to. Keeping them separate is load-bearing: several public APIs (AnalysisResult.domains/streamlets/…,UseCaseWitnessPass,foreachOverloadedSymbol) ENUMERATEparentage/symTab, and seeding the shared maps leaks the standard library into "all X in the model". A user definition with a colliding name wins structurally (the user's table is consulted first) — no ambiguity, no message. It also holdsEnvelope(2.0.0-rc.10+), the record carrying a message's metadata, selected byoption message_envelope("Riddl.Envelope"). Fields are the CloudEvents v1.0 context attributes, with ONE forced deviation: CloudEventsidis spelledmessageId, because RIDDL requires identifiers of >= 3 chars andiddraws a StyleWarning — and the standard module must validate clean. There is deliberately nodatafield: in RIDDL the payload IS the message, already modelled and typed, so Envelope is the metadata AROUND a message rather than a wrapper containing one. The option is scope-inherited (Seq.emptyvalidParents — resolved by walking UP the parent chain), so declaring it on a context covers every entity in it. Opt-in by design: RIDDL specifies meaning, not representation, so how the attributes ride (CloudEvents JSON, Kafka headers, gRPC metadata, or nothing for an in-process call) stays the generator's choice. It also holdsGeneratorError(origin,kind,detail,occurredAt) — the shape every generator sends to the inlet markedoption error-sink, for a saga whose undo retries were exhausted, an adaptor's dead-lettered message, a projector's poison event. The name states the SOURCE (it wasHardErroruntil 2026-08-01, andOperationswas withdrawn from the module at the same time): the standard library owes a generator the SHAPE of a notification and a way to NAME its destination, nothing more, so there is deliberately no predefined receiver. An error-sink inlet must accept it — directly, or via an alternation including it so a model can route its own error messages to the same inlet — else it is an Error, because a generator has nothing it can send there. A missing error-sink is aMissingwarning, NOT a CompletenessWarning:isIgnorableisseverity < CompletenessWarning, so Completeness asserts STRUCTURAL incompleteness (unfed inlets, unreachable sinks) while "has not said where hard errors go" is the "has no author" family. Emitting it as Completeness turned thirteen unrelated suites red on models that were otherwise fine. Both records are legitimately unused inside the module — that is the design, not a defect — soPredefinedTerminatorsTestasserts exactly which ones are unused (GeneratorErrorandEnvelope, by name); widen that list when adding another, never loosen it. All exemptions (A31 cardinality, unattached/isolated/reachability, handler completeness) test REFERENCE IDENTITY viaPredefinedModule.isPredefined, never a name. A port typedAnythingis connector-compatible with every type (validateConnector).language/input/predefined/riddl-standard-module.riddlis a verbatim copy so the CI grammar validators cover it;PredefinedModuleSourceTestfails if the copy drifts from the constant. -
on other as x [: <envelope>](A57) — binds the residual message's ENVELOPE, not a message: the clause names none.OnOtherClausegainsbinding: Option[Identifier]andenvelopeType: Option[TypeRef], both declared BEFOREcontentsand WITHOUT defaults (@JSExportTopLevelneeds defaulted params trailing — same rule as A55).x's type is the ascription when written, else whateveroption message_envelopenames in scope (ResolutionPass.envelopePathFor), soxandx.sourceboth resolve. The ascription RESTATES the option, it never overrides it. Three Errors incheckOnOtherBinding: a binding with no envelope in scope, an ascription with no envelope in scope, and an ascription that contradicts the option. A per-clause override would mean reading one clause tells you nothing about its siblings — exactly what scope inheritance prevents. The type is BARE after the colon — no keyword.messagewould be untrue andtypeis correct only because it is vacuous; the colon already says a type follows. Both spellings parse elsewhere in RIDDL, so this is a choice about meaning, not consistency.OnOtherClausemust NOT joinOnMessageLikeClause— that is what keeps it out ofUseCaseWitnessPass's index (see its comment); a clause matching every type would witness every step. Rendering lives inDeclaration.ascription, NOT in the clause'sformat. The prettifier reads the former viaopenDef; putting it onformatalone makes prettify silently DROP the binding on every round trip. That shipped as a bug for exactly one commit and is whatOnOtherEnvelopeRoundTripTestpins. -
Correlations in projectors (A70, release/2) —
correlation <id> by <k>[, <k>…] yields command <C> is { <handler> } times out after "<duration>" { <statements> } [with { … }]. A keyed accumulation of several events into one command the Repository handles. Semantics live in../RIDDL-Computational-Model.md§6.2 and §6.5–§6.8 and are NOT restated in the code — that document is the authority for any lowering decision.yieldsnames a COMMAND (Reid, 2026-08-12; it wasyields record <T>for one day). A projector's only output is a change to a repository, and a repository is changed by handling a command. The record form could never work: a handler clause takes amessageRef, which is the four real messages only (A9b), so noonclause could name what the correlation produced — which is why the first design had to INFER acceptance from a command that held the record. Naming the command deletes the inference. Enforced in two places on purpose: the wrong KEYWORD dies in the grammar (commandRefinProjectorParser, soyields record Rdoes not parse), whileyields command Foonaming a non-command is an Error fromValidationPass— the only place with the resolved referent, and a parse-timeerror()there would preempt the whole pass chain. The timeout clause is MANDATORY and is grammar, not metadata. It was designed as an optionalelseblock plusoption timeout(…), which left one question unanswerable — what an unbounded correlation means — and needed three warnings to paper over it. Reid's ruling made it mandatory, which deletes all three states instead of diagnosing them. The reasoning is entity intentions again: §4.2 calls options advisory, and a bound that MUST fire a block is not. Consequences: no timeout inheritance from the Projector (nothing is left to default, soRecognizedOptionsis untouched by this feature), the duration is aLiteralStringstill duration-VALIDATED viaDefinitionValidation.checkPreciseDuration(shared with thetimeoutoption, sotimes out after "banana"is an Error), and an empty block is a parse error —do "nothing"is the discard idiom. Keys are stored AS WRITTEN and never canonicalized:Definition.equalsis structural and §6.5 makes identity the full tuple, so sorting them would silently equate two different declarations. This is the exact OPPOSITE ofEntityIntention.canonical, which sorts so that write order cannot make two identical entities compare unequal. Prettify, BAST and JSON all preserve order and each has a test asserting it. The effect ban binds FOLDS only. Fold purity is what makes re-runs safe (§6.5); the timeout block exists to have an effect (§6.7), so banning effects there would leave it useless.CorrelationTestpins both sides — without the "legal in the timeout block" case, a ban wrongly applied to the whole correlation would still look green. Two pre-existing projector checks (needs its own record type; exactly one handler) assumed folds live in one top-level handler and are SKIPPED when correlations are present; a projector without them validates as before. The repository-accepts-it rule is a COMPLETENESS warning, not an Error (Reid, 2026-08-12, overriding A70 as written): a repository lacking theon commandclause is under-specified, not self-contradictory. A???repository is exempt, per the standing???ruling. Becauseyieldsnames a command, the test is plain identity on the resolvedType(eq, not by name — two contexts may each declare aRecordFulfillment). The unemitted-event warning does NOT useMessageFlowPass— depending on it would reorder the standard passes.checkCorrelationEventSourcessweeps the root once inpostProcess, GATED on a correlation existing. AnOutlettyped with the event counts as emitting it, so a???source that declares the port is not reported; adaptor translations deliberately do not count. -
Processor instance identity (2.0, release/2) —
Id(P),self,initiate,terminate, and structuraltelladdressing. Five constructs, one gap: RIDDL could describe processors but not INSTANCES of them.Id(P)names any Processor, not just an Entity (Adaptor, Context, Entity, Projector, Repository, Streamlet). The keyword formId(entity Order)is CANONICAL and the bareId(Order)is the shorthand —UniqueId.kindKeywordstores the keyword as written (aString, not an enum, so prettify is byte-exact without a mapping table), andTypeValidationmakes it tell the truth: a keyword contradicting the resolved referent's kind is an Error, because a wrong keyword is worse than no keyword — a reader believes it. Keyword-name disambiguation is a RIDDL-wide idiom and a bareOrdercould be a context, a message or an entity, which is why the keyword was kept rather than deprecated.Id(P)is RUNTIME instance identity and is NOT the definition ULID of CM line 2523, which is model-time identity of a definition. Two instances ofOrdershare one definition ULID and never share anId(Order).isAssignmentCompatibleis deliberately UNCHANGED (still compatible withString_/Pattern): the value is opaque and system-generated, so a BUSINESS key belongs inon init's parameters and lives in state.self's type is a synthesizedAggregation, and that is load-bearing. Because the type is an ordinary record,let me = selffollowed byme.idresolves through the SAMEValueRefpath walk every other value uses — so no resolution rule anywhere has to knowselfexists. A bespoke node would have needed special-casing at each of those sites. The consequence is that the type is not user-nameable (self.idisId(Order)in an Order handler andId(Shipping)in a Shipping one), solet me: T = selfhas noTto write andselfis not assignable into a message field — passself.id.SelfValue.fieldNamesis a CLOSED set (id,version); adding one is a language change. The admission test is runtime-only: anything a generator can know statically it should inline, which is whyversionis in andisClusteredis not (filed separately).enclosingProcessorOfterminates atFunctionANDSaga— a Saga sits inside a Context routinely, so without the second terminatorselfin a saga step silently typed as the enclosing Context's identity.initiatesupplies the invocationon initalways lacked — it does NOT add a second way for an instance to exist. Construction still completes only whenon initfinishes; CM line 999's "activate on first message" is rehydration, not creation. Without it noId(P)value could ever come into being and the whole addressing story would have been inert.initiateis a VALUE (it yields the newId(P)) andterminateis a STATEMENT (termination produces nothing). That asymmetry is why their bans live in validation and not the parser:valuecarries noStatementsSetto gate on, so parser-gating one and validating the other would split one rule across two layers.on init/on termgained parameter lists; arity and argument types are checked inValidationPass(checkInitiate/checkTerminate), never the parser, because a parse-timeerror()preempts the whole pass chain. Both fold an Entity's STATE handlers in when looking for the clause, exactly asvalidateAskdoes —on initcommonly lives inside aState. Aninitiatewhose id is never subsequently referenced draws a plain Warning — NOT an Error, and NOT gated behindshowCompletenessWarnings. Three reasons, recorded so this is not re-litigated: a self-terminating worker legitimately has an unused id and an Error would make that pattern unwritable; RIDDL specifies MEANING, so an unstated fate is under-specification (which warns) rather than self-contradiction (which errors); and it is ungated because, unlike a missing tell address, this is locally decidable from the clause body alone. The work is the escape-route analysis, not the message: an id escapes by beingsetinto state, passed as atellargument, passed toterminate, yielded in an event, orputto a repository, and the sweep must be conservative enough that no legal model is rejected.UnusedInitiateIdTestpins all five routes plus the nested-whencase.terminate <target> [with (args)]names an INSTANCE, andtargetis a VALUE typedId(entity E)(Reid, 2026-08-15).TerminateStatement.target: ValueREPLACEDprocessor: ProcessorRef— the old form said which KIND of thing ended, never which one, soterminatewas the one rc.14 construct riddlg could not lower at all (it emitted anAI FILLmarker rather than guess, correctly:terminateDESTROYS). The entity is DERIVED from the target's type, so ref and id can never contradict and no truth-check is needed — contrastUniqueId.kindKeyword, which needs exactly one. Arguments sit behindwith (…), not bare parens:terminate order.id("x")reads as a call onid, andwithis the established idiom (morph … with,require … with). Empty list ⇒ nowithclause at all;terminate t with ()parses and prettifies away.on term's parameters are pure PAYLOAD. A leadingId(...)parameter was an addressing convention detectable only BY POSITION — riddlg asked whether address and payload were distinguishable in the AST and the honest answer was no. They are now separate fields.selfis live for the whole clause body, so a clause that needs the instance it is ending readsself.id; nothing was lost. The asymmetry withinitiateis the design, not an inconsistency:initiatenames a TYPE (the instance does not exist yet) and yields an id;terminateconsumes an id and yields nothing. Both are ENTITY-ONLY, and that is an EXPLICIT check, never a consequence of the type system.Id(P)KEEPS its 2026-08-13 widening to all six processor kinds, because a singleton'sIdis how you SEND IT MESSAGES (Reid, 2026-08-15) — it denotes the singular DEPLOYMENT, and addressing it means "select the right shard/partition and forward", the singleton being treated as a whole despite a clustered arrangement. SoId(context C)is a perfectly good value that is simply not a legal thing to end, and onlyreportNotInstantiablesays so. Do not "simplify" this by narrowingId. Two Errors incheckTerminate: the target's type is not aUniqueId, and the target is anIdof a non-Entity. It stays SILENT when the type is undeterminable (a barelet n = 5, an unascribedprompt(…)) — reporting there would be reasoning from absence, the same conservative rule A20's unascribed-hole warning follows. NotevalueTypeExprdoes NOT surface alet's declared PREDEFINED type (let n: Integer = 5yieldsNone), which is pre-existing and why the "not an Id" test uses bareself.resolveIdTargetneeds TWO lookups and the second is not optional. The refMap holds only paths that were WRITTEN, butvalueTypeExprSYNTHESIZES aUniqueIdforinitiateand forself.idcarrying a fully-qualifiedpathOf(p)that has no refMap entry — so a refMap-only lookup made everyterminatewhose target came frominitiateorselfresolve toNoneand skip its checks in silence. Falls back tosymbols.lookup. Found by instrumenting, not by reading. The resolved-identity lesson the deletedon termcheck carried is NOT lost: it lives on inisAddressFieldFor.- Addressing is STRUCTURAL: the address is the message's field typed
Id(target), found without annotation;by <field>only DISAMBIGUATES when more than one field qualifies. Candidates match by resolved identity (eqthrough the refMap), never by the path's last segment — two entities namedOrderin different contexts must not collide, and the name-matching version turned a legal model into a false ambiguity Error. The field'sUniqueIdmust be looked up with its OWNINGTypeas the refMap key's parent (Passpushes aType— aBranch— for its own children), which is whyfieldsWithOwnercarries the owner along. Zero candidates is a CompletenessWarning and only for an Entity target: an entity is the only multiply-instantiated processor, and the corpus holds 7,556tells against 7Id(...)-typed fields, so an Error would have condemned essentially every model that exists. Ambiguity IS an Error — it is a contradiction, not an omission. The candidate test follows ALIAS CHAINS but never NESTING (Reid, 2026-08-14). A field typedOrderId, wheretype OrderId is Id(Order), IS an address — that alias is riddl-models' documented house style, and untilccd278c00isAddressFieldFormatchedUniqueIdalone, so it recognised only the rare inline spelling and misfired on the common one (72 of 86 distinct findings in reactive-bbq were false; it aborted theircheckAll). Butresult R is { thing: ThingBase }, where the NESTED record carries the id, stays flagged: descending into an aggregate's fields is an unbounded search — a record holding a record holding a record — with no principled stopping point, so the id must be a field of the record actually named. Renaming is followed; containment is not. Both alias walks carry a visited list, and the reason is a real crash:type A is B/type B is AsentfieldsWithOwnerinto infinite recursion in rc.14 (java.lang.StackOverflowError, reproduced against the released binary), surfacing as[severe] Exception Thrownwith no line number. Reference identity (eq), NOT aSet/containsguard —Definitionoverridesequalsstructurally, so a set would fuse two distinct identical alias declarations and truncate a legitimate chain. Fixing the alias case cost the corpus 49 Errors it had been hiding, in 16 of 189 models — the fourth reminder that a green corpus is evidence about the corpus. All 49 were corpus defects in three classes: genuine two-id ambiguity needingby, actor fields legitimately of the same entity (identityId+suspendedBy) also needingby, and wrong-entity aliases (type TaskId is Id(NurseShift),type MemberId is Id(Enrollment)) that notellhad ever exposed. initiate/terminateare effects — banned in a function body (pure, A26) and inon activate/on passivate(must be side-effect-free), and in a correlation fold (purity is what makes re-runs safe, A70/§6.5). The fold ban lives in exactly ONE place (validateCorrelation), not duplicated intocheckInstanceEffectScope, so a fold offender is never double-reported. Every ban is wired intocheckStatementScopes, notvalidateStatement— the latter never sees statements held in a FIELD (when/match/foreach), the trap two tasks of this plan fell into.- BAST: value tags 8 =
Initiate, 9 =SelfValue; statement sub-kind 20 =terminate. Landed atFORMAT_REVISION15; sub-kind 20's PAYLOAD then changed at revision 18 (2026-08-15) — it now begins with awriteValuewhere it began with awriteProcessorRef. The two are not interchangeable, so an older reader handed these bytes MISALIGNS rather than failing cleanly, which is the whole reason the revision gate exists. - JSON:
TerminateStmtDto'sprocessor/processorKindpair became a singletargetvalue at the same time.JsonModel's readers reject no unknown keys (BACKLOG § 1), so a producer still emitting the old pair has them SILENTLY DROPPED and gets a nulltarget— recorded on the DTO, because a stale example in a machine-facing document is a data-loss bug.
-
A new
Branchnode breaks three things silently — all found building A70, none caught by the compiler:Containment.of(AST.scala) is an exhaustive match overBranchwith no fallback arm → runtimeMatchError, not a compile error.Pass.traverse's genericcase branch: Branch[?]walkscontentsONLY. Statements held in a FIELD (asCorrelation.timeoutStatementsandSagaStep.do/undoStatementsare) need their own case BEFORE that arm, or they are never resolved and never validated — the model validates clean while naming definitions that need not exist.HierarchyPassdeliberately does NOT do this: its visitors emit field-held statements themselves, in the position the syntax requires.VisitingPass.openContainer/closeContainerend incase _: Definition => (), so a new node falls through in silence. Also rememberPrettifyVisitor.keyword, whose fallback is the string"unknown".
-
Typed holes (A20, release/2) —
prompt("...") as <type>ascribes a type to an AI-computed value: the type is known and checkable at compile time, the computation is prose an AI fills in at generation time. It is the seam between RIDDL's deterministic tier and its AI tier.PromptValue(already the nodeprompt("...")produced) gainstypeEx: Option[TypeExpression]; unascribedprompt(...)is unchanged and still valid. Legal in every position an ordinaryValuecan occupy —let,constant, a constructor argument,set, and awhencondition (which must resolve toBoolean) — with either a predefined type or a declared alias. The ascription's type reference RESOLVES, like any other TypeExpression (2026-08-15 whole-branch review) —ResolutionPass.resolveValue'sPromptValuearm used to say "no references" and do nothing, soprompt( "x") as Nonexistentvalidated clean while naming a type that need not exist. It now calls the sameresolveTypeExpressionevery other TypeExpression position uses, which recursesCardinalitywrappers for free and records the resolved Type inusedBy, so a Type named ONLY by an ascription is not wrongly flagged unused. The ascription RESTATES the position's already-known type; it never OVERRIDES it.let x: Real = prompt("...") as Stringis a validation Error (contradiction), not a coercion — checked by the samecheckValueTypeasetalready used. The comparison is deliberately SYNTACTIC, not resolved-type, mirroring A57:constant G: Real = prompt("...") as Score(type Score is Real) is still an Error even though the alias's underlying type isReal, because RIDDL treats a declared alias as a distinct name, not a transparent synonym — a resolved comparison would swallow exactly the contradiction this rule exists to catch.typeAscriptionName(ValidationPass) does the comparison; it RECURSES through the fourCardinalitywrappers (discarding them rather than folding them into the name) and compares only the LAST path segment on both sides — both fixed 2026-08-15 after review found false positives onlet x: OrderId = prompt(…) as OrderId?and on a qualified restatement (let x: Common.OrderId = prompt(…) as Common.OrderId), and a false negative where two differently-aliasedOptionals compared equal bykindalone. Comparing only the last segment is a KNOWN, accepted limitation shared withcheckOnOtherBinding: two differently-scoped types sharing a simple name compare equal here, because the check stays syntactic rather than resolving through the symbol table. Aconstantwith apromptvalue needs no ascription at all, because the constant's own type declaration already supplies it —constant G: Real = prompt("...")is the complete, idiomatic form; addingas Realis legal but redundant. Where nothing else states a type (a barelet x = prompt(...), a bare constructor argument, awhencondition with no other source of truth) the ascription is the ONLY source of the type — there it is doing real work, but it is still describing what is already true about the hole, never coercing it. The seam warning for an UNASCRIBED hole is deliberately CONSERVATIVE: it fires only at call sites that already carry an expected type to compare against (let,constantviacheckValueType), not at constructor arguments, since nothing wires an expected type there today. Nor atput,return,require … with, or a call/constructor argument — filed to BACKLOG § 1 as a decision to revisit, not a ruling; those positions can legally carry an ascribedprompt(...)and nothing checks it today.PromptValue.format'sascriptionFormatandRiddlFileEmitterwere the SAME "dispatch written twice" risk documented under Total Dispatch below, and 2026-08-15's review fix makesRiddlFileEmitter.emitValuethe ONE emitter-level dispatch — it routes aPromptValueascription throughemitTypeExpression, the total dispatch every other TypeExpression position already uses, for the four positionscheckPromptAscriptionvalidates (constant/let/set/when).ascriptionFormatremains, narrower, for contexts the emitter cannot reach —.format-based error messages, and aPromptValuenested inside aConstructor/Call/Initiate/TerminateStatementargument (also filed to BACKLOG § 1). Before the fix,ascriptionFormat'scase other => other.formatfallback mis-rendered several TypeExpression shapes as unparseable source: an enumeration, a table, an entity reference, and a parameterized predefined type all round-tripped to text riddlc rejects. Historical correction (2026-08-15): an earlier version of this entry claimed the spurioustypekeyword bug (as OrderIdrendering asas type OrderId) meant the string "does not mean the same thing on re-parse" — false.aliasedTypeExpressiondefaults an omitted keyword to"type"too, so both spellings parse to an AST-IDENTICAL node; the defect was cosmetic (an un-authored keyword in emitted source), never semantic.ascriptionFormatstill strips it and RECURSES throughOptional/ZeroOrMore/OneOrMore/SpecificRangewrappers rather than falling back to.format, or the same cosmetic bug resurfaces one level down (as OrderId?→as type OrderId?).Currencycannot appear bare in an example — it is a predefined type requiring acountryargument (Currency(USD)), soprompt("...") as Currencydoes not compile, and it does NOT resolve toRealor anything else underneath — it is its own distinctPredefinedType. UseReal,String,Boolean,Score, or a declared alias in examples instead. BAST/JSON: ridesFORMAT_REVISION18 (the bump numeric literals already spent), not a new bump — see the FORMAT_REVISION note in BACKLOG § 2 for who claims 18 next. -
On-clause message binding (A55, release/2) —
on foo: command Foo { … }optionally binds a local name to the handled message. The:is ordinary TYPE ASCRIPTION (same rule aslet x: T = …andp1: String), so the parser reusesHandlerParser.maybeName.binding: Option[Identifier]sits onOnMessageLikeClauseand BOTH concrete nodes, declared immediately afterfromand without a default —@JSExportTopLevelrequires defaulted params to be TRAILING andcontents/metadataare defaulted.id/formatstay derived frommsg. Barefoodenotes the whole message;foo.fieldis an ordinary path walk. See "Validation Specifics" for how it resolves. -
Entity intentions (2.0.0-rc.10) — six keywords written BEFORE
entity, in three INDEPENDENT groups, mutually exclusive within a group: role (aggregate), consistency (consistent|available), persistence (event-sourced|persistent|transient).Entity.intentions: Seq[EntityIntention]; enum + companion atAST.scala:4144. They are grammar, not options, on purpose. They werewith { option event-sourced }until 2.0, but the Computational Model §4.2 calls options advisory ("honored if possible"), and a hard Error keyed off advisory metadata is a category error — seecheckEventSourcing. The oldoptionspellings still parse, deprecated.persistentreplaces the uninformativevalue. Two from one group is an Error, not a parse failure, so the message can name both.event-sourcedsits in the persistence group because it IMPLIES persistent. Any order parses; the parser stores them viaEntityIntention.canonicalbecauseDefinition.equalscompares this field — write order must never make two identical entities compare unequal. Prettify emitscanonicalOrder. Four event-sourcing rules are Errors (ValidationPass.scala:1865), because replay must reproduce the same state changes: R1 every handled command declaresyields; R2 every yielded event has anon eventclause; R3/R4 noset/morphoutside handling one of the entity's OWN events. R1/R2 read theyieldsDECLARATION on the command's type, neveryieldstatements in a body. Two traps when migrating a model:yieldsexists ONLY on the kind-first form (command X yields event Y is {…}), so type-first commands must be reshaped; and R3 forbidssetinon initwhile an empty body is a parse error, so the idiom ison init is { yield event Created }plus anon event Createdclause that does the mutation. -
Unified processor model (2026-07-26, release/2) — every
Processor(Context/Entity/Projector/Repository/Adaptor + the genericprocessorkeyword) is port-bearing:Inlet/Outletare inOccursInProcessor, andWithInlets/WithOutletsare mixed into theProcessorbase. Each carriesascribedShape: Option[StreamletShape](None ⇒ derived from arity viaProcessor.arityShape/effectiveShape). Surface:[<intention>] context <id> [as <shape>] is {…}andprocessor <id> [as <shape>] is {…}. The old streamlet shape keywords are deprecated aliases;StreamletShape.fromKeywordcanonicalizes synonyms (cascade→Flow, fanin→Merge, broadcast/fanout→Split).Contexthasintention: Option[Intention](Application/External/Gateway/ Service). Shape/intention now participate inDefinition.equals, so keep theirlocatAt.emptyon every surface (parser/BAST/JSON). -
Numeric literals (2026-08-15,
release/2) —NumericLiteral(loc, text)in theValueandComparandunions, accepting[+-]? digits [. digits] [(e|E) [+-] digits]. No digit separators, no radix prefixes. The text is stored AS WRITTEN and that is the whole design.1.50,007,+3and2E+8are not recoverable from a parsedLong/BigDecimal, so a parsed payload would make prettify diverge from source on first use. Same reasoning asUniqueId.kindKeywordand correlation keys. It also keepsBigDecimaloff the Native and JS paths, and needs one BAST tag (value 10, comparand 3) rather than two. JSON stores it as aujson.Str, never aujson.Num—ujson.Numis a Double and would silently turn1.50into1.5. A JSON-identity fixed-point test cannot catch that, because a consistently-mangled value is still a perfect fixed point; assert the text.count > 5now parses, REVERSING A28's deliberate narrowing.Comparandwas ref-only on purpose, "so magic-constant comparisons cannot be constructed at all" — Reid reversed it 2026-08-14 on the evidence that the whole 189-model corpus contained exactly ONE constant, so the rule had no uptake to protect (plausibly because naming a number meant quoting it). The intent survives as a StyleWarning whose population started at zero.count > trueis still a parse error: booleans are atoms, not comparands.Integeris signed,Wholeis>= 0,Naturalis>= 1(Reid, 2026-08-14). Until then the three had NO definition anywhere — no scaladoc, no language reference, no Computational Model entry — so the check had nothing to enforce. They are documented atAST.scala:2518-2530; a check cannot enforce a rule the language never states. Literals are held STRICTER than references, deliberately.NumericType.isAssignmentCompatible(:1912) lets ANY numeric accept any other and STAYS that way —let x: Nat = someRealFieldis unchanged. Only a literal, whose value the compiler can see, is range-checked (checkNumericLiteralConformance).NumericLiteralConformanceTestpins the loose side so a later "tidy-up" ofisAssignmentCompatiblereddens instead of silently changing behaviour far beyond literals.Bool extends IntegerTypeExpression extends NumericType, so any check matchingIntegerTypeExpressionalso catches Boolean-typed values — put an explicitBoolarm first, or a Boolean constant is told it "requires a whole number". Never callasLongin a match guard. It istext.toLongand the parser accepts unbounded digit runs, so a 20-digit literal throwsNumberFormatExceptioninside the guard and surfaces as[severe] Exception Thrownwith no line number. UseasBigDecimalor test the text. -
Constantholds four kinds, and prettify emits:(2026-08-15).ConstantValue = LiteralString | NumericLiteral | BooleanLiteral | PromptValue— a narrowing ofValue, defined the wayComparandis. Deliberately NOT the full union, which would admitCall,AskandInitiatein a constant. ThePromptValuearm is a typed hole: the constant declares the type and the computation is prose, so it needs noas T— see the full A20 typed-holes entry above (AST / Language Internals) for the ascribed form and its restate-never-override rule, built on this precedent. There was never any parser work for the separator.CommonParser.is(:38) isStringIn("is","are",":","=").?and has always accepted the colon, and omission. All spellings are legal, none warns, and prettify emits:. The quoted numeric/boolean form is CONSUMED by the parser, not merely deprecated — that is what makes itsautoFixable = truehonest and the round trip converge, exactly asConnectorOptionToIntentiondoes. A deprecation claimingautoFixablewhile prettify re-emits the old spelling is a lie a migration tool will act on. -
A20 typed holes —
prompt("…") as T(2026-08-15).PromptValuegainstypeEx: Option[TypeExpression] = None; one node, not two, because the forms differ by anOptionand not by wire shape. The default is legal ONLY because it is trailing (@JSExportTopLevelforbids a non-trailing default, which is why A55/A57's fields had to go undefaulted —PromptValuehas nocontents/metadataafter it). The ascription RESTATES the position's type and NEVER overrides it, per A57. Agreement is silent — writing the type out lets the hole read standalone — and a contradiction is an Error. The comparison is SYNTACTIC on purpose, not by resolved type.constant G: Real = prompt("g") as Currencymust Error even thoughtype Currency is Realresolves to the same underlying type; a resolved comparison would swallow exactly the contradiction the rule exists to catch. MirrorscheckOnOtherBinding. The untyped-seam warning is deliberately CONSERVATIVE (Reid, 2026-08-15): it fires on an unascribedlet x = prompt("…")with no declared type, and nowhere else.whenis wired toBoolean; constructor arguments,setand every unwired position stay SILENT. The evidence was a count — all 288prompt(uses in riddl-models already carry a type (273 authors wrote the ascription unprompted; the other 15 arewhenconditions) — so the warning's whole value is for future code and its whole risk is firing on correct code. "We did not wire this position" is not the same fact as "the language cannot type this position", and only the second deserves a diagnostic.Currencyis a predefined type requiring acountryargument, so it cannot be written bare. Several early A20 examples usedas Currencyand do not compile. -
PromptValue.ascriptionFormatis a SECOND, narrower copy ofemitTypeExpression— CLOSED 2026-08-15, prettify never reaches it for aValueanymore. Until this fix, only the four validated positions (constant,let,set,when) routed throughRiddlFileEmitter.emitValue, andemitValue's fallback for every OTHERValueshape wasadd(other.format)— so aPromptValuenested one level deeper (aConstructor/Call/Initiateargument, anInvariantCondition'swithargument, aLogicalExpression/NotExpressionoperand) fell straight back into.formatand reachedascriptionFormat's narrower dispatch, which could emit non-parsing output (as any of {…},as Currency(USD),as table of T of [3,3],as reference to entity E).emitValueis now TOTAL over everyValueshape that can contain a nestedPromptValue:Constructor/Call/Initiateroute their arguments through newemitConstructorArg(s)helpers (which recurse throughemitValue, so a namedid = valueargument's value gets the same treatment);InvariantConditionroutes itswithargument;LogicalExpression/NotExpressionroute their operands through a newemitLogicalOperandhelper that preserves the same parenthesizing rule asLogicalExpression.format's privateparenhelper (kept in step by hand, since that helper is private toAST.scalaand this emitter cannot call it). EveryemitStatementsite whose operand can reach aPromptValue—send/tell/yield/reply/morph … with(via aConstructor/RecordRefoperand, through a newemitConstructorOperandhelper),put,return(previously unhandled at all — both fell to the genericcase statement: Statement => addLine(statement.format)arm and are now explicit cases),require … with, awhencondition'sBooleanExpressionarm, and amatch/caseguard — now routes throughemitValuetoo.PrettifyVisitor.doInvariant's condition rendering (invariant X is <condition>) had the same defect and is fixed the same way, INCLUDING theInvariantBlockform (invariant X is { <stmts> <predicate> }) — fully closed as of Reid's 2026-08-15 ruling, both halves:predicate: BooleanExpressionroutes throughemitValue(never callsnl/addIndent, so no capture/squash machinery needed for it).statements: Contents[Statements]route throughemitStatement— the SAME total dispatch every other statement position uses. This was found to need a genuine LAYOUT change (single-line -> multi-line, one statement per line, matchingemitCodeBlock/on-clause bodies/when/matcharms) and was correctly escalated rather than silently squashed; Reid ruled it in, on the grounds that RIDDL statements are whitespace-separated EVERYWHERE (pseudo_code_blockhas no;/,separator — disambiguation is the formatter's job, not the grammar's) and every other statement block already puts one per line, so the single-lineInvariantBlockrendering was never a deliberate choice — it was the narrow, un-synced SECOND copy of the block dispatch (AST.InvariantBlock.formatvs. the emitter) behaving differently from the other five. Verified against the stagedriddlc(plus a negative control) that the grammar was untouched:invariant Inv is { let a = 1 a > 0 }parses clean before and after. Correction (2026-08-15, earlier same-day review): an intermediate version of this entry first claimedInvariantBlockwas untouched (wrong — its predicate was fixed immediately), then that itsstatementswere a genuinely open, layout-entangled residual needing an owner ruling (correct AS FAR AS IT WENT — that analysis is what got the question to Reid, and is why the ruling above exists). Both intermediate states are superseded: the whole construct is closed now.ascriptionFormatremains inAST.scala, unchanged, for the one place this emitter genuinely cannot reach:.format-based error-message rendering. It is no longer reachable from prettify output, anywhere, full stop. Proven byTypedHoleContainerAscriptionRoundTripTest(passes/.../prettify/): a namedConstructorargument (any of {…}), a namedCallargument (Currency(USD)), a nestedLogicalExpressionwith the parenthesizing intact (reference to entity E), anot(table of T of […]), anInvariantBlock's own predicate (Currency(USD)), and anInvariantBlockleadingstatement(any of {…}) — all six previously mis-emitted, all six verified to fail before their respective fix viagit stash. In-repo fixtures checked for drift, none needed edits: the ONLY.riddlfixture anywhere in the repo containing aninvariant … is { … }block islanguage/input/invariant-scope.riddl(repo-wide grep), and its block was ALREADY hand-formatted in exactly the multi-line styleemitInvariantBlocknow produces — byte-identical, verified by prettifying it and diffing. A repo-wide grep for a hardcoded single-lineinvariant … is { … }golden string in any Scala test source found none outside this session's own test file (already updated). Fulllanguage+passessuites stay green (70+208 suites, 707+1357 tests) and theRiddlModelsRoundTripTestcorpus baseline is unchanged (59/189, same pre-existing failures) — evidence nothing outside invariant blocks moved.AST.scalais inlanguageandRiddlFileEmitterinpasses, so the copy still cannot call the original — the two must be kept in step by hand, which is precisely why this pattern keeps recurring here. What is NOT fixed by this:checkPromptAscription(validation) is still wired at only the same four positions, so an ascription that CONTRADICTS its position's actual expected type is silently accepted atput,return,require … with, and aCall/Constructor/Initiate/TerminateStatementargument. That is a different defect (a missing check, not broken output) at an overlapping set of positions — see BACKLOG § 1.
-
Inlet/outlet direction — the one people invert, Reid included (2026-08-16). An OUTLET is an exit and an INLET is an entrance. A processor PLACES a message on its outlet; the connector carries it; the message ARRIVES at the receiver's inlet. Source-of-truth:
Connector(from: OutletRef, to: InletRef)(AST.scala:5232) — from an outlet, to an inlet — plus the CM's "validated on arrival … per-inlet ordering preserved" for Inlet and "name WHICH outlet they place the message on" for Outlet. The reliable mnemonic is the arity table, not the words: asinkhas inlets and NO outlets. A sink only consumes, so an inlet must be an entrance; everything else follows. The inverted rule — "inlets push into a connector" — reads plausibly and survives casual checking becausesend … to <portlet>accepts BOTH kinds, so a sentence about "sending to an inlet" is grammatical and still wrong about direction. Consequence that keeps coming up: an extra INLET raises the inlet count, so a 1-in/1-outflowthat also hosts anerror-sinkinlet derives as amerge(≥2 inlets, 1 outlet) — never asplit, which is ≥2 OUTLETS. -
empty— the minimum-cardinality inhabitant of a type (rc.23+).EmptyValue(loc, typeEx: Option[TypeExpression]).noneis a SYNONYM producing the identical node — no flag records the spelling, the same choicenot/!made, and prettify convergesnonetoempty. The rule is minimum cardinality ZERO: legal forT?,T*,T{0,n}; an Error forT+,T{1,n}and a bareT. That one rule is why ONE literal covers both the absent optional and the empty collection — same inhabitant, different upper bounds — and it makesadmitsEmptytotal over the fourCardinalitywrappers instead of special-casing two. The ascribed form is load-bearing, not sugar. A bareemptytakes its type from the position, and onlylet/constant/setwire an expected type — NOT a constructor argument, which is the position this was requested from. And the expected-type machinery resolves only NAMED types, so a field typed INLINE (note: String(1,20)+) cannot be checked at all against a bareempty. Pre-existing, shared with A20. Two traps this hit, both worth re-reading before adding aValuearm:- The four throw-terminated walks are INVISIBLE to
-Werror(countValueFailPoints,stateReadsIn,initiatesIn,asksIn) — the terminalthrowthat enforces totality is itself what makes the match exhaustive, exactly as the Total Dispatch section warns.-Werrorfound three sites; the fourth family threw at RUN time and abortedcheckStatementScopesbefore the new checks could run. Grep forhas no arm forand add an arm to each. - An optional trailing TypeExpression SWALLOWS THE NEXT STATEMENT. An aliased type is a bare
path and RIDDL statements are whitespace-separated with no terminator, so
set x to emptyfollowed byset y to …parsed the second statement as the first's ascription. Guarded by refusing statement-leading keywords (statementStart), which is COMPLETE rather than heuristic because a type can never be named a reserved word. The EBNF carries the same guard — without it the two parsers disagree and TatSu reddens. BAST tag 12 atFORMAT_REVISION21; JSON{"value":"empty"}with an optionaltype.
- The four throw-terminated walks are INVISIBLE to
-
telladdresses an INSTANCE as well as a named processor (rc.21+).TellStatement.targetisProcessorRef | Value: keyword-led means a static processor, a bare path orself.idmeans a value typedId(...)naming WHICH INSTANCE. Told apart by the leading keyword, exactly asforwardis;ValueexcludesProcessorRef, so the union is disjoint. The instance is NEVER resolved and nothing needs it (Reid, 2026-08-22: "You CANNOT know the specific instance at validation time, but fortunately you don't need to."). Every question asked of a tell target is answered by the processor KIND theIdnames.TellTarget.processorOfis the one place that answers it:selfby a LEXICAL parent walk with no lookup, a reference by the one refMap lookup the static case already makes. This is why an earlier "it needs a new resolution-output map" analysis was WRONG — it assumed resolving a value target requiredValidationPass's general value-typing machinery. Reuse of a general helper is not the same fact as a capability being unavailable; check which one you have.checkTellAddressingis SKIPPED for a value target, and that is the feature. It exists to recover the address structurally from a message field typedId(target)when the tell does not say which instance; a value target says it outright, so demanding the field would ask for something the statement made unnecessary. NOT entity-only (unliketerminate): only an entity can be ended, but any processor can be addressed.sendis untouched — it takes a PORTLET, soId(entity E)cannot apply there. Diagnostics must use the bare PATH, notProcessorRef.format, which prepends the keyword and silently rewrites every existing message fromtarget 'E'totarget 'entity E'. BAST gains a target-shape discriminator atFORMAT_REVISION20; JSON addstargetValuebeside theto/processorpair (register new keys inknownKeysor the vocabulary guard reddens). -
A message delivered where nothing can receive it — two CompletenessWarnings (rc.21+).
checkTellDeliverabilityis the SENDING end (atellwhose target declares no clause receiving that type);checkInletsAreReceivedis the RECEIVING end (a processor declaresinlet I is type Tand handlesTnowhere). Not redundant — one needs a delivery to exist, the other fires on the declaration alone. The receiving-end question had to be RESTATED before it could be built, and the restatement is the durable part: "an inlet no handler consumes" relates two things that are never directly related. Handlers do not consume, they CONTAINonclauses, and anonclause names a MESSAGE TYPE, never an inlet. Nothing in the AST links the two; the relation is INDIRECT, through the type.on othersatisfies both — it states a policy for anything unmatched, and is the idiomRiddl.BottomlessPitis built from. Both reuse ONE helper (receivesMessageType) rather than a second copy ofvalidateAsk's identical logic;validateAsknow calls it too. Two interactions found by RUNNING it, not reading it: a deliberate-discard sink is now exempt from "contains only 'do' statements" (otherwise the two checks form a demand no legal spelling satisfies — same trap as the adaptor advisory inc075f1af0); andcheckInletsAreReceivedis silent when a processor declares NO handlers at all, because "should have a handler" already reports that — adding the exclusion took fixture churn from 7 edits to zero, which is evidence the existing diagnostics covered those cases. Corpus cost 6,379 + 906 across 190 models — 84% of all tells — and they are TRUE POSITIVES. Verified by hand before reporting: the corpus idiom is tell the event to the entity, handle it somewhere else. Migration filed in riddl-models. Reid: "Correct is correct." -
resolvePathhad NOClassTagand cast unchecked, for the whole life of the function.Terases, sopathIdToDefinition(...).map(_.asInstanceOf[T])always "succeeded" and returned a definition of the WRONG kind typed asT. Nothing failed there; theClassCastExceptionfired at whichever caller first touched aT-specific member — and only for callers that touch one, so the same mistyped value crashed one model and passed silently through another. A crash whose occurrence depends on which check ran first is this shape.ReferenceMap.definitionOfdoes the same job correctly with aClassTag; the two resolution paths disagreed about whether to check. ReturningNoneloses no diagnostic —ResolutionPassreports a wrong-kind path first. -
forward— delegation, and the ONLY statement that discharges by passing on (rc.19+).forward <operand> to <portlet|processor>says the declaredyields/repliesis produced by whatever handles the message downstream. Legal ONLY in a clause handling a command that declaresyieldsor a query that declaresreplies— you cannot delegate an event or a result (author's ruling): those record what happened and owe no answer. The operand's TYPE must match the handled message; its VALUES need not, so a handler may adjust a field and still be forwarding the same message. NOT terminal: ayield/replyafter it is an Error (the response was delegated), asend/tellafter it a style warning. Both transmission shapes, told apart by the keyword leading the reference. BAST sub-kind 21 with ONE discriminator byte before the ref;FORMAT_REVISION19. -
What DISCHARGES a
yields/repliesobligation NARROWED at rc.19, and this is the part that breaks models. Onlyyield/reply,error/require, andforwardsettle a path. Asend/tellno longer does — neither of the handled message nor of a different one. That retired the previous "emitting ANY message settles a path" allowance and the event-sourcing example defending it. Two corpus shapes need DIFFERENT fixes and a bulk edit must not conflate them: a handler that passes the message on becomesforward(mechanical), while one that declines by emitting a*Rejectedevent cannot forward anything and needs an expliciterror/require— a semantic change. -
errorANDterminateare TERMINAL in their block;requireis not. A statement after either is unreachable and an Error.errorREFUSES,terminateDESTROYS the instance — same rule, different reasons, and the message must state the one that applies.require Xrefuses only when X fails, so statements after it are ordinary. Per statement LIST, recursing intowhen/match/foreachbodies as their own lists.on termneeds no exemption: it is a different list, and it runs BECAUSE of the termination rather than after it. Theterminatehalf was missing for a full release, and that is the lesson. rc.19 shipped theerrorhalf and reordered 268 corpus statements for exactly this reason, while aset statesitting after aterminatein reactive-bbq survived that pass and every validation since — because the check matchedErrorStatementalone. riddl-models found it BY EYE. When a rule is about unreachability, ask what ELSE ends a block; enumerating one terminator is how the next one stays invisible. Do not "simplify" this by matching the two terminators together. That was the reported suggestion and it is the smaller change; it also yields a TRUE diagnostic with a FALSE explanation, telling an author theirterminate"refuses" and offeringrequireas the conditional alternative, which is not a conditionalterminateat all.BlockEndercarries each terminator's own reason and advice. Same trap as A23 borrowing A26's effect set: a check inherited wholesale stops answering its own question. -
A23 ("refusals first") asks a DIFFERENT question from A26, and its effect set was borrowed from A26 for months. A26 asks is this pure?; A23 asks would refusing now leave a partial change? Narrowed 2026-08-19 to LOCAL state transformation:
set,morph,terminateare effects;send,tell,yield,putandbecomeare not. Transmissions leave nothing partial HERE — any state they cause is elsewhere and later, a remote "maybe" that is acceptable for a locally immutable statement — andbecomeis a BEHAVIOR transition, not a state one. The narrowing is load-bearing: without it, makingerrorterminal left the corpus's "refuse AND publish a rejection event" idiom illegal in BOTH orders, i.e. inexpressible. When a check is borrowed wholesale from another, re-derive it from its own question. -
option snapshots(Entity, event-sourced only) — and reconstructability is a CM MUST-PRESERVE. The option says WHETHER journal-derived snapshots are taken, never how; no policy enum and no interval, because whether snapshotting pays turns on update rate, read/write mix and physical layout, none of which is in the model. Its ABSENCE is the default and is meaningful: take NO snapshots, replay the whole log — right more often than it looks, since many entities see under a hundred events in their lifespan. An Error on a non-event-sourced entity. The CM gained a must-preserve with it: state as of any past point must be reconstructible, so a current-state row kept as an optimization is fine but a current-state row that is the ONLY reconstruction mechanism is not. -
A clause that answers should handle a message that DECLARES what it answers with — StyleWarning, not an Error (author: it "doesn't rise to the level of an error"). The converse is already an Error in all four combinations (declare and produce nothing; declare and produce the wrong type; command and query alike), so do not add a check for it.
-
AST.Set shadows scala.Set — use selective imports or qualify as
scala.collection.immutable.Set. -
Schema match ordering — Schema extends
Leaf(Definition) but is also in theNonDefinitionValuesunion. Its case must appear BEFOREcase _: NonDefinitionValues. Same trap forRelationshipvscase _: Definition. -
State is a Branch, not a Leaf, of
Branch[StateContents]whereStateContents = Handler | Comment.PassVisitorusesopenState/closeState(notdoState). ResolutionPass prepends State to parents (as with all Branches), so refMap keys for State's type ref use State as parent, not Entity. -
do "..."is an alias forprompt "..."— both producePromptStatement. -
notand!are SYNONYMOUS everywhere, as the inverse of a boolean expression (ruling 2026-08-14, implemented and shipped 2026-08-15 —2026-08-15-not-bang-synonymyplan, all 5 tasks complete).!is legal in every positionnotis, and both build the IDENTICALNotExpressionAST node — there is no spelling flag anywhere, so two ASTs meaning the same thing can never compare unequal.notis prefix and recurses (not not a/!!a), and both work wherever a boolean expression does:when,require,let, parenthesised, and applied before a comparison. This OVERRIDES the 2026-08-13 ruling, which saidnotwas the only general-purpose negation, that!was a legacy spelling accepted ONLY aswhen !<bare-identifier>, and that it "will not be extended to" anything more. That reasoning is retired, not merely superseded — do not restore it. The!grammar rule is("not" | "!") not_expression, replacing the oldwhen_condition-only special case entirely (EBNFnot_expression—language/.../ebnf-grammar.ebnf); the parser guards the!=case with"!" ~~ !"="(fastparse negative lookahead, no regex — unavailable on Scala Native). Prettify converges!tonot— the same precedent asA | Bprettifying toone of { A or B }— pinned byBangNotRoundTripTest; a!=comparison is untouched, since it is a comparison operator, not a negation. BAST and JSON both carry the change atFORMAT_REVISION18 (WhenStatement.negateddeleted entirely — there was never a second node kind to reconcile). Corpus fixture:language/input/bang-not-synonymy.riddlexercises every position plus the!=guard, and is what moved the TatSu baseline from 108/131 to 109/132. Corpus A/B against the four known-red suites (RiddlModelsRoundTripTest,Root2JsonCorpusTest59/190, riddlc local-corpus,ReportedIssuesTest"should 406") showed zero movement — the corpus (riddl-models + riddl-examples) has no!uses, 597notuses, and no!=uses either. Language-reference documentation is a task drop in../ossum.tech/task/2026-08-15-not-bang-synonymy.md, not an edit here (one Claude instance per project). -
walkStatements helper — private in ValidationPass; walks into
WhenStatement/MatchStatementnesting. -
Accessors see through the provenance wrappers;
Findersees through everything. The 35contentsaccessors (context.entities,domain.contexts,handler.clauses, …) useContents.filterThroughWrappers, which descendsIncludeANDBASTImport— the same twoflatten()removes. HOW a definition reached a container is riddl's bookkeeping; a client asking what is in a context wants the whole list and has no stake in whether a member was written inline, included, or imported. Three rules follow:Contents.filterstays literal ("my direct children"), andincludesmust keep using it, since the wrapper is matched BEFORE the type test.vitals/processorsalso stay literal — their callers (DiagramsPass, StatsPass) already reach included definitions another way and would double count. Reasons are recorded at each inContents.scala.definitionswas the third of those and is transparent as of 2026-08-06 (synapify's task), withdirectDefinitionsadded as the literal form. That change disproved the rule the old comment stated — "make it transparent AND delete the caller's manual walk". ResolutionPass's walk descendsIncludeand deliberately NOTBASTImport, andfilterThroughWrapperscannot express "includes but not imports", so ResolutionPass keeps its walk and readsdirectDefinitions(7 sites). Making it transparent would have made imports resolve, breaking rule 2 below. Three validation checks readdefinitionsand moved with it:checkContentsandcheckIncludeHygienestopped emitting two FALSE warnings (a container whose content all arrived by include was told it "should have content"), andcheckUniqueContentSTARTED reporting duplicate sibling names across an include boundary — a real ambiguity, approved as a deliberate tightening (Reid, 2026-08-06). It cost the corpus nothing: 189/189 riddl-models validate with zero errors. Pinned byIncludeTransparentValidationTest.- READING and RESOLVING answer differently for imports, on
purpose.
domain.typesreports a.bast-imported type, but a reference to it does NOT resolve until an explicitflatten— the symbol table is built by traversal, not by these accessors, and S61-2's contract that loading only fills wrappers is unchanged. Structure is likewise untouched:contents.filterstill shows nothing spliced in, andBASTLoader.getImportsstill finds the wrapper. Pinned inBASTImportLoadingTestandIncludeAndImportTest. Finder.recursiveFindByTypeand the accessors answer DIFFERENT QUESTIONS — it walks EVERYContainer, the accessor walks only the provenance wrappers. Where they diverge: under a Domain (domains DO nest,domain_content, ebnf-grammar.ebnf:77), and forTypeunder a Context, since a recursive find also picks up types declared inside entities — riddl-generator relies on exactly that to emit state records. Where they do NOT diverge:Entityunder aContext, because contexts cannot nest (context_definition:85 omitscontext,entity_content:96 omitsentity, andprocessor_definition_contentshas noentity). Pick by the question, not by reflex — an earlier version of this note warned that recursive find "returns nested contexts' entities", which the grammar forbids; riddl-generator caught it. Before 2026-08-03,context.entitieswas empty whenever the entity lived in an include — silently. That is how riddl-generator produced 582 files for reactive-bbq with no entity class among them while the model validated clean. It survived because riddl validates by TRAVERSING and every internal test took that path; the consumer path had no gate at all.ConsumerReadsIncludedDefinitionsTestis now that gate — add to it whenever you add an accessor.
-
A case class that transitively reaches a DOCUMENT has an O(document) hashCode, and only Scala.js notices.
StringParserInput's first field isdata: String, the entire text of a source file;Atholds aRiddlParserInput,IdentifierandDefinitionhold anAt, andReferenceMap.Keyholds aDefinition— so every refMap add and lookup hashed a whole source file, twice perDefinition.hashCode. The JVM and Native memoiseString.hashCodeinto the string object and never noticed; a JS string cannot carry that field. Measured on a 139KB source: 14ns (JVM), 1ns (Native), 181,187ns (Scala.js). Fixed by memoising on the parser input (RiddlParserInput.cachedHashCode) — one field per FILE, nothing per node — taking Scala.jsDefinition.hashCodefrom 384,016ns to 217ns, at parity with the JVM. The tell was the RATIOS, not the totals: parse cost 3.2x on Scala.js while Resolution cost 97x, and ordinary overhead is uniform — when one number is 30x the others on the same runtime, the runtime is doing something different, not the algorithm. Get the cross-platform ratio BEFORE profiling. (Both the report and our first hypothesis blamed complexity; the favourite suspect, ClassTag dispatch, measured 5x faster on Scala.js than the JVM.) -
Definition hashCode/equals override —
Definitiontrait overrides both:hashCodecheap (id + loc + class);equalsstructural viaproductEquals, skippingContentsfields. Prevents O(subtree) hashing in anyHashMap[Definition, X]. Opaque typeContents[?]erases toArrayBufferat runtime, socase (_: Contents[?], …)matches correctly.
RuleId (language/.../RuleId.scala) is a kebab-case, subject-prefixed enum: 303 rules
covering all 307 diagnostic sites. Message.ruleId: Option[RuleId], and ruleId is a
REQUIRED parameter on the eight Accumulator.add* helpers — a new diagnostic does not
compile until it names its rule. The six calls in MessagesTest pass None explicitly.
It GENERALIZES Messages.DeprecationCode; it does not sit beside it. That object was
already a threaded kebab-case id registry for deprecations, consumed at RiddlLib.scala:970
to build SourceEdits. Its 12 codes are reproduced EXACTLY — including prompt-statement,
whose rule was renamed DoStatement while its code deliberately was not, because renaming a
rule is a source change and renaming its code is an API break. Do not introduce a second
scheme (an early draft proposed REF001-style ids; it was dropped for exactly this).
An id names a RULE, not a site. Four rules are emitted from more than one place on
purpose — ref-wrong-kind from BOTH ReferenceMap.definitionOf and
ResolutionPass.wrongType, which is apt given those two paths once disagreed about whether
to check the kind at all.
Non-reuse is enforced by CODE, in three parts (all canary-tested by breaking them):
values is generated so codes are checked unique; RuleId.retired names withdrawn codes and
no live code may appear there; and a committed append-only ledger
(language/src/test/resources/rule-ids.txt) catches what the in-memory checks cannot see — a
rule DELETED without retiring its code, which is the one at risk of being reused later.
RuleId.grandfathered is CLOSED: the 12 legacy codes predate the subject scheme and are
exempt from it. A new rule that fits no subject needs a SUBJECT added, never an exemption.
Why the enum at all: DeprecationCode.all was a hand-maintained Seq beside the
definitions, and TWICE a code was defined but never added to it — entity-option-to-intention
for months — so "exhaustive" migration reports silently omitted a whole family. all and the
mechanical-replacement map are DERIVED now; there is no second list to forget.
The id renders in the LOGGER, not in Message.format. The logger already supplies the
kind prefix, so output reads [error] [use-unused-definition] file(...), rustc's shape.
format is what CheckMessagesTest compares its 13 goldens against, so putting it there
churned every one of them for a fact those files do not exist to pin. --no-msg-ids
(CommonOptions.showMessageIds, default TRUE) restores the previous output exactly.
validate --json emits one object per diagnostic on stdout (rule, severity, message,
file, line, col, and context/suggestion when present); [] when clean, never empty output.
validate --fix / --fix-rule <id> applies the codemod a rule carries
(RuleId.mechanicalFix), through the SAME gate as find -replace —
FindEditor.applyVerified, lifted so there is one copy rather than two. Only PURE SPAN
replacements qualify: type-first-aggregate is a reordering and shape-keyword inserts
outside the reported span, so both are excluded rather than approximated. See BACKLOG [1.16]
for quoted-constant-literal, which is genuinely mechanical but needs a COMPUTED replacement
an Option[String] cannot express.
FindEditor.fileOfSource, never Path.of(loc.source.origin). origin is the SHORT name
error messages render, so treating it as a path works only when the cwd happens to be the
model's own directory — how find -replace originally shipped, and a bug validate --fix
nearly reintroduced the same day.
println is Console.println, and Console.out is a THREAD-LOCAL initialised at class
load. System.setOut therefore does not redirect it, and code printing from inside a
Future — on an executor thread — writes to the real stdout regardless. In production the two
name the same object and nothing is wrong with the output; under capture the test reads an
empty string, which presents as exactly the "command printed nothing" defect the whole
ValidateSummaryTest/ProductGoesToStdoutTest family exists to detect. A false positive from
the instrument, not the code.
Emit a command's product with System.out.println. ValidateCommand.emitJson and
DumpCommand.emit both do. StdStreamCapture also wraps Console.withOut, which closes the
same-thread half but CANNOT help across threads — the System.out form is what does.
do { "a" "b" "c" } and prompt({ "a" "b" }), with the bare single-string form unchanged.
The braced shape is doc_block's, already RIDDL's spelling for prose, so no new syntax
idiom was invented. The bare form takes EXACTLY ONE string: do "a" "b" by juxtaposition
parses unambiguously (nothing else begins with a quote) but leaves nothing except the next
keyword to mark where the statement ends.
DoStatement.what and PromptValue.prompt are Seq[LiteralString]; .text derives the
\n-separated prose riddlg reads. Derived, not stored, so there is no second field to
disagree — and a single-line do is a Seq of one rather than a special case.
Additive at every layer, and that is load-bearing. A one-line do prettifies
byte-identically to before and serializes as a bare JSON string rather than an array, so none
of the corpus's 190 models move for a feature they do not use. Several lines get ONE PER LINE
inside braces — the layout every other block uses; squashing them onto one line would be the
narrow second copy of a block dispatch InvariantBlock was already caught being.
BAST FORMAT_REVISION 23: both now write a SEQUENCE where they wrote a bare string, so a
revision-22 file's string is read as a COUNT and everything after it derails. The JSON reader
accepts a string OR an array, so nothing already written stops loading.
Keywords.keywordends in a CUT —P(key ~~ &(isNotKeywordChar))./— so once the keyword matches, the enclosing|CANNOT backtrack. Whichever alternative comes first wins outright and the others are unreachable. That is howattachment ULID is "…"could not be parsed AT ALL: the general attachment rule was first, soulidAttachmentwas never tried and the ULID form failed where a mime type was expected. Reordering only breaks the other branch the same way — the shared prefix must be FACTORED, matching the keyword once, ahead of the choice, and alternating the BODIES (ulidAttachmentBody | namedAttachmentBody).bastImportwas already written this way, with a comment describing the identical hazard. The same cut collision is why an optional leading marker needs a non-cutting variant (Keywords.maybeInitial), and whyon event/on <msg>must be ONE parser branching on the parsed ref viaflatMaprather than twoon …alternatives. Symptom to recognise: a documented piece of syntax that has never worked, with an error naming what the OTHER branch expected.- Test the alternation; do not read it. fastparse aggregates its failure set
at the FURTHEST position reached, which is not the same thing as "what is
allowed here".
tell preportedExpected one of ("become" | "command" | "event" | "morph" | …)— mixing statement keywords with message-kind keywords — which reads liketellis banned in that clause. It was not; the OPERAND was the problem. A three-line experiment settled in seconds what two people read in opposite directions. - A
rep(2)that looks like a semantic guard usually is not.sagaDefinitionsread as "a saga needs two steps"; the real rule is inValidationPass, with a proper Error and a suggestion. Relaxing the parser lost no rule and UPGRADED the diagnostic — a parse failure at the wrong token became a message that says what is wrong. Check for this shape before assuming a parser cardinality is load-bearing.
Reid's standing rule (2026-08-09): "There must be no non-sealed matches — it is okay to fall through to generate an error or exception but not okay to not select anything and then carry on as if nothing happened."
A case _ => () on a SEALED hierarchy is the failure mode: it compiles, and
when a new node type is added the code quietly does nothing for it. Every
symptom then appears far from the cause — an empty output, a dropped statement,
a model that validates clean and means something else.
- Enumerate the cases — and do it by READING, because nothing checks it for
you.
-Werroris NOT a safety net here. This file said it was until 2026-08-13; the claim is false as this repo is configured, and believing it is how the processor-instance-identity branch shipped seven missed dispatch or dispatch-input sites (five across its tasks 2/4/5, two more found by task 7's review) — every one caught by a human reading code or a code review, none by the compiler. Two independent reasons, and the second is the important one:languageandcommandscompile with--no-warningsalongside-Werror(build.sbt:229,:417), so in those two modules there is no warning left for-Werrorto escalate. (An earlier note here namedpassesandriddlLibas well — wrong; checkbuild.sbtbefore repeating it.-Werrorreally is live in those two.)- Where
-WerrorIS live it still cannot help, because a wildcard arm makes a match exhaustive — so the terminalthrowthis section prescribes is itself what silences the compiler. Follow the rule and you are guaranteed never to be told the hierarchy grew. Most of the seven were inpasses, where warnings are on. The real net is thatthrow, and it fires at RUN time on the first test that exercises the missing arm — so it protects you exactly as far as your tests reach, and not one node further. When you add a node type, grep the dispatches and read them; do not wait to be told.
- When a branch genuinely cannot be reached,
throwrather than return unit.Pass.processValuedoes this; so doBASTWriter/BASTReader, which previously used aprintln-and-drop and a placeholderPromptStatementrespectively — both of which produced corrupt output instead of a failure. case _ => ()remains correct for "not interested in this node" — a visitor that handles three of forty types. The test is whether the arm means "nothing to do here" or "I do not know what this is". Only the second is the bug.- Enumerate the domain of the FUNCTION, not of the nearest-looking type.
stateReadsIn/asksIn/countValueFailPointswalk whatstatementValuesyields, which is WIDER thanValue:WhenStatement.conditionalone isLiteralString | Identifier | ValueRef | BooleanExpression | PromptValue, andIdentifierappears in no other member. AuditingValueexhaustively therefore still misses it — which is exactly howwhen !isValid, a form that validated on rc.11, threw on rc.13 (fixed 2026-08-13). The throw did its job; the enumeration was against the wrong hierarchy. - A total walk is still defeated if its INPUT drops a field. Auditing the
match arms proves nothing about the fields each arm forgot to RETURN.
statementValueswas total over the statement kinds and nonetheless never yieldedRequireStatement.argument(thewith <expr>operand) orMatchCase.guard— both fullValues — so aninitiateparked inrequire X with initiate entity Orderwas invisible to every walk built on it at once: state-reads, asks, the A12 fail-point census, and the instance-effect ban that was itself written correctly (found 2026-08-13 by task 7's review of the instance-identity plan). Check the arms AND their payloads. - A dispatch written TWICE hides the incomplete copy behind the complete one.
AST.WhenStatement.formathad four arms over a five-memberconditionunion (noPromptValue), sowhen prompt("…")threw aMatchError— and it survived becausePrettifyVisitordoes NOT route through it:RiddlFileEmitter.emitStatementkeeps its OWN copy of that same dispatch, and that copy has the arm. So the reflectivity round trip, which is what normally proves aformattotal, could never reach the hole; prettifying the construct produced correct output on the released binary. Fixed 2026-08-14 (Task 5 of the message-value plan made it reachable by rendering a clause body). When you find two implementations of one dispatch, the tested one tells you nothing about the other — read both.Statement.formatandRiddlFileEmitter.emitStatementare that pair; keep them in step. - Fix the SHAPE of a dispatch/recursion defect, not the instance. The
alias-chain cycle guard was added to
fieldsWithOwnerin rc.14 and its siblingaggregateFieldsOfwas left unguarded, sotype A is B/type B is Astill killed the stack — it was simply latent until a caller reached a cyclic alias (2026-08-14). Same lesson the flaky-benchmark round recorded a day earlier: when fixing a defect of this class, grep for the shape.
A field-drop defect has no natural blast radius, and Finder is where it
lives. Finder.recursiveFindByType walked contents only, so 27 field-held
sites were unreachable — MatchStatement's cases and guards,
Correlation.timeoutStatements, SagaStep's do/undo blocks,
RequireStatement.argument, InvariantBlock, PromptValue.typeEx, the
Constructor/Call/Initiate argument lists, the LogicalExpression/
NotExpression operands. Anything reading the AST through Finder rather than
a Pass silently returned SHORTER LISTS; nothing errored. The consumers most
exposed are the ones that ENUMERATE rather than traverse, i.e. riddl-generator.
Consolidated into Finder.fieldChildren (Finder.scala:86) — one extension
point instead of four scattered special cases — which still ends in case _ => Seq.empty, so arm 12 of Value will be invisible on the day it is added.
The lesson is about detection, not the fix: it surfaced because ONE BAST test
looked for a ComparisonExpression inside a when condition and got nothing
back. The instance you notice is the one your test happened to walk, not the
extent of the problem — which is what separates this family from a dispatch
defect, where the compiler at least knows the arms exist.
Known-total today: Pass.processValue, classifyHandlers (all 17 Statement
kinds), countValueFailPoints, BASTWriter/BASTReader statement dispatch. The
remaining ~140 catch-alls are unaudited — see BACKLOG § 2.
A new Value arm touches EIGHT sites, not five (counted 2026-08-15 adding
NumericLiteral; the plan said five and -Werror found three more). Beyond
ValidationPass's four walks (countValueFailPoints, stateReadsIn,
initiatesIn, asksIn) and validateValue, there are: AST.NonDefinitionValues
— a parallel union to Value that is easy to miss entirely — ValidationPass.valueType,
and JsonifierPass in riddlLib. Widening Comparand is a SEPARATE
family of its own: resolveComparand, serializeComparand, buildComparand,
plus the BAST writer/reader pair. Grep and read; do not trust a five-item list.
A catch-all that "just works" is how a literal disappears. Before Task 3
added its arm, JsonAstBuilder.buildComparand's pre-existing
case other => ValueRef(curAt, PathIdentifier.empty) silently degraded a numeric
comparand into an empty reference — no error, no warning, a valid-looking wrong
answer. That is a live instance of the unaudited catch-alls above, not a
hypothetical.
Reid has been bitten by this repeatedly while developing RIDDL, and it can make
EVERYTHING fail if implemented wrong. Read this before touching isEmpty or
before "fixing" a spurious emptiness warning.
- The contract:
RiddlValue.isEmptydefaults totrue, documented atAST.scala:98as "non-containers are always empty". Emptiness asks whether a node HAS CONTENTS. It does not ask whether the author supplied it, and it does not mean "all optional fields are None". - Overrides belong on CONCRETE case classes that genuinely have contents, and
should fold in their parents'
isEmptyresult. Traits with no members of their own generally need nothing — auditing every subclass is the wrong sweep. Statementdeliberately inherits thetruedefault. Statements have no bodies, so they are ALWAYS empty, and it never matters: they are leaves that traversal never descends into.- Among the
Valuekinds, onlyLiteralStringoverrides it (:181,s.isEmpty) — the one Value whose emptiness is a real question, because an empty string IS the author writing nothing.Call,Ask,Constructor,ValueRef,GetValueandBooleanLiteralare non-containers and correctly report empty ALWAYS.
The gotcha this produces. checkNonEmptyValue (BasicValidation.scala:279)
asks value.nonEmpty, so it is meaningful ONLY for a LiteralString. Eight of
its ten call sites in ValidationPass honour that — they pass a LiteralString
field (PromptStatement.what, ErrorStatement.message, CodeStatement.language,
LiteralPattern.literal, PromptValue.prompt) or guard with case ls: LiteralString => and explicitly skip ValueRef/BooleanExpression. Two sites
passed an arbitrary Value unguarded and therefore fired on correct code:
let's expression and set's value, so let q = call function F(…) and set field S.flag to true were both reported "must not be empty". Fixed 2026-08-10 by
guarding both on LiteralString; pinned by ValueEmptinessCheckTest.
The trap to avoid. The tempting "fix" is to override isEmpty on Call/
Constructor/ValueRef/BooleanLiteral so they report non-empty. That
REDEFINES emptiness from contentless to present, which is a different
question and the one the whole traversal/flatten layer depends on. When an
emptiness check misfires, the bug is almost always in the CALLER asking the wrong
question, not in the node's isEmpty. Non-literal values get their real
validation — resolution and type-checking — in checkStatementScopes.
- OutlinePass / TreePass — lightweight
HierarchyPasssubclasses inpasses/shared/.../passes/. OutlinePass → flatSeq[OutlineEntry]. TreePass → recursiveSeq[TreeNode], exposed viaRiddlAPI.getOutline()/getTree(). TreePass uses amutable.Stack[ListBuffer[TreeNode]]for pure O(n) building (not aHashMap[Definition, ListBuffer]). - Analysis passes — MessageFlowPass, EntityLifecyclePass,
DependencyAnalysisPass (1.22.0). All in
passes/shared/.../analysis/; each extendsCollectingPassand requires ResolutionPass. (AIHelperPass was removed in 1.24.0 — see "Message suggestions" under Validation Specifics.) - MessageFlowPass —
MessageFlowEdge.messageTypeisOption[Type](adaptor declarations produceNone; typed handler edges produceSome). Direction-aware:InboundAdaptor("from") → producer=referent, consumer=source;OutboundAdaptor("to") → producer=source, consumer=referent.MessageFlowOutput.edgesForDomain()/edgesForContext()take aSymbolsOutputparameter for parent-chain walking. - UsageResolution uses
mutable.Set[Definition]foruses/usedBy(wasSeq). API boundary methods (getUsers,getUses) return.toSeq. - ParentStack is a class, not a type alias. Use
ParentStack.empty(notmutable.Stack.empty). Same API (push, pop, toParents). It cachestoParents(toSeq). - ValidationMode enum —
FullorQuick. Quick skipscheckStreamingandclassifyHandlersin postProcess. - IncrementalValidator — caches messages per-Context using
FNV-1a fingerprints.
validator.reset()forces a full recheck. - RecognizedOptions registry — validates option names,
argument counts, parent types. Unrecognized → StyleWarning.
This registry is the ONLY thing validation consults. The
KnownOptions.*lists inlanguage/.../KnownOptions.scala(adaptor,context,domain, …) have no consumers anywhere in the codebase — they are advisory/reference data exported to JS via@JSExportTopLevel. Adding a name there does NOT clear a warning; adding it toRecognizedOptions.registrydoes. Keep both in sync anyway, sinceKnownOptionsis public API. - Generator-metadata options (1.30.0, 1.31.0) — riddl-gen
and friends drive output from RIDDL metadata, with option
names prefixed for their target so they are self-describing:
protocol(AsyncAPI),event_catalog_version(EventCatalog),sql_dialect/sql_table(SQL DDL),backstage_owner/backstage_lifecycle/backstage_type(Backstage catalog),confluence_space/confluence_parent(Confluence). These parse fine without registration but draw a spurious "not a recognized RIDDL option" StyleWarning until registered. ChoosingvalidParents: useSeq.emptywhen the generator resolves the value by walking up the parent chain (so it is legitimately settable at any level) — this is the common case. Use a specific list (e.g.Seq("Domain")for theconfluence_*pair) when the generator reads it from exactly one kind of definition, so a misplaced option gets a "not typically used on X (expected: Y)" nudge instead of passing silently. Registering a new one is ~3 edits:KnownOptionconstant,KnownOptions.*list membership, registry entry, plus aCompletenessTestcase. - RiddlLib analysis API —
getHandlerCompleteness(),getMessageFlow(),getEntityLifecycles()on the shared RiddlLib trait and JS facade. JS facade returns""for the untyped (None) MessageFlow edges. - Path-identifier usages tracked separately (1.23.1).
ResolutionPass.resolvePathFromAnchorcallsassociatePathUsage(parents.head, intermediate)for each anchor + non-terminal component, into the newusesInPath/usedInPathBymaps onUsageBase. Existinguses/usedBysemantics are intentionally unchanged soUsages.getUsersandAnalysisResult.getUsersdon't shift underneath callers. Filtered againstuser eq useandparents.exists(_ eq anchor)so internal self-references don't leak in. Public accessors:Usages.isUsedInPath(d)/getPathUsers(d). - Path-only usage triggers a CompletenessWarning (Types
only). When a Type's
usedByis empty butusedInPathByis non-empty,UsageResolution.checkUnusedemits "only referenced in path identifiers" — the type is addressable but can't carry data because nothing declares a field / state of that type.
-
validateTypeskips its type-expression walk for a TOP-LEVEL aggregate, so a whole family of checks fires only on nested inline ones. The guard isif !t.typEx.isInstanceOf[AggregateTypeExpression](ValidationPass.scala:2752), which meanscheckAggregationandcheckAggregateUseCaserun forf: command { … }and NEVER forcommand X is { … }— that is, never for any aggregate a model actually writes. This is why a duplicate field name (command C is { x is String, x is Integer }) validated clean AND survived an idempotent prettify round trip until 2026-08-19: putting the new check with its obvious neighbours made it fire on NOTHING, and the tests stayed red in a way that looked like the check was broken. Their neighbours (field naming, identifier length, metadata) share the blind spot and nobody has audited what else that guard silently excludes. When a new aggregate check appears to do nothing, suspect the guard before the check. -
The three integer types (
Integer/Whole/Natural) have defined ranges, and a LITERAL is checked more strictly than a REFERENCE (numeric-literals plan, 2026-08-14/15). Ruled by Reid:Integeris signed (any whole number),Wholeis non-negative (>= 0, the counting type),Naturalis positive (>= 1, the ordinal type, excludes zero). These were undefined everywhere — code, grammar, language reference, Computational Model — until this work, so nothing could enforce a distinction between them.ValidationPass.checkNumericLiteralConformanceenforces it now, but ONLY against aNumericLiteralvalue on aConstant— aValueRefis untouched, andNumericType.isAssignmentCompatibledeliberately still lets ANY numeric type flow into any other by reference (let x: Natural = someRealFieldstays legal). The asymmetry is intentional: a literal's value is statically known where a reference's is not, so only the literal can be held to the stricter standard. The fractional-value check (IntegerTypeExpressionrejecting a decimal) is reported BEFORE theNatural/Wholerange checks — both are integer-type violations, and a range message for1.5would be true but useless next to "has a fractional part".Boolis excluded even though it extendsIntegerTypeExpression: a Boolean-typed constant is a different kind of thing, not "a whole number with a fractional part." -
Connector intentions (
persistent,at-least-once|at-most-once) — keywords written BEFOREconnector, two independent groups, mutually exclusive within a group (an Error, not a parse failure, so both keywords can be named). Absence of a delivery keyword meansat-least-once— Computational Model §25.7 already said so, so nothing was invented and an absent keyword draws NO warning;at-most-onceexists to make that section's "knowing downgrade, never a silent one" enforceable.at-least-onceis writable and redundant. ORDERING is deliberately NOT an intention: §25.7 makesunordered"permission, not mandate" with a best-effort obligation, which is the definition of advisory. The admission test for the enum is whether a generator may decline to honour the keyword.option persistentis deprecated and CONSUMED into the intention by the parser, which is what makes the round trip converge and migrated 430 corpus uses for free. AskConnector.isPersistent, neverhasOption("persistent")— it accepts both spellings, and three validation gates go through it. Two traps this hit, both documented elsewhere in this file and both worth re-reading before touching AST: inserting the enum between@JSExportTopLevel("Connector")and its case class silently reattached the annotation (invisible tocJVM), andStreamingValidationhad anoptions.find(…).getthat was safe only while persistence could come from nowhere else. -
The stream-shape arity table is TOTAL, and
sink/sourcetake ANY port count (Reid, 2026-08-12).Processor.shapeForAritymaps every non-negative(outlets, inlets):shape outlets inlets void0 0 sink0 ≥1 source≥1 0 flow1 1 merge1 ≥2 split≥2 1 router≥2 ≥2 sinkandsourcewere pinned to exactly one port until 2026-08-12, which left(0, ≥2)and(≥2, 0)unnamed; they fell to a catch-all returningVoid, sorepository R as sinkwith two inlets was rejected as "its arity is void". The final arm now THROWS — it is reachable only for a negative count — because returning a plausible shape is how the gap became a confident wrong diagnosis thatvalidateProcessorShapereported as fact. Two places encode this and both must move together: the table above, and the parser's per-shapeminInlets/maxInlets/minOutlets/maxOutletsinStreamingParser(sink Randrepository R as sinkmust agree about what a sink is). Their prior agreement was not corroboration — it was one assumption written twice. -
external context Foois an INTENTION, notoption external— test both.Context.intention: Option[Intention](Application/External/Gateway/Service) is set by the keyword formexternal context Foo is {…}, which is what riddl-models uses almost exclusively.hasOption("external")is the OTHER spelling (with { option external }) and does NOT see it. A check that exempts external contexts must ask for both:c.intention.contains(Intention.External) || c.hasOption("external"). Testing only the option cost 1120 false warnings across the corpus in one run — every event declared in anexternal contextblock, i.e. exactly the systems a model deliberately does not implement, reported as emitted by nothing. The correct idiom was already in the codebase atStreamingValidation.scala:66, which has always asked for both; it just was not copied. Two sites still ask for the option ONLY —ValidationPass.scala:248(checkCompletenessPostProcess) and:581(validateOnMessageClause) — so anexternal contextis NOT exempt from those two. Filed in BACKLOG; each needs its own corpus A/B, since widening an exemption changes which models escape a different check. -
Statement scope:
setandget from stateneed something that OWNS state (Reid, 2026-08-12).setis legal only in an Entity (which owns itsState) or a Projector (which owns the read-model record its folds build — A70 REQUIRES it). It is an Error in a Context (§3.5: state lives in contained entities/repositories/projectors, "never in the Context itself"), a Saga (§9.5: a saga's state is housekeeping with "no domain-specific value"), a Repository, an Adaptor and the streamlets. A Function is deliberately not reported here — A26 already rejectssetat the keyword, and a second message would double-report. A Repository is banned despite the corpus appearing to disagree. 97sets across reactive-bbq and two pattern templates were added to silence "contains only prompt statements" — evidence about that warning, not about what a repository does. The warning now exempts repositories (most of their on-clauses legitimately hold onedostanding in for SQL) and saysdo, notprompt(dois canonical;promptis the deprecated synonym, andprompt(…)with parens is a VALUE). Do not re-admitsetin a repository without re-reading that ruling — the two halves must move together.get from stateis legal only inside the entity that OWNS the state: outside any entity there is nothing to read (and in a saga step this is the rule theaskban already states, which reading state directly would otherwise bypass), and inside a different entity it crosses §4.6's encapsulation rule. That second half is why the whole rule lives in validation, not the parser — it needs the resolvedStateand its owner.get from inputis untouched:GetValue.sourceisInputRef | StateRef, and inputs are confined to application contexts indirectly, because A41 pins UI groups there, so aninputreference outside one has nothing to resolve against. Giving it a dedicated message was considered and REJECTED (2026-08-12) — but know the tradeoff that accepts: what the author actually sees is the GENERIC "Path 'Screen.NameField' was not resolved" (verified, not assumed), not A41's message, which fires on a misplaced group declaration rather than on this. It is correct and it is unhelpful. Revisit if it confuses anyone in practice; the reason to leave it is thatget from inputoutside an application context is nearly always a symptom of a missing group, which A41 does report well. Hooked invalidateStatement, which every statement reaches WITH its parents — including saga-step statements, whoseparents.headis the Saga (a SagaStep is a Leaf and is never pushed; seePass.traverse). NotecheckStatementScopesis NOT that hook: it is wired only to on-clauses and function bodies. -
A processor receives ONLY through its OWN inlet, and publishes ONLY through its OWN outlet (Reid, 2026-08-18). "Inlets are needed to receive, outlets to transmit/publish." A message reaches a processor through THAT processor's inlet — not a sibling's, and not its container's.
tellis no exception: it is the same operation assendunless a generator can lower it more efficiently while keeping RIDDL's semantics, so atelltarget must have an inlet. An "inbox" is a LOWERING detail with no presence at the RIDDL design level — do not reason about one in validation. Consequences that are easy to get backwards:- An entity cannot publish on its context's outlet. Getting a message out of a context is entity outlet → connector → context inlet → handler → context outlet, so the FIRST step is the entity's own outlet and no context-level port substitutes for it.
- Intra-context, nothing needs ceremony. Inside one context any
processor/streamlet/connector may communicate with any other, and a connector
may drive a contained entity's own inlet directly. No dedicated
sink/sourcedefinition is required to carry a message between two definitions of one context. - At the boundary, and only there, the CONTEXT is the port. Crossing IN it is
the sink; crossing OUT it is the source.
This corrected two completeness checks that had encoded the opposite. 4h asked
whether the parent CONTEXT had an outlet (never asking about the entity at all)
and 4i whether anything in the context had an inlet; both are now per-entity, and
4i's context-level form is DELETED. Each is gated on the entity actually doing the
thing — handles no message ⇒ needs no inlet, emits nothing ⇒ needs no outlet — and
???is exempt. Fold STATE handlers in:entity.handlers ++ entity.states.flatMap(_.handlers), the idiomvalidateAskand four neighbouring checks already use. An entity's clauses commonly live inside aState, whichentity.handlersalone cannot see. (Adding the fold moved NOTHING in the corpus — it is correct-by-idiom, not evidenced by movement.)
-
A cross-context connector must land on the CONTEXT'S OWN portlet — an Error (Reid, 2026-08-18, choosing Error over CompletenessWarning).
StreamingValidation.checkBoundaryEncapsulation. Reaching past the boundary onto a contained definition's portlet contradicts the bounded context rather than under-stating it: a context publishes its message set and keeps its representations private, so binding a peer to a contained entity's existence and to its current command/query set means that entity can no longer change without breaking a stranger. That is why it is not a warning. The rule engages ONLY across contexts; intra-context it does not apply at all. Cost, ruled acceptable: 250 inbound + 241 outbound violations across 184 of 198 corpus entry points. NO ADAPTOR EXEMPTION (Reid, 2026-08-18, asked and answered). An Adaptor is the CM's boundary translation seam and reads like the canonical anti-corruption layer, so the obvious question is whether a cross-context connector may terminate on its port. It may not: being the translator does not make it the boundary. An adaptor is content of the context like anything else and sits BEHIND the context's own portlet; the context receives and routes inward to it. One rule, no exceptions, so the context's message set stays the single public surface. Only 12 of the corpus's 491 violations involved an adaptor anyway, despite 1,475 adaptors declared — this was never a cost question. Do not add an exemption tocheckBoundaryEncapsulation. -
A
telltarget needs BOTH a declared inlet AND a connector into it (Reid, 2026-08-18). The two rules genuinely compound, and that is intended. Atellrequires the target to have an inlet (above);checkUnattachedOutletsseparately reports a declared inlet that noconnectorreferences as "is not connected". So declaring the inlet to satisfy the first rule then trips the second — asked explicitly, and ruled that the CONNECTOR SHOULD EXIST:tellis sugar for a send on the outlet connected to the target's inlet (CM § 25.7 / A6), so the warning is correctly telling the author to model the channel rather than leave it implied. Do not "fix" this by teachingcheckUnattachedOutletsto count tells. There is no corpus population today (ZERO "is not connected" messages) because the corpus's tell-target entities declare no inlets at all; the interaction surfaces only as models comply. -
A queried repository with no index draws a CompletenessWarning — and the check deliberately does NOT name a field (Reid, 2026-08-18, on riddlg's request).
checkQueriedWithoutIndex. Fires when a repository has a schema, answers at least one query, and declares noindex onat all. 26 corpus sites. The ruling that produced it: an index belongs to the REPOSITORY, not to a field. riddlg asked for anindexedoption onField; declined, because a database index is a persistence concern and putting it on an entity's field leaks a generator's lowering choice into the model.Schema.indicesis the mechanism — 517 uses across 228 corpus schemas. Do not try to make it name the field. Both routes were MEASURED and neither is derivable: all 406 repositoryon querybodies in the corpus areprompt(...)/do "..."with zero comparisons (by design — a repository on-clause may be a singledostanding in for SQL); and taking the query TYPE's fields as the comparison operands — the better idea, since a query's parameters ARE its operands — maps to a stored record field 1 time by name and 19 by type out of 284 (6%). The correspondence between a query's parameters and the storage it filters has never been required of authors, so it is not in the models. Making it derivable needs a language change; prose on the query type would move the ambiguity, not remove it. The no-repository case is already diagnosed: an entity with no repository draws "has entities but no repository to persist them", so it is an under-specified model rather than a shape needing new syntax. -
???is a body that says "known to be incomplete" — validation must EXEMPT it (Reid's ruling, 2026-08-11). Any definition whose body is???earns at most a Missing warning saying the body should be provided. Every other check — structural requirements, completeness, wiring, cross-references — is skipped for it, because the author has already said don't expect much. This is why a check must not reason from what a???body does NOT contain:repository R is { ??? }is not missing its handlers, it is unwritten, and a rule that fires on it will fire on nearly every stub in the corpus. When adding a check, guard it onnonEmpty(see the streamlet shape check, which already does exactly this) rather than reporting the stub. -
A parse-time
error()PREEMPTS validation — the pass chain never runs. So whatever the parser says is the ONLY thing the author sees, and any more specific diagnostic ValidationPass would have produced for that input is silently lost. Learned 2026-08-08 adding theyields/repliespairing: checking it in the parser looked equivalent to checking it in validation and is not — it killed three existing A19 messages ("should be one of these message types", "Only command and query types may declare") because those inputs stopped reaching the pass that emits them. Rule: put a check in the parser ONLY when validation cannot make it, and the test is whether the evidence survives into the AST. The keyword/use-case pairing qualifies:usecaseis in the AST but which KEYWORD was written is not, so by validation time the evidence is gone. Everything else belongs in ValidationPass. Two corollaries:- A parser
error()is otherwise NON-FATAL and accumulating (seedefOfTypeKindType's type-alias check), so it looks harmless in isolation. The damage is to the passes that never run, not to parsing. - Parse-time messages travel a DIFFERENT channel:
parseInputWithMessages→PassInput.parseMessages→PassesResult.additionalMessages. They reach users under everyriddlccommand, butparseAndValidatein tests DISCARDS them — assert them withTopLevelParser.parseInputWithMessages(pattern:RecognizedOptionSetTest:98).
- A parser
-
ValueRefresolves in the RESOLVER (A55), not in validation.ResolutionPassqueues everyValueRefand resolves it inpostProcess(its anchors are reached through other references, and the pass visits definitions in source order). Only the ANCHOR differs from an ordinary reference: the on-clausebinding, else a field of the handled message / entity state / functionrequiresinput (valueScopeField), else the ordinaryfindAnchorroute. The rest isresolvePathFromAnchor's walk. Validation readsrefMap.anyDefinitionOf(path, parents.head). Do NOT reintroduce last-component name matching — that was A54'svalueAllowedFields/constantOf, and it letgarbage.nonsense.realFieldvalidate.let-locals stay LEXICAL — aletis not a Definition and is statement-ORDERED (visible only after its declaration, shadowed by inner blocks), which the symbol table cannot model. They are threaded bycheckStatementScopes; alet's type is DECLARED (let x: T = …) or INFERRED from its expression (letType). Because the resolver cannot see them, the ValueRef walk runs underResolutionPass.quietly(suppressesnotResolved/wrongType/ambiguous) and validation owns the diagnostic.Reference.idis a reference's optional LOCAL NAME, the onefrom di: context Csets — NOT the referenced definition's id. NoMessageRefever carries one, which is whyfindMatchingCandidate's on-clause arm was dead until A55 changed its guard toomc.msg.nonEmpty.
-
Message suggestions /
provideTips(1.24.0) — everyMessages.Messagecarries asuggestion: String; any pass attaches one at the message-creation site (via theaddX/checkhelpers' trailingsuggestionparam). The single chokepointMessages.Accumulator.addSTRIPS the suggestion unlessCommonOptions.provideTipsis set, andMessage.formatappends aSuggestion:line only when present — so default output is unchanged (no.checkchurn).riddlc advise==validatewithprovideTips=true;--provide-tips/ HOCONprovide-tipstoggle it. This replacedAIHelperPass: the pass and its tests are deleted; theTipmessage kind is retained but has no producer;RiddlLib.analyzeForTips/analyzeSourceForTips+ theadvisecommand are kept, re-implemented to run standard passes withprovideTips=true(analyze* are@deprecated). Human/AI catalog of every message→suggestion pair:MESSAGE_SUGGESTIONS.md(repo root). Three entity completeness checks promoted from old AIHelper tips (no command types, no event types, unhandled command) are ADVISORY — gated behindprovideTipsbecause message types are often context-scoped (summon[PlatformContext].options.provideTipsinvalidateEntity). The context-with-entities-but-no-repository check is ALWAYS-ON (c.repositories.isEmpty), gated only byshowCompletenessWarnings. -
Streamlet shape check — guard on
nonEmptybefore checking inlet/outlet counts (empty = placeholder). -
Adaptor cross-context type resolution — use the parent-independent
resolution.refMap.definitionOf[Type](pathId). -
Schema parser —
schemaKinduses"time-series"(hyphenated). Consecutive schemas needwith { ... }blocks. -
CheckMessagesTest
.checkfile format — lines starting with space are continuation lines; non-space lines begin new entries. Don't insert mid-continuation. -
RiddlResult[T] replaces
Either[Messages, T]— sealed ADT withSuccess[T]/Failure; useresult.toEitherfor backward compat.
- Container.flatten() recursively removes Include / BASTImport
wrappers in place. Use base
Pass, notDepthFirstPass— mutating contents during traversal corrupts ArrayBuffer iteration. - FileBuilder requires PlatformContext —
trait FileBuilder (using PlatformContext). All subclasses must propagate theusingclause.
- Multi-file mode —
flatten=false(default) preserves include/import structure;-s truecollapses to single file. PrettifyState.toDestination()strips leading/trailing/fromoutDir(URL basis can't start with/).- Include paths —
openIncludeusesurl.path(relative filename), noturl.toExternalForm(absolute URL). RiddlFileEmitter.trimTrailingNewline()— used incloseTypeto join}withwith {on the same line.
- parseString returns an opaque Root in JS — use
getDomains(root)orinspectRoot(root)to access data; TypeScript type is brandedRootAST. - RiddlLib.ast2bast(root) returns
RiddlResult[Array[Byte]]on the shared side /RiddlResult<Int8Array>in TS. - riddlLibJS tests override
Test / scalaJSLinkerConfigtoCommonJSModule. Production stays ESModule. - ESM shim hazard — never put
import ',import ", orimport(in shared string literals; ESM shim plugins rewrite these patterns. Use string concatenation.ESMSafetyTestenforces it. - npm prerelease publishing — sbt-dynver versions like
1.2.3-1-hashare prerelease per npm semver; pass--tag dev. - The opaque
*ASThandles inindex.d.tsare DELIBERATE. Do not "fix" them by exporting the AST to TypeScript (considered and DECLINED 2026-08-27, BACKLOG [2.9]).parseStringhands JS a branded handle (RootAST,EntityAST, …) that can only be passed back in; structure is served through flattened projections (inspectRoot,getOutline,getTree). Three reasons, in order of weight:- JSON serializes STATE; the AST's value is largely BEHAVIOUR.
AST.scalacarries ~540def/lazy valmembers that do not serialize — 182format, 67kind, the 34WithXaccessor traits, and derived answers likeeffectiveShape,Connector.isPersistent,Statement.canFail,Function.input/output.JsonModelhas ZERO references torefMap/symTab/usedBy, so no resolution output crosses either. - JSON keeps
Include/BASTImportas content entries, so a consumer walkingcontentssees the WRAPPER rather than through it — the exact defect that had riddl-generator emit 582 files with no entity class, at exit 0. A JSON-derived TS AST would invite every consumer to reimplement include-transparency and alias-resolution. - It would be a FIFTH reflective surface to keep in lockstep with parse/prettify/BAST/
JSON, and nothing would fail when it drifted.
The real consumers agree: riddl-vscode touches a raw AST handle zero times (all facade —
parseToTokens,parseString,getTree,validateString, …), and the consumer that truly walks the AST is Synapify, which is Scala.js and has the real objects, methods included. If this returns, the trigger is a TS consumer hitting a wall the facade cannot answer — add one accessor inside the conversion layer, never the AST.
- JSON serializes STATE; the AST's value is largely BEHAVIOUR.
- NEVER
@JSExportan overriddentoString. Interpolation compiles to JS+, sos"…$loc…"throwsTypeError: Cannot convert object to primitive valueand takes down the whole validation run on JS while the JVM passes.AtandURLboth carried it. JS callers gettoStringfrom the prototype anyway, so the export buys nothing.ToPrimitiveCoercionTestguards it and is JS-only by necessity — on the JVM every assertion in it passes regardless of the annotation, which is precisely why the bug survived. Grep before adding@JSExportanywhere near atoString. - Three JSON-surface traps, all the same shape — a second code path that
quietly disagrees with the first: (a) upickle TAGS sealed hierarchies —
making the DTOs extend a
sealed traitsilently added$typeto every object, and the round trip still agreed with ITSELF so the fixtures suite stayed green;ContentDtois a Scala 3 UNION for exactly this reason. (b) Hand-written codecs drop new fields —writeTypeExprandrefJsare hand-written, not derived, soRecordDto.commentsandRefDto.keywordwere added to the case class and went on being dropped. Anything inJsonModel's manual codec section needs the field added in TWO places. (c) The tag key is$kind, notkind—OnClauseDtoandSchemaDtocarry akindFIELD of their own andujson.Obj.fromkeeps the last of a duplicate pair, so the tag silently overwrote the data. - GitHub Packages npm auth —
gh auth refresh -s write:packagesis required.
A relative assertion cannot notice that its own population vanished. Both corpus suites
compared one count to another — identical mustBe reparsed, reparsed mustBe parsed,
parsed mustBe files.size — which is equally satisfied by 190 models and by 3, and
RiddlModelsRoundTripTest simply generates one case per model FOUND. A truncated corpus
therefore produced fewer green cases and said nothing.
Root2JsonCorpusTest's own docstring already recorded the same shape biting once: every read
failed, every failure was skipped, and its assertions reduced to 0 mustBe 0 for months.
Both now carry an absolute floor (MinimumModels, 189 and 190) and FAIL when the corpus is
present but partial, while an ABSENT corpus still SKIPS — Reid's [1.3] ruling, so a developer
without the sibling checkout is not blocked. Raise a floor when the corpus grows; never lower
one to make a run pass. Both floors were canary-tested by setting them to 9999 and
confirming the right cases redden: a check that has only ever passed is not evidence it works.
CI could also serve a stale result for these suites, and that is closed separately.
sbt/setup-sbt restores $HOME/.cache/sbt under a key of the form
Linux-X64-sbt-runner-<sbtVersion>-<actionVersion> — keyed on VERSIONS, not content — and
v2/ac maps task-input hashes to task RESULTS. The corpora are cloned by a workflow step and
are not build inputs, so their CONTENT is in no key. scala.yml now deletes v2/ac after
restore; the expensive caches (Coursier, ivy2, launcher, JDK, v2/cas) are untouched.
Both halves were needed because they are indistinguishable from outside: a replayed result and a truncated corpus both present as a fast green suite.
All three were hit in one session (2026-08-19), each looked like a finding rather than a broken instrument, and each was caught only by a CONTRADICTION. A zero from a measurement you have not calibrated is not evidence of absence — calibrate on a case known to be positive before trusting a zero.
grep '^\[error\]'matches nothing when output is ANSI-coloured. riddlc colours by default, so the line starts with an escape sequence, not[. This produced the report "both statement orderings are accepted" when one of them was rejected — the opposite of the truth. Pipe throughsed 's/\x1b\[[0-9;]*m//g'before counting anything.--show-style-warnings=trueSUPPRESSES style warnings. The same probe gave 2 findings on default flags and 0 with the flag that names them. Default already shows them; passing the flag explicitly is worse than passing nothing.- Every riddl-models
.confsetsshow-style-warnings = false, soriddlc from <model>.conf validatereports ZERO style findings across all 190 models. A style-warning census must validate the.riddlDIRECTLY. This is why a 452-site finding read as 0 corpus-wide.
Related, and the same family as the false-green traps below: validate ENTRY POINTS, not
include fragments. A fragment validated alone reports errors by construction, which reads
as corpus breakage. riddl-examples' FooBarSameDomain is a further trap — it is a
DELIBERATELY ambiguous fixture, so its duplicate-name errors are the fixture working.
- Three ways a test suite passes without running (all found in
#64, which had hidden 38 dead cases — including a completely
non-parsing
import "f.bast"— for months). A green suite is NOT proof the assertions ran; the check is to drop afail("canary")into a case body and confirm the suite goes red.- TestData lambda on a plain spec.
AbstractTestingBasis(utils/src/test/.../AbstractTestingBasis.scala) is a PLAINAnyWordSpec with Matchers, so itsintakes a by-name=> Any. Writingin { (td: TestData) => body }there merely constructs aFunction1and never evaluatesbody— deterministic Scala semantics, not sbt elision. That form is only meaningful onAbstractTestingBasisWithTestData(theFixtureAnyWordSpecbase) and everything derived from it (AbstractParsingTest→ParsingTest→AbstractValidatingTest→AbstractRunPassTest). Rule: if a case body takes(td: TestData), the suite MUST extend a…WithTestDatabase. - Abstract spec with no concrete subclass. The runner never
instantiates it, so its cases never appear in the log at all —
zero mentions, not even as skipped. Either make the class
concrete or declare a subclass in the platform aggregator
(
JVMTests.scala/JSTests.scala). Beware the silent trap: a class stays abstract because an inherited member is unimplemented (PrettifyPassTestdeclaredcheckAFile(Path, File)against a base wantingcheckAFile(Path, Path)). - Constructor parameters on a concrete suite. ScalaTest cannot
instantiate
class FooTest(using PlatformContext), so it is never discovered. Concrete suites take NO parameters; importcom.ossuminc.riddl.utils.pcinstead.
- TestData lambda on a plain spec.
- Unawaited Future in a non-async spec is a fourth variant of the
same failure:
inputFuture.map { … assertions … }followed byAwait.result(inputFuture, …)awaits the WRONG future — the assertions run detached and their failures are discarded. Await the MAPPED future. (BASTWriterSpecdoes NOT have this shape — this note said it did until 2026-08-14, wrongly. All five of its cases bindassertionFuture = inputFuture.map { … }and await THAT (BASTWriterSpec.scala:35/70,:77/123,:130/170,:177/225,:232/254), which is the correct form. The failure mode is still real and worth watching for; it just has no instance in the repo today.) test/tJVMresolve totestQuick— which incrementally SKIPS test suites it judges unaffected, even after a source change and even with~/Library/Caches/sbt/v2/accleared (a DIFFERENT cache from testQuick's own succeeded-tests tracking). Symptom: "No tests to run for language / Test / testQuick" and a false green. For a guaranteed full run after edits, use<module>/testOnly *(e.g.language/testOnly * ; passes/testOnly *), which ignores incremental state. This is separate from — and additive to — the action-cache fixture blindspot.sbt -batchruns only the FIRST command argument — found 2026-08-03.sbt -batch 'utils/testOnly *' 'language/testOnly *' …with seven module arguments ranutilsONLY, printed "Suites: completed 18 / Tests: succeeded 146 / All tests passed", and exited 0. The other six modules never ran and nothing said so. This is the most deceptive member of the false-green family because both the exit code and the word "passed" are honest about the 14% that executed. Put every command in ONE argument separated by;—sbt -batch 'a/testOnly *; b/testOnly *; …'— and then count theSuites: completedlines against the number of modules you asked for. (The;chain still aborts at the first failure, so a short count means either a red or a skip; either way, look.)- Corpus tests can resolve the WRONG
../riddl-models— or none — under sbt 2'sprojectMatrix, and the failure mode is a CANCELLED, green-looking suite. Found 2026-08-15 doing the corpus A/B for the!/notsynonymy plan (task 5).RiddlModelsRoundTripTestandRoot2JsonCorpusTestlocate the corpus viaPath.of("../riddl-models")resolved against the process cwd at sbt launch — NOTTest/baseDirectory, which underprojectMatrixis<root>/.sbt/matrix/<module>, several directories deeper than the repo root the relative path was written for. Depending on where sbt was launched from, that relative path can land on a directory that doesn't exist, or a different one that happens to exist — either way the test finds nothing to iterate over. A plain symlink at that path does not fix it and fails the SAME silent way: BSDfindand Java'sFiles.walkdo not descend into a directory reached via a top-level symlink argument without-L/FOLLOW_LINKS(confirmed both ways), so a symlinked corpus also reports zero files. The symptom in both cases is "No .conf files found" (or an equivalent zero-models message) followed by the suite reporting as cancelled, not failed — which reads as green in a summary scan exactly like thetestQuick-skip and abstract-spec-with-no- subclass members of this family. To tell: don't trust "all tests passed" from a corpus-reading suite — check that it actually reports the expected model COUNT (e.g. "models=190"), not zero, and use a real directory copy (cp -R, not a symlink) at the path the test computes when reproducing a corpus run outside CI. @JSExport*annotation placement — an@JSExportTopLevel(...)binds to the very next definition. Inserting a newenum/object/class between the annotation and its case class silently reattaches it (breakscJS, invisible tocJVM). Any AST edit near an exported type MUST be checked withcJS(andcNative), notcJVMalone.- Scala.js stale-incremental devirtualization — when a class gains a
WithXaccessor trait (or any mixin changing which field a trait method resolves to), the JS linker can keep a stale devirtualization of that method to the OLD owner's field, producing a runtimeTypeErrorwhilecJSsucceeds. Neither a passingcJSnor deleting the*-fastoptdir clears it — only<module>JS/cleandoes. Symptom: JS-only runtime failure that no compile catches. Learned addingWithContextsetc. toModule(#61). - Parse-time messages now surface —
warning()/deprecation()emitted during a successful parse used to be dropped (parseRulereturned the buffer only on fastparse failure). They now flow viaTopLevelParser.parseInputWithMessages→PassInput.parseMessages→PassesResult.additionalMessages, so deprecations show under everyriddlccommand, not justvalidate. New parse-time warnings therefore appear in.checkgoldens. - Scala Native builds with
gc = "none"— a bump allocator that NEVER reclaims. It is sbt-ossuminc'sWith.Nativedefault andbuild.sbtdoes not override it. Right for a short-lived binary; catastrophic for a test binary that runs the whole corpus in one process. Measured 2026-08-19 by sampling the liveriddl-commands-testprocess: 18.18 GB peak RSS withnone, 1.11 GB withimmix— 16x, identical results. A GitHub runner has 15,989 MB, so the Native corpus rows needed more memory than the machine had; the host killed them for 18 consecutive runs, always with the build step stillin_progressand NO log blob, which is why it stayed invisible.immixis now scoped toTeston the two corpus-reading rows (nativeTestGCinbuild.sbt); the SHIPPED riddlc still builds withnone, deliberately — changing that is a separate decision. A CI job that dies with no logs at all is a lost runner, not a timeout: a realtimeout-minuteskill is markedcancelledand KEEPS its logs. - release.yml — triggered by
gh release create. Builds native riddlc (macOS ARM64, Linux x86_64) + JVM universal. Sendsrepository_dispatchto homebrew-tap with SHA256s. Requires theHOMEBREW_TAP_SECRETrepo secret. - sbt-dynver wants a clean working tree —
git stashmodified files beforesbt publishon a release tag. - External-repo tests — download at construction time (not
in
beforeAll) for ScalaTestAnyWordSpec. - TatSu pin —
TatSu>=5.12.0,<5.17.0. 5.17.0 has a missingrichdependency that breaks import. - EBNF TatSu syntax —
{rule}+notrule+for positive closure; TatSu requires curly braces around the repeated element. - ScalaDoc + inline + opaque types — keep
inlineoffContentsextension methods (NPE inScalaSignatureProvider.methodSignature). Filed: scala/scala3#25306. - Scala 3.8.x scaladoc parallel race — multiple
doctasks running concurrently underpublishcrash indotty.tools.scaladoc.renderers.Resources.allResources. Symptom:(<module>Native / Compile / doc) java.lang.reflect.InvocationTargetExceptionpartway throughsbt clean test publish, leaving partial Maven artifacts on GitHub Packages. Workaround applied topassesNativeandriddlLibNativeinbuild.sbt:.nativeSettings(Compile / doc / sources := Seq.empty). If a future Native module trips the same race, add the same one line. annotateErrorLinetolerates EOF-boundaryAt— when a parser failure points one past EOF (typical "missing}" case), the failure'sendOffsetcan exceed the line range computed bylineRangeOf. Downstream slicing inannotateErrorLinealready clamps viaMath.min, so the function does NOT assert on the boundary. Don't reintroduce therequire(end >= index.endOffset, …)check that lived there before 1.23.3 — it crashes the error reporter itself and surfaces the real parse error as[severe] Exception Throwninstead of a normal[error].- sbt-riddl auto-downloads riddlc — caches in
~/.cache/riddlc/<version>/; three-tier resolution: explicit path > download > PATH. Use--no-ansi-messagesand strip ANSI for version parsing. PinriddlcVersionto a real release tag in scripted tests, not the dynver snapshot. ThirdPartyNotices.scalais a hand-maintained CONSTANT and goes stale in SILENCE. It is not generated and not read from a file, because only the JVM build has a filesystem — the Native binary has no resources at all and the same text must render under Scala.js.ThirdPartyNoticesTestpins the SHAPE (80 columns, every license group, both links) but cannot know a dependency was added, so regenerate it whenever deps change: JVM truth is the stagedriddlc/universal/stage/lib(what actually ships), JS/Native from<mod>/Runtime/fullClasspath, licenses from each artifact's POM in the Coursier cache (walk to the parent POM when the child declares none). Do NOT take the copyright holder from<developer>— that is the first committer, not the holder; for Apache projects readMETA-INF/NOTICEfrom the jar, which Apache-2.0 §4(d) requires be reproduced anyway. riddl carries NO copyleft dependency (all Apache-2.0/MIT/BSD-3-Clause) and the test asserts that ABSENCE —must not include "logback" / "LGPL" / "ScalaTest"— so a regression fails the build instead of quietly re-adding an obligation. The URL it prints is compiled into riddlc and cannot be silently redirected.- Run sbt as
sbt --server …when you need to read its output. The sbt 2 CLI is thesbtnnative thin client talking to a DETACHED server, so piped stdout comes back empty and the build looks hung.--serverruns in the foreground with attached stdout. Do not trust its exit code — grep the log. - sbt plugin visibility — use
private[plugin] def(notprivate def) so the compiler doesn't warn "private method never used" when sbt macros generate the usage. (The sbt-riddl plugin is now Scala 3 / sbt 2, but the pattern still holds.)
- PR merge with branch protection —
gh pr merge --admin --merge --delete-branch=false.