Conversation
jimador
added this pull request to stack #2067
September 27, 2026 14:03
jimador
marked this pull request as ready for review
September 27, 2026 14:03
jimador
force-pushed
the
feature/decision-spec-contracts
branch
from
September 27, 2026 17:19
8601264 to
0bde2aa
Compare
jimador
removed this pull request from stack #2067
September 27, 2026 22:10
jimador
added this pull request to stack #2081
September 27, 2026 22:11
jimador
force-pushed
the
feature/decision-spec-contracts
branch
from
September 28, 2026 01:29
f17ce5d to
b46a83d
Compare
Question ids are d1- plus the unpadded base64url SHA-256 of the compact UTF-8 JSON array [kind, name, instructions, [[id, description], ...]]. Spec ids are s1- plus the same digest of the ordered question id array. The generator settings are pinned to today's Jackson 3 defaults, and golden ids computed outside the JVM guard the escaping rules. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add RatingLevel, RatingStatistic, RatingScore, LevelProbability and the sealed RatingResult (Answered/Inconclusive/Failure) so a rating question's provider evidence can be represented without inventing a selected level or a distribution the provider never reported. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…e test Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
StrictObjectReader reads one JSON object member by member from the parser's tokens and reports duplicate, unknown and missing members and wrong value types through DeserializationContext.reportInputMismatch. Duplicates are caught whatever the mapper's parser settings are, because the reader never builds a tree. It offers typed reads for strings, booleans, finite doubles, ints, nested objects, arrays and delegated values, plus a map-style reader that allows any member name but still rejects repeats. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Adds the closed Question<R> hierarchy: proposition, choice and rating question specs, each immutable, compared by definition, and carrying a d1- definition id. Questions.named(...) starts a reusable declaration, and each kind's builder offers only that kind's operations. Builders and specs cannot be constructed from Java; DecisionSpec will create builders through hidden internal factories. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
DecisionSpec holds an ordered, unmodifiable list of questions with unique names and an s1- definition id computed from the question ids. Java and Kotlin callers get DecisionSpec.builder() with proposition, choice and rating methods that take a Consumer of the kind's builder, plus question(...) and the of(...) factories. Every path goes through the same private constructor, so validation is shared. Each declaring call adds exactly one question or leaves the builder as it was. A blank or repeated name is rejected before the customizer runs, the customizer's exceptions pass through unchanged, and the question is appended only after the kind builder's build() succeeds. A customizer that tries to add to the builder running it gets an IllegalStateException, which keeps nested declarations from slipping in ahead of their parent. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
decisionSpec { } declares questions on DecisionSpecScope and delegates
each call to the matching DecisionSpec.Builder Consumer method, so a
spec built with the DSL validates and equals one built with the
builder or with Questions.named(...).
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…iage spec id Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Adds DecisionRequest (input paired with a spec), DecisionOptions with ExecutionMode (NATIVE, SINGLE_QUESTION, SEQUENTIAL), and DecisionCapabilities describing what a service accepts and supports. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Options and capabilities now copy their sets into an EnumSet behind an unmodifiable view, so kinds and modes iterate in declaration order and toString is the same on every run. Set.copyOf gave a per-JVM order. DecisionCapabilities drops its public constructor for of(kinds, modes) plus withMaxQuestions and withMaxInputCharacters, which return new instances and validate the limit. DecisionOptions gains of(first, vararg rest) for Java callers. The SINGLE_QUESTION KDoc now limits the mode to proposition and choice questions, and the null-limit KDoc and a test name are reworded. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Adds DecisionJacksonModule, registered explicitly or through ServiceLoader, with hand-written serializers and deserializers for the question classes, DecisionSpec, DecisionRequest, DecisionOptions and DecisionCapabilities. Readers go through StrictObjectReader and the public builders, so duplicate and unknown members, unknown kinds and modes, and every builder invariant are rejected whatever the mapper's own settings are. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
DecisionResponse holds one DecisionAnswer per spec question in spec order. Answers carry the question's name, kind, definition id, public options or levels and a typed outcome, so a reader needs no question objects. Typed lookup checks name, kind and definition id; name lookup returns the sealed answer for Kotlin when and Java pattern matching. Internal factories rebuild a response without its spec and reject a dropped, extra or reordered answer through the spec id. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Replace the generic Builder.answer with one overload per question class, so a mismatched outcome no longer compiles. Move the option, level and outcome checks into OutcomeRules, shared by the question validators and the response answers. A request failure now requires every outcome to be a failure with that same reason. Standalone answers and responses check their ids with DefinitionIds.isQuestionId and isSpecId, and the response toString includes its spec id. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…tcomes Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add DecisionProjection, reducing a response's answers to a plain value per question (proposition Boolean, choice category id, rating selected level id) and converting that map to a caller type or Map<String, Object> via Jackson. Answers with no representable value are rejected by name through DecisionProjectionException. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rter example Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Add ResponseJson with hand-written bindings for DecisionResponse, DecisionAnswer and its three classes, and RatingResult and its three classes, registered by DecisionJacksonModule beside the spec bindings. Proposition and classification outcomes and model provenance are written and read by private helpers inside the answer bindings, so the module adds no binding for any existing type. Readers go through StrictObjectReader and build through the internal response factories, whose spec id check rejects a dropped, extra or reordered answer. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Compiles positive and negative Java/Kotlin fixtures in-process, against this test's own classpath, with javac and the embedded K2JVMCompiler. Positive fixtures are asserted first so a broken classpath never makes a negative fixture look like it passed for the wrong reason. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ct diagnostics Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ings both use Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…efault mapper EmbabelObjectMapperHolder.createDefault() now builds its mapper with the Kotlin module and DecisionJacksonModule, so decision specs and responses round-trip through the platform mapper the same way they do through Spring Boot's auto-configured one. A test dependency on spring-boot-jackson lets a context test prove the auto-configured mapper needs no application code. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Covers the builder, named questions, the Kotlin DSL, question kinds, rating evidence, definition ids and their canonical recipe, requests and options, the JSON format, Jackson registration, strict parsing, and projection. Snippets come from the tagged test regions. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… array The typed answer lookup now compares a choice answer's options, or a rating answer's levels, with the question's. A response cannot prove them from its ids, so a response read from JSON could carry renamed options or levels behind an unchanged definition id and still pass the lookup. A response now writes answers as a JSON array in spec order. Each element carries its name first, the same shape an answer has on its own. The reader rejects the object shape, an element with no name, and a repeated name. Array order does not depend on how a store or library orders object keys. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ls check The response JSON example and member table now show answers as an array in spec order, each with its name first. The typed lookup section says the lookup also compares options and levels. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ger use Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rs hold their options as written Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…iles Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…on the types The decision types carry standard Jackson annotations, so any Jackson 3 mapper reads and writes them with no module or registration. The wire format matches the golden files byte for byte. Each type has a private any-setter that rejects an unknown member. It applies when the mapper disables FAIL_ON_UNKNOWN_PROPERTIES. Readers build values through the existing builders and constructors, so a response read from JSON checks its answers' definition ids against its spec id. Classification and proposition results, model provenance and categories keep their own JSON. Decision JSON writes and reads them through small internal forms in ResultJson.kt. Decision JSON has no Jackson module, ServiceLoader entry or token-level reader. EmbabelObjectMapperHolder builds a plain Kotlin mapper. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The reference page shows a plain mapper round trip and lists the read rules: unknown members fail whatever the mapper settings are, definition ids are checked on read, and a repeated member follows Jackson's default, where the last value wins. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…s on read Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Decision specs carry no execution mode. ExecutionMode and DecisionOptions are deleted. DecisionCapabilities holds the supported question kinds and the optional limits, and DecisionCapabilities.of takes the question kinds. DecisionResponse holds its spec id, an optional request failure and its answers. DecisionResponse.builder takes the spec and DecisionResponse.failed takes the spec and the failure reason. The golden JSON files are regenerated. The only byte change is the removed executionMode member in response.json and failed-response.json and the removed executionModes member in capabilities.json. options.json is deleted with DecisionOptions. The decision specs reference page drops the execution mode and options sections and table rows. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… against specs by value Questions, specs, answers and responses no longer carry d1-/s1- hashed ids. The ids were a wire contract to maintain across deployments, and value equality of specs and questions already identifies them. - Remove DefinitionIds and the definitionId member from Question, DecisionSpec, DecisionAnswer and DecisionResponse, including JSON. - The typed answer(question) lookup compares name, kind and options or levels, and names what differs. Reworded instructions are not detected. - Add DecisionResponse.requireMatches(spec), which fails on missing, extra or reordered answers and on answers that do not fit their question. Reading JSON validates each answer on its own. - The response builder compares questions by equality. - Update tests, golden JSON and the decision-specs reference page. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
ClassificationSpec is a DecisionSpec with one choice question, built with
asking(...) and category(...) or the classificationSpec { } DSL. It equals
a DecisionSpec holding the same question and writes the same JSON.
ClassificationRequest becomes a DecisionRequest over a ClassificationSpec,
created with ClassificationRequest.of(input, spec).
selected(...) and validate(...) move to the spec. ClassificationService
gains classify(input, spec). CategoryMapping takes the instructions and
exposes spec(). The LLM and TypeSafe providers now send the spec's
instructions to the model.
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… its own The classification page shows the ClassificationSpec builder and DSL, a 'Use it on its own' section that classifies with classify(input, spec), and notes that a classification spec is a one-question decision spec. The decision specs page links to it. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Covers the private functions in ObservedClassificationService and TypeSafeDecisionService, which this change touches. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
jimador
force-pushed
the
feature/decision-spec-contracts
branch
from
September 28, 2026 05:19
565259d to
d833603
Compare
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



