LLMs are strong in popular, high-resource languages (Kotlin, Java, Python)
but measurably weak at generating external, low-resource DSLs — and STEP’s
native exchange format (Part 21, graphs of #123-style entity references)
is a particularly hostile one to read or write directly.
kSTEP applies Typed Domain Grounding (TDG) — the pattern already validated by its sibling project, kUML: expose a domain as a type-safe, embedded Kotlin DSL with semantic validation and structured errors, so that both human developers and LLM agents can work in it reliably. The syntax stays Kotlin (high-resource); only the vocabulary is domain-specific, enforced by the type system. A strict type system, a compiler, and structured validation errors form a feedback loop the LLM can act on directly, without a separate benchmark or fine-tuning step. kSTEP compiles that DSL down to the neutral, standardized ISO 10303 exchange format.
kSTEP is not "just another CAD tool." The ambition is to cover the entire STEP standard (ISO 10303) — CAD is the entry point, not the boundary. STEP spans far more than mechanical geometry: manufacturing (STEP-NC), electrical and electronics (assemblies, PCBs, wiring), kinematics, composites, FEM/simulation, and plant/process data are all part of the standard, each carved out as its own Application Protocol (AP242, AP238, AP210, AP209, and others). Those APs are the building blocks kSTEP intends to grow into over time.
The CAD market today splits into two camps: classic CAD suites (SolidWorks, CATIA, Siemens NX, Fusion 360, Inventor, Onshape) — GUI- centric, click-based, proprietary formats, AI bolted on after the fact — and code-first CAD (OpenSCAD, CadQuery, Build123d, Replicad, JSCAD) — scriptable and Git-friendly, but niche and without real AI integration. kSTEP is the deliberate third way: code-first like the second camp, AI-first like neither camp, and STEP-native instead of proprietary. It is the same thesis as its sibling project kUML in the UML/SysML space, applied across the full breadth of engineering rather than just software architecture — kUML covers the IT domain, kSTEP covers the engineering domain, both grounded in the same Typed Domain Grounding (TDG) principle described above.
V1 deliberately starts narrow — a single Application Protocol (AP242), scoped down to product structure and metadata with no geometry and no PMI (see Roadmap below and kSTEP-ADR-0001) — but the architecture is meant to carry the wider vision from the start, not to box it in.
The project has completed M1 (Headless MVP — EXPRESS parser, semantic
model, Kotlin code generation, WHERE-rule evaluation, the kstep-core
runtime DSL surface, STEP Part 21 export/import, and DERIVE/INVERSE/
UNIQUE clause capture, six waves) and has started M2 (AI Layer)
with an MCP server exposing the M1 DSL as LLM tool-calling tools (M2
Welle 1, below). Concretely, as of this writing:
-
An ANTLR4-based parser for the EXPRESS schema language (ISO 10303-11) exists and successfully parses a hand-written AP242-subset fixture in
kstep-tests, plus (M1 Welle 4) a small excerpt of the real, official AP242 schema containing the six V1 entities and their supporting declarations — see below. -
A semantic model walks the ANTLR parse tree into a typed Kotlin AST (
dev.kstep.express.semantic): entities with their explicit attributes (declared type, optional-ness), supertype/subtype relationships, andWHERE-rule clauses. Attribute types resolve against a per-schema, case-insensitive symbol table built in a two-pass walk, so forward references (an entity referencing an entity declared later in the same schema) resolve correctly.WHERE-rule bodies are captured verbatim as unparsed source text. -
A KotlinPoet-based code generator (
dev.kstep.express.codegen.ExpressKotlinCodeGenerator) turns the semantic model into idiomatic Kotlindata class`es, one per entity, with named (and, for `OPTIONALattributes, defaulted) constructor parameters mirroring the EXPRESS declaration order. Verified end-to-end against all six entities of the AP242-subset fixture, and (M1 Welle 4) against the real six V1 entities extracted from the official AP242 schema — see below.SUBTYPE OFis handled by attribute-inheritance flattening, not Kotlin inheritance (see the SUBTYPE OF inheritance-flattening entry below); constructs the generator still doesn’t turn into Kotlin — redeclared attributes,LOGICAL/NUMBER/BINARY-typed attributes, and zero-attribute entities — raise a structuredCodeGenExceptioninstead of emitting a silently wrong or partial class. ATYPEreference (DefinedTypeRef) resolves only the single-level simple-alias case (TYPE x = STRING;and the other five simple EXPRESS types —ExpressDefinedType’s `underlyingSimpleType) to its underlying Kotlin type; anything needing further indirection — an aggregation, aSELECT/ENUMERATION, or aTYPEthat itself references anotherTYPE— still raisesCodeGenException, deliberately not resolved transitively (M1 Welle 4). -
(M1 Welle 4) The generator has now run against real AP242 ground truth for the first time, not just the hand-written AP242-subset fixture. A curated excerpt of the official
ap242ed2_dis2_mim_lf_v1.101.exp(ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019 — see NOTICE for full provenance, which parts are verbatim vs. deliberately adapted, and the precedent this excerpt’s inclusion rests on) — the six V1 entities plus the supportingTYPE/ENTITYdeclarations their attribute types reference, directly or (as of the SUBTYPE OF inheritance-flattening wave below) via SUBTYPE OF — is vendored atkstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.expand regenerated bydev.kstep.express.codegen.Ap242V1CodeGen(see thegenerateExpressKotlinGradle task below). The complete, unmodified official schema (49,942 lines, 2,122 entities) is not vendored in this repository — this wave’s real-schema verification is scoped to this excerpt, not a full-schema-scale exercise of the parser/semantic model. Against this real-schema ground truth, codegen now succeeds for all six V1 entities, includingnext_assembly_usage_occurrence— see the SUBTYPE OF inheritance-flattening entry below for how. -
SUBTYPE OF attribute-inheritance flattening (M2, following M1 Welle 4’s real-schema cross-check above):
dev.kstep.express.semantic. InheritanceResolverflattens a SUBTYPE OF chain into aResolvedEntity— every ancestor’s explicit attributes prepended (supertype-most-general first, matching STEP Part 21 instance encoding) to the entity’s own — because a generated Kotlindata classcannot itself extend anotherdata class; Kotlin inheritance was considered and rejected in favor of this flattening approach (seeExpressKotlinCodeGenerator’s KDoc for the full rejected-alternatives writeup). An `ABSTRACT SUPERTYPEcontributes attributes to its subtypes but is never itself emitted as a Kotlin class. Structured failures (SemanticModelException): a SUBTYPE OF cycle, a chain deeper than 32 levels, an unresolved supertype name, more than one SUBTYPE OF ancestor (EXPRESSAND/ANDORmultiple inheritance, out of scope for V1), aSELF...RENAMEDredeclared attribute encountered while flattening, and a flattened property-name collision between two different ancestors. Against real-schema ground truth,next_assembly_usage_occurrence’s actual SUBTYPE OF chain is `product_definition_relationship→product_definition_usage→assembly_component_usage→next_assembly_usage_occurrence; none of the first three declareABSTRACT SUPERTYPEin the real schema, so per EXPRESS semantics they are themselves instantiable too — but nothing in the six V1 entities' generated Kotlin references them as an attribute type, soAp242V1CodeGendeliberately does not emit Kotlin classes for them (see that file’sSUPPORT_ENTITY_NAMEScomment).NextAssemblyUsageOccurrence’s flattened constructor is `id, name, description?, relatingProductDefinition, relatedProductDefinition, referenceDesignator?(6 parameters). This also incidentally resolves the two entities flagged as a limitation in M1 Welle 4:product_context/product_definition_contextare themselvesSUBTYPE OF (application_context_element), and once that chain flattens cleanly they codegen too — soProduct’s and `ProductDefinition’s generated Kotlin is now fully self-contained, with no dangling class references anywhere in the emitted file (see `Ap242V1CodeGenTest). -
A real correctness fix from this cross-check: the real AP242 MIM’s
approval.levelattribute is typedlabel(aTYPE label = STRING;alias), notINTEGERas Welle 3 had assumed without a real schema to verify against.ap242-subset.exp’s `approvalentity andkstep-core’s `Approval/ApprovalBuildernow useString; theSELF.level >= 0WHERE rule (meaningless for a string) is replaced withSELF.level <> '', mirroring the non-empty-string pattern already used byproduct.id/product_definition.id. -
A
WHERE-rule evaluator (dev.kstep.express.validation) interprets the actually-occurring subset of EXPRESS WHERE-rule expressions: comparisons (>,>=,<,⇐,=,<>),SELF.attributereferences (and the equivalent bareattributeform) resolved against an instance attribute value bag, string/integer/real literals, andAND/OR/NOTboolean combinators.WhereRuleExpressionBuilderre-parses the verbatim expression text via a newExpressParserFactory.parseExpressionentry point and walks theexpressionparse tree into a small AST;WhereRuleEvaluatorevaluates that AST against aMap<String, WhereRuleValue>;WhereRuleValidatorties both together into a(entityName, rules, attributeValues) → List<WhereRuleViolation>API. Anything outside the supported subset (EXISTS(),SIZEOF(), other function calls, aggregate/set operations,QUERY, arithmetic operators, the tri-stateLOGICALtype, …) raises a structuredUnsupportedWhereExpressionExceptioninstead of silently doing the wrong thing; a genuine evaluation-time problem (a missing attribute, a non-boolean result, an incompatible-type comparison) raisesWhereRuleEvaluationException. Both the re-parse and the AST walk are depth-guarded against pathologically deep (but syntactically valid) expressions, mirroring the existingStackOverflowErrorguard at the ANTLR-parse boundary and theMAX_TYPE_NESTING_DEPTHguard in the semantic model. Theap242-subset.expfixture now carries aWHERErule on five of its six entities (product_definition_formationdeliberately has none), exercised end-to-end from parse through evaluation. -
kstep-corenow has hand-authored, type-safe Kotlin builders for all six V1 entities (dev.kstep.core.ap242):product,personAndOrganization,approval,productDefinitionFormation,productDefinition,nextAssemblyUsageOccurrence. Each is a named-parameter, lambda-with-receiver DSL function returningdev.kstep.core.ValidationResult<T>—Valid(value)orInvalid(violations), never a thrown exception for a validation failure. Building an instance runs the WHERE-rule evaluator against the built values and also checks that every mandatory entity-typed reference attribute and every mandatory primitive attribute was actually set, collecting all violations (never stopping at the first) as structuredDslViolation`s with `KSTEP-W-001(WHERE rule not satisfied),KSTEP-M-001(missing mandatory reference), or (M2 Welle 7)KSTEP-M-002(missing mandatory primitive attribute) codes, analogous to kUML’sKUML-E-xxxstructured errors — see Roadmap above for whatKSTEP-M-002covers and what it deliberately doesn’t. These six types are hand-authored independently ofExpressKotlinCodeGenerator’s generated output, and (M2 Welle 8) that relationship is now precisely characterized rather than a vague "future work" placeholder: (i) codegen already produces schema-faithful equivalents of all six types plus their six support entities in the generated `dev.kstep.generated.ap242v1package (see Status above andAp242V1CodeGenTest) — the generator needed no changes; (ii)kstep-coreis a deliberately ergonomic layer aligned to theap242-subset.expfixture, which simplifies the real excerpt in specific, now-enumerated ways (entity references modeled asString, a few real attributes omitted, one optionality narrowed, one attribute invented, several WHERE rules synthesized — see each type’s KDoc indev.kstep.core.ap242for the per-attribute rationale); (iii) this wave addsdev.kstep.tests.Ap242CoreSchemaConsistencyTest, which re-derives the real shape live fromap242-v1-entities.expon every run and fails the build on any divergence not explicitly named in its allowlist — so the gap is now bounded and guarded, not open-ended. See Roadmap for the full divergence inventory and what remains deliberately deferred. -
(M2 Welle 7) A correctness fix in the same spirit as the
approval.levelone above:Product.kt’s doc comment claimed `id, name, description, all STRING, none OPTIONAL, which contradicted the real AP242 MIM (description : OPTIONAL text;,ap242-v1-entities.expline 138) and the builder’s owndescriptionhandling — corrected to state plainly that onlyid/nameare non-OPTIONAL. This wave also closes the mandatory-primitive-attribute-presence gap:product.nameandnext_assembly_usage_occurrence.nameare non-OPTIONALlabelattributes with no WHERE rule, previously left silently defaultable to""by bothkstep-core’s builders and `kstep-mcp’s `build_product/build_next_assembly_usage_occurrencetools (which used to writename = args.name ?: ""). Both now use a nullable presence sentinel and surfaceKSTEP-M-002— including through the MCP tool path, since that is exactly the "compiler as oracle for the LLM" surface this project exists to demonstrate — whennameis never assigned. An explicitly emptynameis unaffected and staysValid. -
(M1 Welle 5)
kstep-step21now has a real STEP Part 21 (ISO 10303-21 physical file exchange format) writer and reader for the six V1 AP242 entities, indev.kstep.step21. Part 21 is a different, much simpler ISO 10303 sub-format than EXPRESS (the physical#123=ENTITY_NAME(…);exchange syntax, not a schema language), so this wave deliberately does not reuse the ANTLR EXPRESS grammar/parser — it is a small, independent, hand-rolled scanner (Part21Tokenizer) plus a two-pass graph resolver (Part21GraphResolver).Part21Writer.write(header, roots)serializes a graph of already-validatedkstep-coreinstances, deduplicating shared references by object identity (not structural equality) so a single sharedProductgets exactly one#Nno matter how manyProductDefinitionFormation`s reference it. `Part21Reader.read(source)parses Part-21 text back, resolving forward references (an entity may reference a#Ndefined later in the file) via an iterative, non-recursive topological sort, and reconstructs each instance through itskstep-corebuilder function — soWHERE-rule validation runs on read too, not only on write. Genuine structural malformation (missing semicolon, malformed#N=, unknown entity name, wrong argument arity/kind, a reference pointing at the wrong target entity type, a dangling reference, a reference cycle, or a DoS-guard trip on source length/instance count/nesting depth/reference-chain depth) raises one of six structured exceptions (Part21SyntaxException,Part21EncodingException,Part21DanglingReferenceException,Part21CycleException,Part21LimitExceededException,Part21WriteException). AWHERE-rule failure while reconstructing a parsed instance is not thrown — it surfaces as aDslViolationin the returnedPart21ReadResult.violations, with any instance that (directly or transitively) depended on a failed instance recorded inPart21ReadResult.skippedinstead of being attempted, mirroringkstep-core’s own "a validation failure is structured data, not a thrown exception" philosophy. V1 does not implement ISO 10303-21’s `\X\/\X2\/\X4\non-ASCII escape mechanism — string values are scoped to printable ASCII plus the''-doubling convention for an embedded'; anything else raisesPart21EncodingExceptionrather than being silently mis-encoded. Acceptance-criterion honesty: kSTEP-ADR-0001 names a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD) as the real-world bar for Part 21 export/import. No such tool is available in this development environment, so that external validation has not been done — this wave’s acceptance test is a self-roundtrip only (Part21Reader.read(Part21Writer.write(header, model)) == model, verified inPart21RoundtripTest). This closes the kSTEP-own-format half of the ADR-0001 bar, not the external-interop half; see Roadmap below, which carries the FreeCAD/external-tool gap forward explicitly rather than overclaiming it. -
kstep-step21has no CLI wiring yet (nokstep render/kstep importcommand) — it is a library API only, callable from tests, matching how Wave 2’s codegen and Wave 3’s WHERE-rule evaluator were introduced as plain Kotlin APIs before any CLI wiring. -
(M1 Welle 6) The semantic model now captures
DERIVE,INVERSE, andUNIQUEentity-body clauses — a gap flagged across three separate wave reviews (ExpressEntity’s own "losslessly captured" KDoc was, until now, aspirational rather than true). Three new data classes in `dev.kstep.express.semantic:ExpressDerivedAttribute(name, declared type via the sameparameterTyperesolution explicit attributes already use, verbatim initializerexpressionText— DERIVE expressions are captured, not evaluated, exactly like WHERE rules),ExpressInverseAttribute(name, optional SET/BAGInverseAggregationKind+ bounds, unresolved rawtargetEntity, optionalforEntityqualifier,forAttribute), andExpressUniqueRule(optional label, verbatimreferencedAttributeslist, covering both bare andSELF\entity.attr-qualified forms).ExpressEntitygained matchingderivedAttributes/inverseAttributes/uniqueRulesfields, defaulting to empty lists exactly likewhereRuleswhen a clause is absent. DERIVE and UNIQUE assertions are cross-checked against the realproduct_definition,product_definition_formation,next_assembly_usage_occurrence, andperson_and_organizationentities inap242-v1-entities.exp; no V1 entity has anINVERSEclause, so that capture is proven against a small, hand-written synthetic fixture instead. A redeclared (SELF\entity.attr) DERIVE or INVERSE name throws a structuredSemanticModelExceptionrather than silently dropping the clause or NPE-ing, mirroringmapParameterType’s existing precedent for out-of-scope constructs. Evaluation of `DERIVE/INVERSE/UNIQUEandExpressKotlinCodeGeneratorcodegen support remain out of scope — this wave is capture only, exactly like WHERE-rule capture (Wave 2) preceded WHERE-rule evaluation (Wave 3). -
(M2 Welle 1 — start of the "AI Layer" milestone) A new module,
kstep-mcp(dev.kstep.mcp), exposes the sixkstep-coreV1 entity builders andkstep-step21’s Part 21 export as MCP (Model Context Protocol) tools over stdio, built on the official `io.modelcontextprotocol:kotlin-sdk-server:0.14.0(MIT-licensed, see License and attribution below). Nine tools:build_product,build_person_and_organization,build_product_definition_formation,build_product_definition,build_next_assembly_usage_occurrence,build_approval,export_part21,list_entities,get_entity. Because every MCP tool call is stateless (JSON in, JSON out) but five of the six V1 builders take entity-typed Kotlin references (ProductDefinitionFormation.ofProduct: Product, etc.),kstep-mcpadds a session-scoped, in-memoryEntityStore: each successfulbuild_*call stores its validated entity under a caller-supplied id/handle (the entity’s own naturalidwhere one exists —product,product_definition_formation,product_definition,next_assembly_usage_occurrence— or an arbitrary handle string for the two entities that don’t —person_and_organization,approval), and later calls reference earlier ones by that string. The store is scoped to oneServerprocess for its lifetime (reset only on restart) — thekotlin-sdk’s own transport model (stdio, one process per session; `ChannelTransportsupports multiple concurrent sessions against oneServer, exercised directly in the test suite) offers no finer-grained session boundary worth adding complexity for at this wave’s scope. AConcurrentHashMapbacks the store, withput’s capacity check and insert made atomic under a single lock (a bare `size()-then-put()sequence would race under concurrent sessions); the store is capped at 512 entities and every string field/list is length/size-bounded, so a malformed or adversarial tool call cannot grow memory unboundedly or crash the server process. Every tool returns one of five structuredCallToolResulterror shapes instead of an opaque MCP protocol error —malformed_input,unknown_reference(an id/handle that was never built, or was built as the wrong entity type — collected for every bad reference in one call, not just the first),validation_failed(a lossless JSON mirror ofkstep-core’s own `DslViolationlist — the same structured "compiler as oracle" feedback loop a Kotlin caller already gets),store_capacity_exceeded, andexport_failed— and no raw exception message or stack trace ever reaches the caller (every handler catches its own exceptions before the SDK’s own top-level handler, which does interpolatee.messageverbatim, gets a chance to). Tested end-to-end (not just handler-level) through the SDK’s ownChannelTransportin-memory client/server transport (io.modelcontextprotocol:kotlin-sdk-testing,@ExperimentalMcpApi) — a realClientdrives a realServerwith all nine tools registered, including a full multi-tool-call build-and-export sequence whose Part 21 output is parsed back withkstep-step21’s own `Part21Readerand compared for equality, proving the MCP layer round-trips genuinely, not just that individual calls don’t crash. Explicitly out of scope this wave: any actual LLM API call or benchmark (README roadmap item 5’s second half — a separate, larger piece of future work),kstep-cliwiring (nokstep mcpcommand yet —runStdioServer()is fully wired and ready for a future one-line CLI subcommand to call), and any transport other than stdio. -
(M2 Welle 3)
kstep-cliis no longer a placeholder skeleton:kstep mcpstarts thekstep-mcpserver over stdio by calling its existing, unmodifiedrunStdioServer(). No arguments,help, or--helpprint a short usage message and exit0; an unknown subcommand (ormcpwith extra trailing arguments) prints the same usage message and exits1. The argument-dispatch logic lives in a pureresolveCommand(args: Array<String>): CliCommandfunction, kept deliberately separate frommain()’s side effects (`println/exitProcess/runBlocking) so it’s directly unit-testable —main()itself callsexitProcesson the error path, which would tear down a test JVM if invoked in-process, so it is intentionally never called from a test. Tests live inkstep-tests(CliMainTest), not akstep-cli-local test source set, matching this project’s one-test-module pattern (see Building below). Verified empirically (not assumed) what happens oncerunStdioServer()returns: at that point the JVM has exactly one non-daemon thread left (main, parked inrunBlocking), so the process exits on its own with code0— no explicitexitProcess(0)needed on that path. Also verified: an immediate stdin EOF (e.g.< /dev/null) does not reliably makerunStdioServer()return on its own — akotlin-sdk-server:0.14.0/StdioServerTransportbehavior, not introduced by this wave and out of scope to fix here (would mean changingkstep-mcp’s own transport handling). In practice this matches how MCP hosts actually manage stdio subprocesses: SIGTERM/SIGKILL the child directly rather than relying on it observing a clean stdin close, and a SIGTERM does terminate the process immediately, verified the same way. No new CLI subcommand beyond `mcp, no argument-parsing library — a plainwhenoverargsis enough at this scope. -
(M2 Welle 4)
DERIVE-expression evaluation andUNIQUE-constraint enforcement now exist, building on M1 Welle 6’s capture-only clauses.dev.kstep.express.validationgainedWhereRuleEvaluator.evaluateToValue(a thin additive entry point returning the rawWhereRuleValuean expression reduces to, withoutevaluate’s top-level boolean requirement) and a new `DerivedAttributeEvaluator, which re-parses anExpressDerivedAttribute’s initializer text and evaluates it via the same `WhereRuleExpressionBuilder/WhereRuleEvaluatormachinery WHERE rules already use — DERIVE’s initializer and WHERE’sdomainRuleare the identicalexpressiongrammar production, so this is deliberately not a second parser/evaluator. Verified against all three real DERIVE clauses inap242-v1-entities.exp:product_definition’s and `person_and_organization’s (`get_name_value(SELF),get_description_value(SELF)) andnext_assembly_usage_occurrence’s (a two-hop `SELF\entity.attr\entity.attrchain) all correctly throwUnsupportedWhereExpressionException— function calls and multi-hop qualifier chains are outside the supported subset, exactly like WHERE rules using the same constructs, and that is this wave’s expected, complete outcome for those three clauses, not a gap. A small synthetic fixture (DERIVE canonical_id : STRING := SELF.id;and similar) proves positive evaluation actually works when the expression is within the supported subset.INVERSEevaluation remains explicitly out of scope — no real V1 entity has anINVERSEclause, andkstep-corehas no bidirectional-relationship modeling to evaluate against.Separately,
kstep-mcp’s `build_next_assembly_usage_occurrencetool now enforces the real AP242next_assembly_usage_occurrenceUNIQUE UR1rule —(reference_designator, relating_product_definition)must be unique across everynext_assembly_usage_occurrencealready in theEntityStore— comparingrelating_product_definitionby object identity (mirroringEntityStore.keyOf’s existing precedent for "no natural id" entity comparisons). A conflict returns a new structured `unique_constraint_violatedtool error (added toMcpToolError.kt, following the file’s existing shape/conventions) naming the rule, the conflicting id, and the duplicated field values, instead of silently allowing the duplicate or crashing. The rule constant and the scan logic live inkstep-mcp’s tool file, not in `kstep-core— UNIQUE is fundamentally cross-instance, andkstep-core’s builders are pure, single-instance constructors with no visibility into other instances; the `EntityStoreis the only place in this codebase with that visibility. The scan isO(n)over the store’s current entries, bounded by the store’s existingmaxEntitiescap, so this does not introduce unbounded work. Two rules are deliberately not newly enforced, both documented in code and here rather than faked or approximated: NAUO’s ownUNIQUE UR2(product_definition_occurrence_id,relating_product_definition), becauseproduct_definition_occurrence_idis itself aDERIVEvalue chained throughproduct_definition_occurrence, an entity nowhere modeled amongkstep-core’s six V1 types; and `product_definition_formation’s own `UNIQUE UR1(id,of_product), because theEntityStorealready keys everyproduct_definition_formationby that sameid, so the composite key can never actually collide withoutiditself colliding first — a claim proven empirically by a test (KStepMcpServerTest), not just asserted in prose. The UR1 scan and the store write run atomically underEntityStore’s existing `capacityLock(a newputIfNoConflict, alongside the plainputused by every other tool), so two concurrent, conflictingbuild_next_assembly_usage_occurrencecalls can no longer both pass the scan before either lands. -
(M2 Welle 6) A new module,
kstep-script(dev.kstep.script), adds a Kotlin-scripting DSL surface:.kstep.ktsscripts author kSTEP models with the same sixkstep-corebuilders (available without explicit imports, viaKStepScriptCompilationConfiguration’s `defaultImports) and end with astepFile(fileName = "…") { … }call whose result — aKStepModel— becomes the script’s return value.KStepModelBuilder.root(…)accepts either an already-unwrapped entity (the concisegetOrThrow()pattern) or a rawValidationResult— the latter *aggregates every violation across every registered root intoKStepModel.violationsinstead of aborting the script at the first bad entity, the preferred form for LLM/JSON consumption (kSTEP-ADR-0001 acceptance criterion #3).KStepScriptHost.eval(aFileor inlineStringoverload) compiles and runs a script and maps every outcome — never a thrown exception or a raw stack trace — into a structuredKStepScriptOutcome:Success,CompilationError(KSTEP-S-001, a Kotlin syntax/type error, with source line/column),NoModelProduced(KSTEP-S-002, the last expression wasn’t aKStepModel),ValidationErrors(the aggregatedDslViolationlist, or — belt-and- braces — a single syntheticKSTEP-S-004violation when a script usesgetOrThrow()directly and it throws), andRuntimeError(KSTEP-S-003, any other script-thrown exception, exception class
message only). A blanktimestampinstepFile(…)is defaulted to the current time by the host (not the builder, keeping the DSL surface itself pure/deterministic) once a model is known to be fully valid.kstep-cligained a newkstep export <script.kstep.kts> [--out <file.step>] [--output json]subcommand:--outdefaults to the script’s own name with.kstep.ktsreplaced by.step;--output jsonrenders the same success/error information as a JSON document instead of human-readable text (both are the command’s own stdout output, so both stay plainprintln, matching this module’s existing reasoning forUSAGE_TEXT).resolveCommand’s argument parsing for `exportis a small hand-rolled flag loop, no argument-parsing library, same stance as themcp/helpdispatch above.kstep-scriptis deliberately not sandboxed —KStepScriptCompilationConfigurationusesdependenciesFromCurrentContext(wholeClasspath = true)with no curated/allowlisted classpath, mirroring kUML’s trusted in-process script path (kUML’s sandboxed path exists only for its hosted-portal "compile someone else’s script" scenario, which kSTEP has no equivalent of yet — seeKStepScriptHost’s KDoc for the full reasoning). Verified against the real `kstep-clidistribution, not just the test JVM:./gradlew :kstep-cli:installDistfollowed by running the builtbin/kstep-cli exportbinary against both fixtures below reproduces the exact JSON/text/exit-code behavior asserted in the test suite —kotlin-compiler-embeddableand the rest of the scripting toolchain ride along automatically onkstep-cli’s `runtimeClasspath(and so intoinstallDist’s `lib/) via the ordinaryimplementation project(":kstep-script")dependency, no jlink/native-image wiring needed for this. Two fixture scripts (hello-assembly.kstep.kts,hello-invalid.kstep.kts) live inkstep-tests/src/test/resources— not underkstep-scriptitself, matching this project’s established single-shared-test-module convention (see Building below) — and double as the README usage examples below.
See Roadmap for what’s planned next.
| Module | Purpose | Current state |
|---|---|---|
|
Core DSL types (schema-independent runtime support for the generated
Kotlin DSL): |
Working: |
|
ANTLR4-generated EXPRESS parser ( |
Working: parses EXPRESS source into an ANTLR parse tree (verified
against a real-schema excerpt, not just the AP242-subset fixture);
walks it into a semantic AST; generates Kotlin
|
|
STEP Part 21 (ISO 10303-21 physical file exchange format)
reader/writer for the six V1 AP242 entities ( |
Working: |
|
Kotlin-scripting DSL surface for |
Working (M2 Welle 6): |
|
Command-line entry point ( |
Working (M2 Welle 3): |
|
MCP server exposing the six V1 entity builders and Part-21 export as
LLM tool-calling tools ( |
Working (M2 Welle 1): nine tools — |
|
Cross-module integration tests (Kotest) |
Smoke test for the EXPRESS parser; semantic-model, naming-convention,
and code-generation tests; WHERE-rule expression-builder, evaluator,
and validator tests (unit-level and an end-to-end fixture
integration test); |
Requires JDK 21. The Gradle wrapper pins Gradle 9.6.1.
./gradlew clean checkcheck runs ktlint (now including kstep-mcp, kstep-cli, and
kstep-script), kstep-express’s `generateExpressKotlin task
(regenerates the six V1 entities from real-schema ground truth — see
Status above — and fails the build if its success/skip counts ever
drift from the documented boundary), and the Kotest test suite in
kstep-tests: the EXPRESS parser smoke test, semantic-model tests,
naming-convention tests, code-generation tests (including real-schema
six-V1-entity regeneration), WHERE-rule expression-builder/evaluator/
validator tests, kstep-core DSL builder tests, (M1 Welle 5)
kstep-step21 Part 21 writer/reader/roundtrip tests, (M2 Welle 1) the
end-to-end kstep-mcp MCP server test suite (KStepMcpServerTest),
(M2 Welle 3) CliMainTest for kstep-cli’s argument-dispatch logic,
and (M2 Welle 6) `KStepScriptHostTest/KStepScriptExportTest for
kstep-script’s scripting host, plus `CliMainTest’s new `export
argument-parsing cases. kstep-mcp, kstep-cli, and kstep-script
themselves compile and are ktlint-checked as part of check; none of
them has tests of its own — their tests live in kstep-tests, matching
every other module’s pattern (the two *.kstep.kts test fixtures are
resources under kstep-tests/src/test/resources, loaded via
getResourceAsStream — not under kstep-script itself, for the same
reason).
kSTEP logs through kotlin-logging
(io.github.oshai:kotlin-logging-jvm), an idiomatic Kotlin wrapper
over SLF4J. kstep-mcp’s server lifecycle and tool-call outcomes are
the first real usage of it (M2 Welle 2); `kstep-core, kstep-express,
and kstep-step21 remain untouched, since their existing structured-
exception/ValidationResult error model already covers their
diagnostic needs. (M2 Welle 6) kstep-script’s `KStepScriptHost is the
second real usage: it logs a warning if host.eval(…) itself throws
before producing a scripting result at all (the one case its own
KStepScriptOutcome mapping can’t attribute to the script source) —
every expected outcome (compile error, validation failure, runtime
exception) is structured data, not a log line. The SLF4J backend is
slf4j-simple (MIT-licensed, zero-config, prints to stderr) —
kstep-mcp depends on it at runtimeOnly, and kstep-tests carries a
matching testImplementation, so ./gradlew clean check now shows real
log output instead of SLF4J’s "No SLF4J providers were found …
Defaulting to no-operation (NOP) logger" warning.
Ap242V1CodeGen’s `generateExpressKotlin console report and
kstep-cli’s `main() — including its USAGE_TEXT help output (M2
Welle 3) and (M2 Welle 6) kstep export’s own success/error rendering,
text or JSON — remain plain `println deliberately: that is
Gradle-task/CLI stdout output, not diagnostic logging, and stays outside
kotlin-logging’s scope.
generateExpressKotlin can also be run on its own:
./gradlew :kstep-express:generateExpressKotlinIt writes generated Kotlin source under
kstep-express/build/generated/expressKotlin/main. That output is a
build artifact only, not added to any module’s sourceSet — two of
the five entities it successfully generates (Product,
ProductDefinition) reference support entity types
(ProductContext, ProductDefinitionContext) that themselves cannot
be code-generated in V1 (see Status above), so wiring the output into
an actual compilation would break it.
Parsing an EXPRESS schema and generating Kotlin `data class`es from it works end-to-end today:
import dev.kstep.express.codegen.ExpressKotlinCodeGenerator
import dev.kstep.express.semantic.ExpressSemanticModelBuilder
val schema = """
SCHEMA example_schema;
ENTITY product;
id : STRING;
name : STRING;
END_ENTITY;
END_SCHEMA;
""".trimIndent()
val model = ExpressSemanticModelBuilder.build(schema)
val kotlinSource = ExpressKotlinCodeGenerator.generateFileSource(
model.schemas.single(),
"dev.kstep.generated.example",
)
println(kotlinSource)
// package dev.kstep.generated.example
//
// public data class Product(
// public val id: String,
// public val name: String,
// )ExpressParserFactory.parse (used internally by
ExpressSemanticModelBuilder.build) throws ExpressSyntaxException on
the first syntax error instead of returning a partial tree.
ExpressSemanticModelBuilder throws SemanticModelException for a
named type that resolves to neither a known entity nor a known TYPE
in the same schema. ExpressKotlinCodeGenerator throws
CodeGenException for EXPRESS constructs it doesn’t yet turn into
Kotlin (see Status above). None of this is wired into the
CLI yet; the generateExpressKotlin Gradle task (see Building above)
is the one place it is wired into a Gradle task today, scoped to the
six V1 entities.
Building a kstep-core DSL entity runs WHERE-rule validation and
returns a structured result instead of throwing for a validation
failure:
import dev.kstep.core.ValidationResult
import dev.kstep.core.ap242.product
val result = product(id = "BRK-001") {
name = "Bracket"
description = "Mounting bracket"
}
when (result) {
is ValidationResult.Valid -> println(result.value)
is ValidationResult.Invalid -> println(result.violations)
}
// An empty id violates product's WHERE rule (SELF.id <> ''):
val invalid = product(id = "") { name = "Bracket" }
println((invalid as ValidationResult.Invalid).violations)
// [DslViolation(code=KSTEP-W-001, entityName=product, ruleLabel=wr1,
// expressionText=SELF.id <> '', message=WHERE rule wr1 not satisfied: SELF.id <> '')]WhereRuleValidator.validate — the lower-level API kstep-core’s
builders call internally — re-parses WHERE-rule expression text and
evaluates it against a `Map<String, WhereRuleValue>:
import dev.kstep.express.validation.WhereRuleSpec
import dev.kstep.express.validation.WhereRuleValidator
import dev.kstep.express.validation.WhereRuleValue
val violations = WhereRuleValidator.validate(
entityName = "approval",
rules = listOf(WhereRuleSpec(label = "wr1", expressionText = "SELF.level <> ''")),
attributeValues = mapOf("level" to WhereRuleValue.StringValue("")),
)
println(violations)
// [WhereRuleViolation(entityName=approval, ruleLabel=wr1, expressionText=SELF.level <> '', sourceLine=0)]A WHERE-rule expression using a construct outside the supported subset
(EXISTS(), QUERY, arithmetic, …) throws
UnsupportedWhereExpressionException; a genuine evaluation-time
problem (a missing attribute, a non-boolean result, an
incompatible-type comparison) throws WhereRuleEvaluationException.
Neither is ever silently swallowed into an empty violation list.
(M1 Welle 5) Exporting a kstep-core product structure to a STEP
Part 21 physical file, then reading it back:
import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productDefinition
import dev.kstep.core.ap242.productDefinitionFormation
import dev.kstep.core.getOrThrow
import dev.kstep.step21.Part21Header
import dev.kstep.step21.Part21Reader
import dev.kstep.step21.Part21Writer
val bracket = product("BRK-001") { name = "Bracket" }.getOrThrow()
val builtFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = builtFormation }.getOrThrow()
val header = Part21Header(
fileName = "bracket.step",
timestamp = "2026-07-19T12:00:00",
schemaIdentifiers = listOf("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF"),
)
val exported = Part21Writer.write(header, listOf(definition))
println(exported)
// ISO-10303-21;
// HEADER;
// FILE_DESCRIPTION((),'2;1');
// FILE_NAME('bracket.step','2026-07-19T12:00:00',(),(),'','kSTEP','');
// FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF'));
// ENDSEC;
// DATA;
// #1=PRODUCT('BRK-001','Bracket','');
// #2=PRODUCT_DEFINITION_FORMATION('BRK-001-F','',#1);
// #3=PRODUCT_DEFINITION('BRK-001-D','',#2);
// ENDSEC;
// END-ISO-10303-21;
val result = Part21Reader.read(exported)
println(result.isFullySuccessful) // truePart21Reader.read throws Part21SyntaxException,
Part21EncodingException, Part21DanglingReferenceException,
Part21CycleException, or Part21LimitExceededException for
structurally malformed input (see Status above). A
WHERE-rule failure while reconstructing a parsed instance is never
thrown — it surfaces in Part21ReadResult.violations, with any
dependent instance recorded in Part21ReadResult.skipped instead of
attempted:
// A hand-edited file with an empty product id (violates product's WHERE rule):
val handEdited = """
ISO-10303-21;
HEADER;
FILE_DESCRIPTION((),'2;1');
FILE_NAME('n','t',(),(),'','','');
FILE_SCHEMA(('S'));
ENDSEC;
DATA;
#1=PRODUCT('','','');
#2=PRODUCT_DEFINITION_FORMATION('PDF-001','',#1);
ENDSEC;
END-ISO-10303-21;
""".trimIndent()
val badResult = Part21Reader.read(handEdited)
println(badResult.violations) // {1=[DslViolation(code=KSTEP-W-001, ...)]}
println(badResult.skipped) // {2=[1]}Part21Writer/Part21Reader are called directly here as a library
API; (M2 Welle 6) below, the kstep export CLI subcommand wraps this
same pair via the kstep-script scripting DSL instead of hand-written
Kotlin.
(M2 Welle 1) Driving the kstep-mcp server end-to-end from an MCP
client — the same tool-call sequence an LLM agent would make, here
shown via the SDK’s own in-memory ChannelTransport rather than a
real stdio subprocess:
import dev.kstep.mcp.buildServer
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
val server = buildServer() // registers all nine tools on a fresh EntityStore
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
server.createSession(serverTransport)
val client = Client(clientInfo = Implementation(name = "example-client", version = "1.0"))
client.connect(clientTransport)
val built = client.callTool("build_product", mapOf("id" to "BRK-001", "name" to "Bracket"))
println(built.structuredContent) // {"id":"BRK-001","name":"Bracket","description":"","entityType":"product"}
// A validation failure comes back as structured content, not a protocol-level error.
// Both the empty id (WHERE rule) and the never-set name (mandatory attribute, (M2 Welle 7))
// are collected in the same response, not just the first one found:
val invalid = client.callTool("build_product", mapOf("id" to ""))
println(invalid.isError) // true
println(invalid.structuredContent)
// {"errorKind":"validation_failed","violations":[
// {"code":"KSTEP-W-001","entityName":"product", ...},
// {"code":"KSTEP-M-002","entityName":"product","message":"required attribute 'name' ..."}
// ]}To run the server for real, kstep-cli wraps this directly (M2 Welle 3):
kstep mcpThis blocks on stdin/stdout until the session closes, exactly like
runStdioServer() above — kstep mcp is a one-line call to it, nothing
more. In practice, expect the process to be terminated by its MCP host
(SIGTERM/SIGKILL) rather than to exit on its own from a clean stdin
close: an immediate stdin EOF does not reliably make runStdioServer()
return in the underlying kotlin-sdk-server:0.14.0 transport, a
pre-existing SDK behavior out of scope for kstep-cli to work around
(see Status above). Once runStdioServer() does return —
whichever way the session actually closes — the process exits on its own
with code 0; no explicit shutdown call is needed on that path.
(M2 Welle 4) Evaluating a DERIVE initializer expression works exactly
like WhereRuleValidator.validate above, via DerivedAttributeEvaluator:
import dev.kstep.express.validation.DerivedAttributeEvaluator
import dev.kstep.express.validation.WhereRuleValue
val value = DerivedAttributeEvaluator.evaluate(
expressionText = "SELF.id",
attributeValues = mapOf("id" to WhereRuleValue.StringValue("W-1")),
)
println(value) // StringValue(value=W-1)
// The real AP242 product_definition.name DERIVE (get_name_value(SELF)) is a function
// call, outside the supported subset — this throws, exactly like an equivalent WHERE
// rule would:
// DerivedAttributeEvaluator.evaluate("get_name_value(SELF)", emptyMap())
// -> UnsupportedWhereExpressionExceptionbuild_next_assembly_usage_occurrence now also enforces NAUO’s real
UNIQUE UR1 rule — a second NAUO sharing an already-used
(reference_designator, relating_product_definition) pair is rejected:
// after building NAUO-1 with reference_designator "RD-1" and
// relating_product_definition_id "PD-A":
val conflict = client.callTool(
"build_next_assembly_usage_occurrence",
mapOf(
"id" to "NAUO-2",
"relating_product_definition_id" to "PD-A",
"related_product_definition_id" to "PD-OTHER",
"reference_designator" to "RD-1",
),
)
println(conflict.isError) // true
println(conflict.structuredContent)
// {"errorKind":"unique_constraint_violated","entityType":"next_assembly_usage_occurrence",
// "ruleLabel":"UR1","conflictingId":"NAUO-1","fields":[...]}By contrast, build_product_definition_formation deliberately has no
such UNIQUE check — and needs none. Its real UR1 is (id, of_product),
and because the EntityStore already keys every formation by id, two
formations can only share a composite key by sharing id, which is a
re-build (overwrite), not a second instance. Two formations of the same
product under different ids both succeed (a product legitimately has
many formations); re-building under the same id overwrites. No
unique_constraint_violated is reachable for this entity — verified by
test, not just asserted:
// after building product "SHARED-PRODUCT":
val first = client.callTool(
"build_product_definition_formation",
mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
val second = client.callTool(
"build_product_definition_formation",
mapOf("id" to "PDF-B", "of_product_id" to "SHARED-PRODUCT"),
)
println(first.isError) // null — different id, so (id, of_product) cannot collide
println(second.isError) // null — same story, even though of_product is shared
// rebuilding PDF-A under the SAME id overwrites, it does not conflict:
val rebuilt = client.callTool(
"build_product_definition_formation",
mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
println(rebuilt.isError) // null(M2 Welle 6) Exporting a *.kstep.kts script directly to a STEP Part 21
file via kstep export, no hand-written Kotlin caller needed. A script
ends with stepFile(fileName = "…") { … }; the six kstep-core
builders and stepFile/root are available without any import
(KStepScriptCompilationConfiguration’s `defaultImports):
// bracket.kstep.kts
val bracket = product("BRK-001") { name = "Bracket" }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = bracketFormation }.getOrThrow()
stepFile(fileName = "bracket.step") {
root(definition)
}kstep export bracket.kstep.kts
# Exported 1 root(s) to bracket.step--out overrides the derived output path; --output json renders the
same information as a JSON document instead of human-readable text —
useful for tool/LLM consumption (kSTEP-ADR-0001 acceptance criterion
#3). The two root(…) forms differ in what happens to a validation
failure: root(entity) takes an already-getOrThrow()-unwrapped
entity, so an Invalid result aborts the script immediately; passing
the raw ValidationResult to root(…) instead aggregates every
violation across every registered root, which is what the second
fixture below demonstrates:
// hello-invalid.kstep.kts — an empty product id violates product's WHERE rule
stepFile(fileName = "hello-invalid.step") {
root(product(id = "") { name = "Nameless" })
}kstep export --output json hello-invalid.kstep.kts{"status":"error","errorKind":"validation_failed","violations":[
{"code":"KSTEP-W-001","entityName":"product","ruleLabel":"wr1",
"expressionText":"SELF.id <> ''","message":"WHERE rule wr1 not satisfied: SELF.id <> ''"}
]}A Kotlin syntax error, an unresolved reference, a script whose last
expression isn’t a KStepModel, and a plain runtime exception all
produce their own structured KSTEP-S-xxx error document the same way
(compilation_error, no_model_produced, runtime_error) — see Status
above for the full KStepScriptOutcome breakdown — never a raw Kotlin
stack trace on stdout/stderr. Both fixture scripts above are the actual
kstep-tests test resources (hello-assembly.kstep.kts is the
3-part-assembly variant of the first example), not just README prose —
see the kstep-tests Modules row above.
Per the project’s scope-reduction decision (kSTEP-ADR-0001, analogous to kUML-ADR-0004), V1 deliberately excludes B-rep geometry and PMI, and focuses on a semantic core that a type-safe DSL benefits from most:
-
EXPRESS parser (done) + EXPRESS-to-Kotlin code generation (done, see above — semantic model and KotlinPoet-based generator, verified end-to-end against the six-entity AP242-subset fixture, and, as of M1 Welle 4, against the real, official AP242 schema and its six V1 entities — see Status and Building above). Supertype/subtype attribute inheritance (needed for
next_assembly_usage_occurrenceand most real AP242 entities, and for the two support entitiesproduct_context/product_definition_contextthatproduct/product_definitionreference) is now done too — see the SUBTYPE OF inheritance-flattening entry in Status above (dev.kstep.express.semantic.InheritanceResolver). Still pending:DERIVE/INVERSE/UNIQUEclause codegen (still not started), andSELECT/ENUMERATION-type and transitive-TYPE-alias resolution.DERIVE/UNIQUEevaluation (as opposed to codegen) is now partially done — capture was M1 Welle 6, and M2 Welle 4 added evaluation for the WHERE-rule- supported expression subset (DerivedAttributeEvaluator) plusnext_assembly_usage_occurrence’s `UNIQUE UR1enforcement inkstep-mcp— see Status above. Still open:INVERSEevaluation (no real V1 entity has anINVERSEclause, andkstep-corehas no bidirectional-relationship modeling to evaluate against) and NAUO’sUNIQUE UR2(depends onproduct_definition_occurrence, an entity not modeled inkstep-core) — both carried forward explicitly as named limitations, not silently dropped. -
A semantic AP242 core: product structure and metadata, without B-rep geometry and without PMI (PMI references geometry shape aspects and moves with the geometry milestone). The Kotlin type layer for its six core entities (
PRODUCT,PRODUCT_DEFINITION
revision,PRODUCT_DEFINITION_FORMATION,NEXT_ASSEMBLY_USAGE_OCCURRENCE,APPROVAL,PERSON_AND_ORGANIZATION) now generates correctly from the fixture and — all six, see Status above — from the real AP242 schema, andkstep-corenow has a runtime construction/ validation API on top of hand-authored equivalents of those six types (see Status above). (M2 Welle 8 — codegen reconciliation, resolved-this-wave vs. deliberately-deferred:)Resolved this wave: every divergence between
kstep-core’s hand-authored shape and the real AP242 excerpt is now enumerated, documented in each type’s KDoc (`dev.kstep.core.ap242), and pinned bydev.kstep.tests.Ap242CoreSchemaConsistencyTest(re-derives the real shape live fromap242-v1-entities.expviaInheritanceResolveron every run, fails the build on any new, undocumented drift). The full inventory:Entity Divergence Status approvalstatusis entity-typed (approval_status) in the real schema;Stringinkstep-coreaccepted, guarded
approvalauthorized_bydoes not exist on the real entity at all — a kSTEP-invented convenience linkageaccepted, guarded; removal deferred (see below)
approvalreal entity has no WHERE rule;
kstep-core’s `wr1is synthesizeddocumented (WHERE rules are out of the guard’s scope, see below)
person_and_organizationthe_person/the_organizationare entity-typed (person/organization) in the real schema;Stringinkstep-coreaccepted, guarded
person_and_organizationreal
WR1/WR2useSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule is synthesizeddocumented
productreal entity has a mandatory
frame_of_reference : SET [1:?] OF product_context;kstep-coreomits itaccepted, guarded
productreal
WR1usesSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule is synthesizeddocumented
product_definitionreal entity has a mandatory
frame_of_reference : product_definition_context;kstep-coreomits itaccepted, guarded
product_definitionreal
WR1usesSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule is synthesizeddocumented
product,product_definition,product_definition_formationreal
OPTIONAL text description;kstep-coremodels it as non-nullStringdefaulting to""accepted, guarded (named convention, "OptionalStringAsEmptyDefault")
product_definition_formationreal
UNIQUE UR1: id, of_productnot enforced anywhere inkstep-coreresolved by construction —
idis theEntityStore’s primary key, so `(id, of_product)cannot collide unlessidalone does; store id-keying already guarantees UR1, proven byKStepMcpServerTest("product_definition_formation’s UNIQUE UR1 needs no enforcement: store id-keying already guarantees it"). Contrast NAUO’sUNIQUE UR1above, whose fields exclude the store key and so needed a real scan.next_assembly_usage_occurrencereal flattened shape inherits an
OPTIONAL text description(product_definition_relationship);kstep-coreomits itaccepted, guarded
next_assembly_usage_occurrencereal inherited
reference_designator(assembly_component_usage) isOPTIONAL identifier;kstep-corenarrows it to non-nullStringdefaulting to""accepted, guarded
next_assembly_usage_occurrencereal
WR1isacyclic_product_definition_relationship(…)(unsupported); `kstep-core’s rule is synthesizeddocumented
Deliberately deferred, not attempted this wave: entity-typed modeling of
kstep-core’s `status/the_person/the_organization(i.e. actually buildingApprovalStatus/Person/Organization/ProductContext/ProductDefinitionContext/ApplicationContextinto the hand-authored layer, and droppingauthorizedBy). This needs capabilities the hand-authored layer does not have yet —LIST/aggregation support (person’s `middle_names/prefix_titles/suffix_titles) andEXISTS()WHERE-rule evaluation (person’s `WR1) — and would, without those, only re-derive by hand whatExpressKotlinCodeGeneratoralready generates faithfully (see Status above). It is therefore a milestone-scoped architecture decision, not a wave: the real fork is whether to rebuildkstep-core’s ergonomic layer on top of the generated `dev.kstep.generated.ap242v1types (no duplication, but couples the ergonomic API to generated shapes and needs a builder/ validation layer over generated data classes) or to keep hand-authoring and extend the hand layer withLIST+EXISTSsupport — an explicit decision for the project owner, not pre-committed here.authorizedByremoval,status-as-entity modeling, and the Part-21APPROVALreal-arity alignment (kstep-step21’s `Part21EntityKind, currently 3 positional args, matchingap242-subset.exp, not the real 2-attributeapproval) all travel together in that future wave — removingauthorizedByalone would leavestatusa bareString, which matches neither the fixture nor the real schema, an incoherent half-step.WHERE-rule divergences are documentation-only, not covered by the drift test: every real WHERE rule on these six entities uses
SIZEOF/USEDIN/EXISTS/acyclic_…, all outsideWhereRuleValidator’s supported expression subset, so there is no supported real rule to mechanically align a synthesized one to. `UNIQUE/DERIVE/INVERSEclause enforcement is likewise out of the drift test’s scope. The test guards attribute shape only (field set, primitive-vs-entity-vs-aggregation category, optionality) — where silent structural drift would actually hide. -
Validation: EXPRESS
WHERErules surfaced as structured errors. Done for the supported expression subset (comparisons,SELF.attributereferences,AND/OR/NOT, string/integer/real literals) — see Status above. Constructs outside that subset (EXISTS(),SIZEOF(), other function calls, aggregate/set operations,QUERY, arithmetic operators, the tri-stateLOGICALtype) are not evaluated and raise a structured exception instead. Note that WHERE-rule evaluation is not the same thing as EXPRESS mandatory-attribute- presence enforcement (the$-token / non-OPTIONAL mechanism) — (M2 Welle 7) this is now enforced for non-OPTIONAL primitive attributes too, not only entity-typed references:kstep-core’s builders model such an attribute as a nullable presence sentinel (mirroring the existing entity-reference pattern) and emit a structured `KSTEP-M-002(missing mandatory attribute) when one is left unset, alongsideKSTEP-M-001for references. In the six V1 entities this closes the two real instances —product.nameandnext_assembly_usage_occurrence.name— both non-OPTIONALlabelattributes that previously carried no WHERE rule and so silently accepted an unset value as an empty string. Enforcement is presence, not non-emptiness: an explicitly assigned empty string is a legal value for a non-OPTIONAL STRING that no WHERE rule constrains, and staysValid. Still deliberately out of scope: non-OPTIONAL attributes on the three hand-authored entities whose Kotlin shape currently diverges from the real schema (approval.status,person_and_organization.the_person/the_organizationare hand-modeled as strings where the real schema has entity-typed references; reconciling those with generated output is the entity-typed-reconciliation item explicitly deferred in the "codegen reconciliation" roadmap entry above), and the general model-driven form (auto-deriving presence checks fromResolvedEntity/ExpressAttribute.isOptionalinstead of a per- builder null-check) which awaits that reconciliation. No non-STRING (INTEGER/REAL) primitive presence case exists among the six V1 entities, so that reasoning is not yet needed either. -
STEP Part 21 export/import. (M1 Welle 5) Done for the kSTEP-own-format half:
Part21Writer/Part21Readerinkstep-step21losslessly roundtrip the six V1 entities through ISO 10303-21 physical file text (Part21Reader.read(Part21Writer.write(header, model)) == model, verified inPart21RoundtripTest) — see Status and Usage above. Still open, and not to be conflated with the above: kSTEP-ADR-0001’s actual acceptance bar is a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD), which has not been attempted — no such tool is available in this development environment. A self-roundtrip proves internal consistency (the writer and reader agree with each other); it does not prove kSTEP’s Part 21 output is actually interoperable with real-world STEP tooling, nor that this reader can parse real-world Part 21 files beyond the six V1 entity shapes it targets. Closing that gap is future work, not assumed by this wave. (M2 Welle 6, done) CLI wiring now exists:kstep export <script.kstep.kts>(via the newkstep-scriptmodule’s*.kstep.ktsscripting DSL andKStepScriptHost) compiles and runs a script and writes itsPart21Writeroutput to disk — see Status and Usage above. The external-CAD/PLM-tool roundtrip gap above is unchanged by this — `kstep export’s output still needs the same manual FreeCAD import to close acceptance criterion #2. -
An MCP server and an LLM benchmark comparing raw STEP, CadQuery, and the kSTEP DSL as generation targets. (M2 Welle 1) Split into two halves, only the first of which is done: the MCP server itself (
kstep-mcp— nine tools over stdio wrapping the six V1 builders and Part-21 export, a bounded session-scopedEntityStore, structured tool-call errors, tested end-to-end via the SDK’s in-memory transport — see Status and Usage above) is complete and gives an LLM agent the same "compiler/validator as oracle" structured feedback loop a Kotlin caller already gets, just over MCP tool calls instead of a Kotlin compiler error. The LLM benchmark half — actually calling an LLM API and comparing raw STEP, CadQuery, and the kSTEP DSL (viakstep-mcp) as generation targets — is real API cost and experiment-design work, deliberately not started, and deferred to an explicit discussion with the project owner rather than assumed by this wave. (M2 Welle 3, done)kstep-clinow wraps the MCP server behind akstep mcpsubcommand — see Status and Usage above. (M2 Welle 4, done)build_next_assembly_usage_occurrencenow enforces NAUO’s realUNIQUE UR1rule against the `EntityStore’s current entries — see Status and Usage above. -
Later: geometry via an OpenCascade (OCCT) bridge, additional STEP Application Protocols as needed.
kSTEP is licensed under the Apache License, Version 2.0 — see LICENSE.
The bundled EXPRESS grammar
(kstep-express/src/main/antlr/dev/kstep/express/grammar/Express.g4)
is vendored from lutaml/express-grammar
and is BSD-2-Clause licensed (Ribose Inc.); see NOTICE for
full attribution.
A small excerpt of the real AP242 EXPRESS schema
(kstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.exp
— nineteen declarations, mostly verbatim (two spots deliberately
adapted), not the complete 2,122-entity schema) is copied from
the official CAx-IF/MBx-IF-published ap242ed2_dis2_mim_lf_v1.101.exp
(ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019); see
NOTICE for full provenance, which parts are verbatim vs.
adapted, and the precedent this rests on.
kstep-mcp depends on the official
Model Context Protocol
Kotlin SDK (io.modelcontextprotocol:kotlin-sdk-server, maintained by
Anthropic in collaboration with JetBrains), which is MIT licensed —
compatible with kSTEP’s Apache 2.0 license, not vendored or modified,
pulled in as a normal Gradle dependency.
