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). Separately, the long-planned geometry milestone has now
started too (Geometrie Welle 1 — a new kstep-geometry module bridging
to OpenCASCADE Technology (OCCT) via JNI; see the kstep-geometry row
below and docs/adr/ADR-0005-occt-jni-bridge.adoc), joined by
Geometrie Welle 6, pulled forward — a new kstep-constraints module
bridging to the PlaneGCS 2D geometric constraint solver via JNI; see the
kstep-constraints row below and
docs/adr/ADR-0006-planegcs-constraint-bridge.adoc. 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 10 —
kstep-corerebuilt on the codegen-generated types) The fork M2 Welle 9 left open (extend the hand-authored layer withLIST/EXISTS()support, or rebuild it on top of the generated types) is resolved:kstep-coreno longer hand-authors any AP242 entity shape at all.Ap242V1CodeGen.CORE_MODULE_OPTIONSgenerates all twelve entities (the six V1 types plus the six support entities their shapes require —application_context,product_context,product_definition_context,approval_status,person,organization) directly intokstep-core’s own compiled output, `internal constructor+@ConsistentCopyVisibility, wired via a Gradleconsumable/resolvableconfiguration pair (not a cross-projectsourceSetsreach-through).dev.kstep.core.ap242now supplies exactly one validating builder function per entity on top. Eleven of the M2 Welle 8 divergence-table’s thirteen entries are resolved outright;EXISTS(<attribute>)WHERE-rule support was added specifically forperson.WR1, the one genuinely real, evaluable rule in the schema slice oncepersonis modeled; a newKSTEP-A-001/AGGREGATION_BOUND_VIOLATEDcode distinguishes "an aggregation was never set" from "set, but empty" for every real[1:?]-bounded aggregation in the schema slice:product. frame_of_reference : SET [1:?](mandatory) andperson’s `middle_names/prefix_titles/suffix_titles : OPTIONAL LIST [1:?](each individually optional, but non-empty once assigned at all). Full rationale, the resolved divergence table, and the accepted consequences (a real ergonomic regression onproduct/productDefinition, which now need an explicit context built first; the Part-21/MCP breaking changes) are indocs/adr/ADR-0004-core-on-generated-types.adoc. -
(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=, an unknown entity name — under the defaultPart21ReadMode.STRICT; see thekstep-step21row below forTOLERANT— 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. This includes a bare reverse solidus (\, 0x5C), rejected symmetrically on both sides: although it is itself printable ASCII, an unescaped\is exactly what a conformant external Part-21 reader/writer would take as the start of an escape sequence it (unlike V1) does implement, so bothPart21Writer(on write) andPart21Reader/Part21Tokenizer(on read, in eitherPart21ReadMode) reject it withPart21EncodingExceptionrather than emit or silently mis-read a value whose meaning could change on the far side of the export boundary. 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 fifteen 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 twelve AP242 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. -
Headless preview rendering (see
docs/adr/ADR-0011-headless-preview-rendering.adoc):kstep-cligainedkstep render <script.kstep.kts> [-f svg|png|text] [-o <file>] [-w <px>] [--height <px>] [--with-step] [--require-geometry] [--output json], writing a headless SVG/PNG/text preview of a script with no window and no display server. A new module,kstep-render, carries the mesh/projection/rasterizer code Viewer-Welle 1 introduced (moved out ofkstep-viewer, which now depends on it) plus two new writers,TriangleSvgWriter(a deterministic vector render) andTextCardRenderer(the shared SVG/PNG text-card renderer both a no-geometry script and a not-renderable-this-run script fall back into) — all with zero Compose Multiplatform dependency, verified via./gradlew :kstep-cli:dependencies --configuration runtimeClasspath.KStepModelgained ashapes: List<ShapeAssignment>field and ashape(…)builder function (kstep-scriptnow depends onkstep-shape), so a script can register anOcctShapefor preview independently ofroot(…)—kstep export’s `Part21Writeroutput is completely unaffected. Never a silently-swapped file type: the requested/resolved container (SVG/PNG/.txt) is always honored, only the content varies. See the ADR for the full content/exit-code matrix and the two distinct "OCCT is unavailable" failure points this wave had to fall back from cleanly. -
Asciidoctor pre-processing integration (see
docs/adr/ADR-0019-kstep-asciidoc.adoc): a newkstep asciidocsubcommand pre-rendersksteppreview blocks (a markdown fence, an AsciiDoc[kstep]/[source,kstep]delimited block, or akstep::path[]block macro) in.adocfiles intoimage::references — mirroringkuml-dev/kUML’s `kuml-asciidocpre-processing approach, since a JVM Asciidoctor extension cannot run inside Antora’s own Node.js pipeline. Two new modules:kstep-preview(the headless-preview pipeline extracted, unchanged in behavior, out ofkstep-cli’s former `RenderCommand.kt, now shared by bothkstep renderandkstep asciidoc) andkstep-docs:kstep-asciidoc(the scanner/rewriter/file-tree processor itself, depending only onkstep-preview— never onkstep-cli).RenderExtractionParityTestpins the two callers' output as byte-identical subprocess-vs-in-process. Pure source move forRenderFormat/PreviewSummary(dev.kstep.cli→dev.kstep.preview) — no published artifacts exist yet, so this is not a breaking change for anyone outside this repo.
See Roadmap for what’s planned next.
| Module | Purpose | Current state |
|---|---|---|
|
Core DSL types ( |
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 twelve AP242 entities ( |
Working: |
|
Kotlin-scripting DSL surface for |
Working (M2 Welle 6): |
|
Command-line entry point ( |
Working (M2 Welle 3): |
|
MCP server exposing the twelve AP242 entity builders and Part-21
export as LLM tool-calling tools ( |
Working (M2 Welle 1, expanded M2 Welle 10): fifteen tools —
|
|
OCCT (Open CASCADE Technology) JNI bridge — the start of the geometry
milestone ( |
Working (Geometrie Welle 1): |
|
PlaneGCS 2D geometric constraint-solver bridge — Geometrie Welle 6,
pulled forward, plus C-Welle 2/2c ( |
Working: |
|
AP242 shape assignment — Geometrie Welle 4, merges |
Working: |
|
Headless, Compose-free mesh projection + SVG/PNG/text-card rendering — headless-preview-rendering wave ( |
Working: |
|
Shared headless-preview pipeline extracted from |
Working: |
|
Pre-processing Asciidoctor integration — |
Working: |
|
Interactive OCCT shape viewer — Viewer-Welle 1
( |
Working: a real Compose Desktop window ( |
|
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).
.github/workflows/ci.yml runs two jobs on every push to master, every
pull request, and on demand (workflow_dispatch):
| Job | Runner | Native deps installed | Gradle invocation | What it proves |
|---|---|---|---|---|
|
|
Eigen, Boost, Skiko runtime libs — deliberately not OCCT |
|
The stable gate: compilation, ktlint, |
|
|
OCCT (4 packages) + Eigen + Boost + Skiko runtime libs |
|
Real, end-to-end OCCT coverage: |
The two-job split exists because there is no GitHub-hosted GA runner
image with an OCCT new enough for this project: ubuntu-latest
(Ubuntu 24.04) ships OCCT 7.6.3, whose STEP toolkits are still named
TKSTEP/TKSTEPBase/TKSTEPAttr — libTKDESTEP.so (OCCT 7.8+, what
kstep-geometry/build.gradle.kts links against via -lTKDESTEP) does
not exist there. Installing the OCCT dev packages on that runner would
make compileOcctBridge run (headers present) and then fail at link
time — worse than not installing them, where the task is skipped and the
documented graceful-degradation path runs instead. ubuntu-26.04 is the
only GitHub-hosted image whose OCCT (7.9.2) provides libTKDESTEP.so,
matching this project’s own development environment; it is in public
preview as of this writing, so build on the GA label stays the gate
that never depends on a preview image. If the preview label ever becomes
unreliable (capacity, image regressions), the documented fallback is
runs-on: ubuntu-latest with container: ubuntu:26.04.
kstep-geometry (Geometrie Welle 1, extended by Geometrie Welle 5a for
extrude/fillet — see docs/adr/ADR-0005-occt-jni-bridge.adoc and
docs/adr/ADR-0008-occt-feature-operations.adoc) links against a
system-installed OpenCASCADE Technology (OCCT) library. This is entirely
optional: ./gradlew clean check stays green with or without it, and no
OCCT binary/header/source is vendored in this repository. To get real
end-to-end geometry coverage (as opposed to only the graceful-degradation
path), install the OCCT dev packages first — on Ubuntu/Debian:
sudo apt-get install --no-install-recommends \
libocct-foundation-dev libocct-modeling-data-dev \
libocct-modeling-algorithms-dev libocct-data-exchange-dev
./gradlew clean check -Pkstep.occt.require=trueWithout those packages installed, kstep-geometry’s `compileOcctBridge
Gradle task is skipped (not failed), and
dev.kstep.geometry.OcctKernel.availability() reports
OcctAvailability.Unavailable at runtime with a human-readable reason;
OcctBridgeSmokeTest/OcctFeatureOperationsTest’s OCCT-dependent cases
then skip cleanly instead of failing. `-Pkstep.occt.require=true turns
that into a hard test failure instead — pass it only on a machine where
OCCT is expected to actually be present. This wave supports linux-x86-64
only; see the ADRs' Folge-Wellen for the planned macOS/Windows follow-up.
BRepFilletAPI_MakeFillet (Geometrie Welle 5a) lives in TKFillet, from
the same libocct-modeling-algorithms-dev package as TKPrim/TKTopAlgo
above — no additional apt-get package is required beyond the four already
listed. BRepMesh_IncrementalMesh (Viewer-Welle 1, kstep-viewer’s
triangulation) lives in `TKMesh, from the same
libocct-modeling-algorithms-dev package too — still no additional
apt-get package required.
kstep-render (headless-preview-rendering wave — see
docs/adr/ADR-0011-headless-preview-rendering.adoc) depends only on
kstep-geometry — no Compose, no google() repository entry, no extra
setup beyond the optional OCCT bridge above (and kstep render still
renders a clean product-structure/fallback preview without it — see
Usage below). kstep-cli’s `render subcommand depends on kstep-render
directly, deliberately NOT on kstep-viewer — verify that stays true
after any dependency change with
./gradlew :kstep-cli:dependencies --configuration runtimeClasspath |
grep -iE "skiko|compose|androidx" (must be empty).
kstep-preview (the shared pipeline kstep render/kstep asciidoc both
call) and kstep-docs:kstep-asciidoc (the scanner/rewriter/processor) need
no extra setup beyond kstep-render’s own — no Compose, no OCCT-specific
build step. `:kstep-docs itself carries no build.gradle.kts; it is a pure
Gradle namespace directory grouping :kstep-docs:kstep-asciidoc, mirroring
kuml-dev/kUML’s own `kuml-docs/ layout. See
docs/adr/ADR-0019-kstep-asciidoc.adoc.
kstep render -f glb’s output (`dev.kstep.render.gltf.GlbWriter, see
docs/adr/ADR-0016-gltf-glb-export.adoc) needs no extra setup to BUILD or
TEST at the byte level — GlbWriterTest (kstep-render) covers the
writer’s JSON/binary layout with no OCCT and no Node.js at all, and is
part of the normal ./gradlew clean check. Cross-checking a real .glb
against the official Khronos gltf-validator is a genuinely optional,
manual step, kept OUT of the Gradle build entirely (this repository has
no established pattern of build-time network access — see that ADR’s
Security section):
cd scripts && npm ci
node validate-gltf.mjs ../path/to/your.glb
# or, cross-checking against a known triangle/vertex count:
node validate-gltf.mjs ../path/to/your.glb --expect-triangles 12 --expect-vertices 36Exits 0 only if the validator reports zero errors AND zero warnings (and,
when given, the --expect-* counts match exactly). GltfValidationExportTest
(kstep-tests, OCCT-gated) writes real .glb fixtures unconditionally
under kstep-tests/build/gltf-validation/, and additionally RUNS this
same validator itself, as a real Node subprocess, when both OCCT is
available AND -Pkstep.gltf.validate=true is passed — that flag
HARD-FAILS (never silently skips) if node/scripts/node_modules are
missing, mirroring -Pkstep.occt.require/`-Pkstep.planegcs.require’s own
"a gate that can silently no-op is not a gate" rule:
cd scripts && npm ci && cd ..
./gradlew clean check -Pkstep.occt.require=true -Pkstep.gltf.validate=truekstep-viewer (Viewer-Welle 1 — see
docs/adr/ADR-0010-occt-triangulation-and-viewer.adoc) depends on both the
optional OCCT bridge above and Compose Multiplatform. Resolving Compose’s
dependencies requires a google() Maven repository entry (scoped to
androidx.* coordinates only — see settings.gradle.kts and that ADR’s
Context section for why mavenCentral() alone fails), already wired into
this repository; no extra setup is needed beyond a normal
./gradlew clean check. Without the OCCT dev packages installed, opening
the real viewer window shows an explanatory UnavailableNotice (with the
exact apt-get install command above) instead of a blank canvas or a
crash.
./gradlew :kstep-viewer:run opens a real, on-screen window showing a
static isometric box — never wired into check, and requires an actual
display (or Xvfb) to run; every test in this module stays headless
(java.awt.headless=true).
kstep-constraints (Geometrie Welle 6, pulled forward, plus C-Welle 2 — see docs/adr/ADR-0006-planegcs-constraint-bridge.adoc and
docs/adr/ADR-0007-planegcs-additional-constraint-types.adoc) compiles a
vendored copy
of the PlaneGCS 2D geometric constraint solver (unlike kstep-geometry’s
OCCT, which links a system-installed package — PlaneGCS ships no
distribution package at all, so its sources are vendored directly into
this repository at a pinned commit; see NOTICE and
`kstep-constraints/src/main/cpp/third_party/planegcs/PROVENANCE.adoc).
This is entirely optional: ./gradlew clean check stays green with or
without the required dev headers installed. To get real end-to-end
constraint-solving coverage, install the Eigen and Boost dev packages
first — on Ubuntu/Debian:
sudo apt-get install --no-install-recommends libeigen3-dev libboost-dev
./gradlew clean check -Pkstep.planegcs.require=trueWithout those packages installed, kstep-constraints’s
`compilePlaneGcsBridge Gradle task is skipped (not failed), and
dev.kstep.constraints.PlaneGcsSolver.availability() reports
PlaneGcsAvailability.Unavailable at runtime with a human-readable reason;
PlaneGcsBridgeSmokeTest’s PlaneGCS-dependent cases then skip cleanly
instead of failing. `-Pkstep.planegcs.require=true turns that into a hard
test failure instead — pass it only on a machine where the bridge is
expected to actually be present. This wave supports linux-x86-64 only,
same as kstep-geometry; see the ADR’s Folge-Wellen for the planned
macOS/Windows follow-up. Unlike the OCCT bridge, no -L/-l/-rpath
linker flags are needed at all — Eigen and Boost are both used here in a
strictly header-only capacity, so nothing links against an external shared
library.
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.applicationContext
import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productContext
import dev.kstep.core.getOrThrow
// product.frame_of_reference is a mandatory SET [1:?] OF product_context in the real
// AP242 schema (M2 Welle 10 — see docs/adr/ADR-0004-core-on-generated-types.adoc);
// kstep-core does not invent a placeholder context behind the caller's back.
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
name = "engineering"
frameOfReference = appCtx
disciplineType = "mechanical"
}.getOrThrow()
val result = product(id = "BRK-001") {
name = "Bracket"
description = "Mounting bracket"
frameOfReference = setOf(prodCtx)
}
when (result) {
is ValidationResult.Valid -> println(result.value)
is ValidationResult.Invalid -> println(result.violations)
}
// An empty id violates product's WHERE rule (kstep_wr1: SELF.id <> ''):
val invalid = product(id = "") { name = "Bracket"; frameOfReference = setOf(prodCtx) }
println((invalid as ValidationResult.Invalid).violations)
// [DslViolation(code=KSTEP-W-001, entityName=product, ruleLabel=kstep_wr1,
// expressionText=SELF.id <> '', message=WHERE rule kstep_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.applicationContext
import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productContext
import dev.kstep.core.ap242.productDefinition
import dev.kstep.core.ap242.productDefinitionContext
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 appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()
val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val builtFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") {
formation = builtFormation
frameOfReference = defCtx
}.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=APPLICATION_CONTEXT('config control');
// #2=PRODUCT_CONTEXT('engineering',#1,'mechanical');
// #3=PRODUCT('BRK-001','Bracket',$,(#2));
// #4=PRODUCT_DEFINITION_FORMATION('BRK-001-F',$,#3);
// #5=PRODUCT_DEFINITION_CONTEXT('engineering',#1,'design');
// #6=PRODUCT_DEFINITION('BRK-001-D',$,#4,#5);
// 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 kstep_wr1 WHERE rule):
val handEdited = """
ISO-10303-21;
HEADER;
FILE_DESCRIPTION((),'2;1');
FILE_NAME('n','t',(),(),'','','');
FILE_SCHEMA(('S'));
ENDSEC;
DATA;
#1=APPLICATION_CONTEXT('cc');
#2=PRODUCT_CONTEXT('eng',#1,'mech');
#3=PRODUCT('','x','',(#2));
#4=PRODUCT_DEFINITION_FORMATION('PDF-001','',#3);
ENDSEC;
END-ISO-10303-21;
""".trimIndent()
val badResult = Part21Reader.read(handEdited)
println(badResult.violations) // {3=[DslViolation(code=KSTEP-W-001, ...)]}
println(badResult.skipped) // {4=[3]}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 fifteen 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)
// product.frame_of_reference is mandatory (M2 Welle 10) — build a context first.
client.callTool("build_application_context", mapOf("handle" to "AC", "application" to "cc"))
client.callTool(
"build_product_context",
mapOf("handle" to "PC", "name" to "eng", "frame_of_reference_handle" to "AC", "discipline_type" to "mech"),
)
val built = client.callTool(
"build_product",
mapOf("id" to "BRK-001", "name" to "Bracket", "frame_of_reference_handles" to listOf("PC")),
)
println(built.structuredContent)
// {"id":"BRK-001","name":"Bracket","description":null,"frame_of_reference_handles":["PC"],"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 "", "frame_of_reference_handles" to listOf("PC")),
)
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 twelve kstep-core
builders (M2 Welle 10), the generated dev.kstep.generated.ap242v1.
types, and stepFile/root are all available without any import
(KStepScriptCompilationConfiguration’s `defaultImports):
// bracket.kstep.kts
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()
val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") {
formation = bracketFormation
frameOfReference = defCtx
}.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 kstep_wr1 WHERE rule
// (frame_of_reference is set deliberately, so this is the only violation in the response)
val prodCtx = productContext {
name = "engineering"
frameOfReference = applicationContext { application = "config control" }.getOrThrow()
disciplineType = "mechanical"
}.getOrThrow()
stepFile(fileName = "hello-invalid.step") {
root(product(id = "") { name = "Nameless"; frameOfReference = setOf(prodCtx) })
}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.
Rendering a headless SVG/PNG/text/glTF preview of a *.kstep.kts script
via kstep render (see
docs/adr/ADR-0011-headless-preview-rendering.adoc and, for -f glb,
docs/adr/ADR-0016-gltf-glb-export.adoc) — no window, no display server.
shape(…) (also available with no import, alongside root(…))
registers geometry for the preview independently of root(…):
// hello-box.kstep.kts
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext {
name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical"
}.getOrThrow()
val defCtx = productDefinitionContext {
name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design"
}.getOrThrow()
val part = product("BOX-001") { name = "Box"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val prodFormation = productDefinitionFormation("BOX-001-F") { ofProduct = part }.getOrThrow()
val definition = productDefinition("BOX-001-D") {
formation = prodFormation
frameOfReference = defCtx
}.getOrThrow()
// OcctKernel/shape(...) need no import -- see defaultImports above.
val box = OcctKernel.makeBox(10.0, 20.0, 30.0)
stepFile(fileName = "hello-box.step") {
root(definition)
shape(definition, box)
}kstep render hello-box.kstep.kts
# Wrote hello-box.svg -- an isometric render, since OCCT is available and the box triangulated
kstep render hello-box.kstep.kts --format png -w 1200 --height 900 -o box.png
kstep render hello-box.kstep.kts --format text --with-step
kstep render hello-box.kstep.kts -f glb -o box.glb
# Wrote box.glb -- a self-contained binary glTF 2.0 document (JSON + binary buffer in one
# file); -w/--height are ignored for this format. "-f gltf" is accepted as an alias for
# "glb" -- both write the same binary container, see ADR-0016 for why a separate plain-text
# glTF format is not offered. Open it in any glTF viewer (e.g. https://gltf-viewer.donmccurdy.com/,
# or a local `<model-viewer>` page) to inspect the model interactively.On a machine without the native OCCT bridge (or if triangulation fails
for any other reason), the SAME hello-box.kstep.kts script above still
renders successfully — content falls back to a text card explaining
why, painted into whatever container was requested (never a silently
different file type), with the fixed OCCT-dev-packages apt-get command
from Building below included in the card. Pass --require-geometry to
turn that silent fallback into a hard failure (exit 1) instead — e.g. for
a CI step that must guarantee a real render happened. A script without
any shape(…) call at all (like bracket.kstep.kts above) renders a
product-structure summary card the same way, content = summary, exit 0
— kstep render never requires geometry to succeed.
(kstep-asciidoc wave, see docs/adr/ADR-0019-kstep-asciidoc.adoc)
Pre-rendering kstep preview blocks in an AsciiDoc document — the
DSL-file-per-block-macro equivalent of kuml-dev/kUML’s `kuml-asciidoc
pre-processor. Three interchangeable block forms; a document can mix all
three:
// bracket-doc.adoc
A markdown fence, no attributes:
```kstep
val appCtx = applicationContext { application = "config control" }.getOrThrow()
val prodCtx = productContext { name = "engineering"; frameOfReference = appCtx; disciplineType = "mechanical" }.getOrThrow()
val defCtx = productDefinitionContext { name = "engineering"; frameOfReference = appCtx; lifeCycleStage = "design" }.getOrThrow()
val bracket = product("BRK-001") { name = "Bracket"; frameOfReference = setOf(prodCtx) }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = bracketFormation; frameOfReference = defCtx }.getOrThrow()
stepFile(fileName = "bracket.step") { root(definition) }
```
An AsciiDoc-native delimited block, with attributes:
[kstep,bracket,format=png,width=800,height=600,alt="Bracket assembly"]
----
... same script as above ...
----
A block macro pointing at an existing script file:
kstep::bracket.kstep.kts[format=svg]kstep asciidoc --input bracket-doc.adoc --output bracket-doc.rendered.adoc
# Wrote bracket-doc.rendered.adoc (3 block(s))
# -> bracket-doc.rendered.adoc now has three `image::...[]` lines, and the
# corresponding bracket-doc-1.svg/bracket.png/bracket-doc-3.svg files
# (named from the INPUT document's basename, and from the second
# block's explicit `target=bracket`) sit next to it (or under
# `:imagesdir:`, if the document sets one).
kstep asciidoc --input-dir docs/src --output-dir docs/build --format png
# Mirrors the whole tree: every .adoc is rewritten, every other file
# (images, includes, ...) is copied unchanged. Symlinks are skipped and
# reported, never followed.Only svg/png are accepted as a block’s image format (auto/text/glb
are rejected outright — an embeddable image is the only sensible container
here); --format sets the document-wide default, overridable per block via
format=. The same Container-Regel/Pflicht-Fallback kstep render follows
applies here too (via the shared kstep-preview module) — a script whose
geometry cannot be rendered this run becomes a notice card in the requested
image format, exit 0, unless --require-geometry is given. A script that
fails to compile/validate/run is a different failure class entirely: by
default it aborts the WHOLE document (--on-error fail, matching a
compiler’s own instinct to stop on a syntax error) — pass --on-error card
to instead embed an error card for that one block and keep going, useful
while iterating on a document’s scripts. Under the default fail policy, a
document with one good block and one broken block writes NEITHER the
rewritten .adoc NOR any of its images — never a half-written result.
Any OTHER AsciiDoc delimited block (----/…./====/**/__/
/////) not opened by a recognized [kstep]/[source,kstep]
attribute line passes through untouched — including one that happens to
contain kstep::path[]-looking text, e.g. this project’s own docs showing
the syntax (see docs/adr/ADR-0019-kstep-asciidoc.adoc’s Stolperfalle 3).
A two-line ("setext") section title underline (`Title\n-----------\n) is
never mistaken for such a delimiter opener, even though a ----------- run
alone is indistinguishable from one — the scanner checks the line above it
first, matching Asciidoctor’s own rule (Stolperfalle 12 in the ADR).
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 twelve core AP242 entities (six V1 entities —
PRODUCT,PRODUCT_DEFINITION+ revision,PRODUCT_DEFINITION_FORMATION,NEXT_ASSEMBLY_USAGE_OCCURRENCE,APPROVAL,PERSON_AND_ORGANIZATION— plus six support entities their generated shapes require:APPLICATION_CONTEXT,PRODUCT_CONTEXT,PRODUCT_DEFINITION_CONTEXT,APPROVAL_STATUS,PERSON,ORGANIZATION) now generates correctly from the fixture and from the real AP242 schema (see Status above), and (M2 Welle 10)kstep-corenow builds its runtime construction/ validation API — one validating builder function per entity indev.kstep.core.ap242— directly on top of those generated types, rather than hand-authoring independent equivalents of them. This resolves the fork M2 Welle 9 left open (extend the hand-authored layer withLIST/EXISTS()support, vs. rebuild it on the generated types): eleven of the thirteen M2 Welle 8 divergence-table entries (below, kept for historical reference) are resolved outright — the codegen-generated shape is the real shape, by construction — and the remaining two (approval.status/person_and_organization. the_person+the_organizationsimplified toString, and the inventedapproval.authorized_by) are resolved the same way: those attributes are now correctly entity-typed, andauthorized_by— never part of the real entity — is removed, not replaced. Full rationale, consequences (a real ergonomic regression onproduct/productDefinition, which now need an explicit context built first; Part-21/MCP breaking changes; the newKSTEP-A-001violation code;EXISTS()WHERE-rule support), and the alternatives considered are indocs/adr/ADR-0004-core-on-generated-types.adoc.The M2 Welle 8 divergence inventory, for historical reference — every row below is now resolved (see ADR-0004), not an open gap:
Entity Divergence (as of M2 Welle 8) M2 Welle 10 resolution approvalstatusis entity-typed (approval_status) in the real schema;Stringinkstep-coreresolved —
statusis nowdev.kstep.generated.ap242v1.ApprovalStatusapprovalauthorized_bydoes not exist on the real entity at all — a kSTEP-invented convenience linkageresolved — removed entirely, no replacement
approvalreal entity has no WHERE rule;
kstep-core’s `wr1is synthesizedunchanged — kept, relabeled
kstep_wr1(never a real rule to align to)person_and_organizationthe_person/the_organizationare entity-typed (person/organization) in the real schema;Stringinkstep-coreresolved — both now correctly entity-typed and mandatory
person_and_organizationreal
WR1/WR2useSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule was synthesizedresolved differently — dropped entirely, not relabeled: both attributes are now mandatory entity references, so "at least one set" is no longer a meaningful approximation
productreal entity has a mandatory
frame_of_reference : SET [1:?] OF product_context;kstep-coreomitted itresolved — mandatory and non-empty, enforced via
KSTEP-M-001/KSTEP-A-001productreal
WR1usesSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule is synthesizedunchanged — kept, relabeled
kstep_wr1product_definitionreal entity has a mandatory
frame_of_reference : product_definition_context;kstep-coreomitted itresolved — mandatory, enforced via
KSTEP-M-001product_definitionreal
WR1usesSIZEOF(USEDIN(…))(unsupported); `kstep-core’s rule is synthesizedunchanged — kept, relabeled
kstep_wr1product,product_definition,product_definition_formationreal
OPTIONAL text description;kstep-coremodeled it as non-nullStringdefaulting to""resolved — genuinely nullable
String?now, matching the generated typeproduct_definition_formationreal
UNIQUE UR1: id, of_productnot enforced anywhere inkstep-coreunchanged — still resolved by construction (
EntityStoreid-keying), seeKStepMcpServerTestnext_assembly_usage_occurrencereal flattened shape inherits an
OPTIONAL text description(product_definition_relationship);kstep-coreomitted itresolved —
descriptionis now a genuineString?next_assembly_usage_occurrencereal inherited
reference_designator(assembly_component_usage) isOPTIONAL identifier;kstep-corenarrowed it to non-nullStringdefaulting to""resolved — genuinely
String?now; the over-constraining synthesized WHERE rule that used to force it non-blank is removed, not relabelednext_assembly_usage_occurrencereal
WR1isacyclic_product_definition_relationship(…)(unsupported); `kstep-core’s rule is synthesizedunchanged — no synthesized rule remains for this entity (the old one was the now-removed reference_designator over-constraint above)
Deliberately still open (named limitations, not silently dropped — see ADR-0004 and Status for
KSTEP-M-002’s scope): the `SIZEOF/USEDIN-based real WHERE rules onproduct,product_definition,person_and_organization, andapplication_contextremain unsupported and unevaluated; NAUO’s realWR1(acyclic_product_definition_relationship) likewise;DERIVEattributes (product_definition.name,person_and_organization.name/description,application_context.id/description, NAUO’s derivedproduct_definition_occurrence_id) are still not codegen’d or evaluated — the generated types simply don’t carry them; and NAUO’sUNIQUE UR2remains unenforced (depends onproduct_definition_occurrence, an entity not modeled anywhere inkstep-core). -
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— fifteen tools over stdio wrapping the twelve AP242 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. -
Geometry via an OpenCascade (OCCT) bridge. (Geometrie Welle 1, started)
kstep-geometrynow exists: box construction, real B-Rep topology/volume readback, and AP242/AP203/AP214IS STEP export, all through a hand-written JNI shim against a system-installed OCCT — see thekstep-geometryrow above anddocs/adr/ADR-0005-occt-jni-bridge.adocfor the full binding-choice rationale, license findings, and the named follow-up waves (further primitives and boolean ops, STEP import, a.kstep.ktsgeometry DSL, merging OCCT shape data with kSTEP’s own AP242 product-structure Part 21 output, a feature/parametric-history model, multi-platform support, a desktop viewer). (Geometrie Welle 5a, done)OcctKernelnow also offersextrudeProfile(…)(2D-profile-to-solid extrusion) andfillet(…)(edge rounding), both composable withmakeBoxand with each other — see thekstep-geometryrow above anddocs/adr/ADR-0008-occt-feature-operations.adocfor the DoS-guard measurements and the parametric-history half (Geometrie Welle 5b) this wave deliberately leaves open. (Geometrie Welle 5b, Teil 1, done) A newdev.kstep.geometry.featuresubpackage (Feature/FeatureSequence/FeatureRebuilder.rebuild(…)) replays a value-only sequence ofOcctKernelcalls, proving the "change one parameter, rebuild, get a new measured result" claim on top ofOcctKernelexactly as it already is — see thekstep-geometryrow above anddocs/adr/ADR-0015-parametric-feature-history-foundation.adocfor the scope boundary (still no stable geometric edge identity, undo/redo, feature-tree UI, or serialization — all remain open follow-ups). (Geometrie Welle 6, pulled forward, done)kstep-constraintsnow exists: a PlaneGCS 2D geometric constraint-solver bridge, initially solving point systems under distance constraints — see thekstep-constraintsrow above anddocs/adr/ADR-0006-planegcs-constraint-bridge.adocfor the full license analysis and the named follow-up waves. (C-Welle 2, partial, done) Coincidence, horizontal/vertical, and point-on-line constraints added on the same points-only API — seedocs/adr/ADR-0007-planegcs-additional-constraint-types.adocfor what shipped and that wave’s reduced-scope DoS measurement, continued as C-Welle 2b. (C-Welle 2c, done) Parallel and perpendicular constraints added on the same points-only API — see thekstep-constraintsrow above anddocs/adr/ADR-0014-planegcs-parallel-and-perpendicular.adoc. Still remaining:L2LAngle/P2PAngle(angle-between-line/point-pairs) andCoordinateX/CoordinateY, deferred to C-Welle 3 alongside sketch entities. Sketch entities, an incremental/interactive solver, PlaneGCS’s own diagnosis API, akstep-sketchmodule, AP242 mapping, multi-platform support, and a WASM path remain later items. Additional STEP Application Protocols beyond AP242 remain a separate, not-yet-started later item. (Geometrie Welle 4, done) A newkstep-shapemodule now merges a validatedkstep-coreAP242 product structure with an OCCT B-Rep solid into one ISO 10303-21 file (Ap242ShapeExporter.export), discarding the placeholder product structure OCCT’s own writer emits and replacing it with kSTEP’s own — see thekstep-shaperow below anddocs/adr/ADR-0009-ap242-shape-assignment.adocfor the full design rationale and the named follow-up waves (assembly geometry, a DSL/CLI/MCP surface, codegen-typed bridge entities, STEP import, raised DoS limits, non-ASCII escapes). This wave also grewkstep-step21itself: the full ISO 10303-21 value grammar (integers/reals, enumerations, the*DERIVE-token, typed parameters, complex instances) and a newPart21ReadMode.TOLERANTthat keeps unrecognized entities as opaque instances instead of throwing — without which OCCT’s own STEP output could not be read back throughkstep-step21at all.Part21ReadMode.STRICTremains the default and is behaviorally unchanged from before this wave. (Viewer-Welle 1, done) The "a desktop viewer" follow-up ADR-0005 named above is also done:kstep-vieweropens a real Compose Desktop window showing a triangulated OCCT shape — see thekstep-viewerrow above anddocs/adr/ADR-0010-occt-triangulation-and-viewer.adoc. (viewer-camera- interaction wave, done) The initial static isometric snapshot now supports drag-to-orbit, scroll-to-zoom, and a reset — seedocs/adr/ADR-0012-viewer-camera-interaction.adoc. (multi-shape- composition-and-fill-light wave, done) The demo scene composes several placed shapes into one mesh under a second, additive fill light — seedocs/adr/ADR-0013-multi-shape-composition-and-fill-light.adoc. (headless-preview-rendering wave, done) The same projection/rasterizer code also renders headlessly (no window, no display server) viakstep renderin SVG/PNG/text — seedocs/adr/ADR-0011-headless-preview- rendering.adoc. (gltf-glb-export wave, done)kstep render -f glbadditionally exports a self-contained, Khronos-validator-clean binary glTF 2.0 document — seedocs/adr/ADR-0016-gltf-glb-export.adoc. Still open: undo/redo, a full sketch DSL beyond the points-only constraint API above, stable geometric edge identity, and multi-platform distribution of the viewer/CLI beyond linux-x86-64.
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.
kstep-geometry’s native JNI shim
(`kstep-geometry/src/main/cpp/kstep_occt_bridge.cpp) is compiled, at
build time only, against a system-installed
Open CASCADE Technology (OCCT) library
(Ubuntu package libocct-*-7.9), licensed under LGPL-2.1-only with the
Open CASCADE Exception 1.0 — see
https://dev.opencascade.org/resources/licensing. No OCCT binary, header,
or source is vendored in this repository, and this product does not
currently bundle or redistribute any OCCT binary; see NOTICE
and docs/adr/ADR-0005-occt-jni-bridge.adoc for the full license and
provenance discussion.