This PR adds typed decision specs. A spec holds one or more named questions, and a
DecisionRequestpairs it with the input they are asked about. A spec is built in Java or Kotlin, validated when it is built, written and read with Jackson, and its responses are read back as typed results.The PR covers the spec, the request and the results. #2080 runs a spec against a decision service, and #2076 selects and registers services.
Building a spec
Java uses nested customizers. Each callback gets a builder for its question kind, so only a choice takes options and only a rating takes levels.
Named questions are reusable handles for typed results:
Kotlin gets a receiver DSL over the same builders. It builds a spec equal to the Java one.
A spec is an immutable value, so it can be built once and shared:
Reading a response
Typed lookup returns the result type for the question's kind. It checks the answer's name, kind, and options or levels against the question, so a changed choice or rating question, or an edited response, is caught. Answers carry no instructions, so a reworded question is not detected:
A reader that only has a name uses the sealed answer types:
Jackson
Any Jackson 3 mapper reads and writes the decision types through annotations on the types, with no module to register. The result types,
ClassificationResult,PropositionResult,RatingResult,FailureReasonandModelProvenance, carry the same annotations, so they write the same JSON on their own as inside a response. Spec JSON carries the instructions. Response JSON lists the answers as an array in spec order, so a store that reorders object keys can't break it. Each answer carries its name, kind, option or level ids and descriptions, and outcome, and leaves out the input and the instructions. Trimmed to one answer:{ "answers": [ { "name": "department", "kind": "choice", "options": [{ "id": "billing", "description": "Payments, invoicing, refunds" }, ...], "outcome": { "status": "selected", "categoryId": "billing", "provenance": { "modelName": "jev-latest", "provider": "typesafe" } } } ] }A request-level failure from
DecisionResponse.failed(spec, reason)adds"requestFailure"and gives every answer a failure outcome with that reason.An unknown member fails the read on every mapper, including one with
FAIL_ON_UNKNOWN_PROPERTIESturned off, as Spring Boot's is. Reading a spec or question runs the same validation as the builders, so unknown kinds, repeated question names and invalid scales fail. A response is checked answer by answer when it is read.response.requireMatches(spec)checks it against a spec: answers that are missing, extra, reordered, of the wrong kind, or with changed options or levels fail with a message naming each one. Specs and questions compare by value, so a response written on one node reads the same on another. The golden files undersrc/test/resources/decision/goldenpin the spec, request, response, failed response and capabilities JSON.Application types
A Spring
Converterhandles each outcome itself:DecisionProjectionmaps answered values onto a record or a generic type. It refuses any answer that is inconclusive, failed, no-match, or a rating with no selected level, and a record component with no answer. The exception lists the refused questions ingetQuestions().Classification is a decision
A classification is a decision with one choice question.
ClassificationSpecincom.embabel.common.ai.classificationextendsDecisionSpecand holds exactly oneChoiceQuestionSpec. The builder takesaskingandcategory, and names the questionclassification.ClassificationSpec.of(question)wraps a choice question you already have and keeps its name.getQuestion()returns it, andselected(...)andvalidate(result)check a selection against the spec's categories.Kotlin has
classificationSpec { }:ClassificationService.classify(input, spec)is a default method that callsclassify(ClassificationRequest.of(input, spec)):A decision service answers it too, since a classification spec is a decision spec and a
ClassificationRequestis aDecisionRequest.response.answer(spec.getQuestion())returns theClassificationResult.ClassificationRequestextendsDecisionRequestand is built withClassificationRequest.of(input, spec). The public constructor is gone. This changes the request shape Feature: implementation of classification and decision service contracts #2062 introduced.CategoryMappingtakes(instructions, values), andfromEnum(type, instructions, describe)takes the instructions too.spec()returns theClassificationSpecbuilt from them.Choicein place of a fixed sentence, and the prompted classifier adds aQuestion:line before the categories.ClassificationSpecfails.DecisionSpecandDecisionRequestare now open withinternalconstructors, so only the classification types extend them.equalsandhashCodeare final, so a classification spec equals aDecisionSpecholding the same question.Types
classDiagram class Question~R~ { <<sealed>> name instructions } Question <|.. PropositionQuestionSpec : PropositionResult Question <|.. ChoiceQuestionSpec : ClassificationResult Question <|.. RatingQuestionSpec : RatingResult DecisionSpec o-- Question DecisionRequest --> DecisionSpec DecisionSpec <|-- ClassificationSpec DecisionRequest <|-- ClassificationRequest DecisionResponse o-- DecisionAnswer DecisionAnswer <|.. Proposition DecisionAnswer <|.. Choice DecisionAnswer <|.. RatingRatingResultholds the evidence a provider reports: a selected level, a distribution over the levels, and a score with its statistic named. Each part is present only when the provider reports it.DecisionCapabilitiesdescribes what a service supports: the question kinds it answers, and the most questions and input characters it accepts, each null when unknown.DecisionResponse.builder(spec)has one typedanswer(...)overload per question kind. Passing the wrong outcome type is a compile error.DecisionSpecandDecisionRequest, the classification types,ClassificationService, the observed classification and decision services (which observeclassify(input, spec)too), and the Jev and prompted classifiers.reference.adocgains one include for the new page, and the classification page coversClassificationSpec. The pom adds two test-scoped dependencies: the Kotlin compiler for the compile checks and Spring Boot's Jackson support for the mapper test.The API is experimental. The reference page covers construction, identity, the wire format, reading JSON and projection.
Stacked on #2074, which sits on #2061 and #2062.