Skip to content

Prototype: read decision specs from annotated records - #2079

Draft
jimador wants to merge 24 commits into
feature/decision-ai-integrationfrom
prototype/annotated-decision-spec
Draft

jimador wants to merge 24 commits into
feature/decision-ai-integrationfrom
prototype/annotated-decision-spec

Conversation

@jimador

@jimador jimador commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Prototype. This PR adds an opt-in module, embabel-agent-decision-annotations, that reads a decision spec from an annotated record or class and projects a response back into it. It's here for evaluation. The module sets maven.deploy.skip; keeping it off Maven Central still needs confirming (condition 1).

Declaring a spec

A record with question annotations declares the same spec as the builder:

enum Department {
    @Described("Payments, invoicing, refunds")
    BILLING,
    @Described("Bugs, outages, integrations")
    TECHNICAL,
}

enum Severity {
    LOW,
    @Described("Work is blocked for one customer")
    HIGH,
    @Described("Work is blocked for many customers")
    CRITICAL,
}

record Triage(
    @PropositionQuestion(asking = "Does this ticket convey urgency?") boolean urgent,
    @ChoiceQuestion(asking = "Which team should handle this?") Department department,
    @RatingQuestion(asking = "How severe is the impact?") Severity severity) {
}
AnnotatedDecision<Triage> triage = AnnotatedDecisions.defaults().of(Triage.class);
DecisionSpec spec = triage.spec();   // equal to the builder's spec

DecisionResponse response = service.ask(ticket, spec);
Triage value = triage.project(response).getValue();

Enum constants become options or levels, and @Described sets their descriptions. Question names come from Jackson, so @JsonProperty and the mapper's naming strategy apply. AnnotatedDecisions.using(mapper) reads with an application's own mapper.

Projection checks the response against the type's spec with requireMatches, then refuses an answer that is inconclusive, failed, no-match, or a rating with no selected level, as DecisionProjection does. project(response) refuses a type with properties that aren't questions. project(response, otherProperties) fills those, such as an id, and returns T.

Classification

An enum annotated with @Classification reads as a CategoryMapping:

@Classification(asking = "Which team should handle this ticket?")
enum Department {
    @Described("Payments, invoicing, refunds")
    BILLING,
    @Described("Bugs, outages, integrations")
    TECHNICAL,
}

CategoryMapping<Department> departments = AnnotatedDecisions.classification(Department.class);
ClassificationResult result = service.classify(ticket, departments.spec());
MappedClassificationResult<Department> mapped = departments.map(result);

Category ids are the constant names. A constant without @Described is an error, as it is for a choice option. A decision type whose only question is a choice reads as a ClassificationSpec, even with other properties such as an id, so its spec also goes to classify.

Errors

The parser reports every problem on a type in one AnnotatedDecisionException, each with its member, or the type when the problem is the whole type, and the fix. For example, a Kotlin @field: annotation on a data class:

FieldSiteTriage.urgent: carries @PropositionQuestion but Jackson leaves this field out of the property "urgent". Move the annotation to creator parameter urgent or getter getUrgent(). In Kotlin, write the annotation with @get: or with no use-site target.

The reference page has a table of the parser's messages and their fixes.

Kotlin

The Kotlin tests read through using(mapper) with jackson-module-kotlin 3.1 on Kotlin 2.2. No test covers defaults() with a Kotlin class.

Placement Reads
No use-site target Yes
@param: Yes
@get: Yes
@param: and @field: together (the placement a later Kotlin default site produces) Yes
@field: alone No. Rejected with the members to use instead
@property: Does not compile

Recommendation

Keep it as an opt-in prototype, unpublished until this recommendation is accepted. It produces specs equal to the builder's, projects into the declaring type after checking the response against its spec, and names the member and fix for every problem. Drop it if no consumer application adopts it by the release that promotes the decision types.

Conditions for accepting it:

  1. Exclude the module from Maven Central publication and confirm that in a release dry run. The module sets maven.deploy.skip, but the Central plugin lives in the build parent.
  2. Review the Jackson introspection calls against the Jackson version the release uses.
  3. Add a public factory to DecisionProjection so project(response, otherProperties) can return DecisionProjection<T> instead of T.
  4. Name an owner and a consumer application, as the decision types require for promotion.

Values in otherProperties are coerced leniently by Jackson: "3" and 3.0 fill an int, and "true" fills a boolean. No test pins this. A stricter rule would need its own decision.

The parser's cache doesn't hold classes alive. Jackson's own caches in the defaults() mapper can still hold a projected type, so an application that reloads classes should pass its own mapper with using(mapper).

Size and removal

Lines
Main code, ten Java files 2,015 (1,003 non-blank, outside comments)
DecisionTypeParser 1,239
Tests 2,951

No other module refers to it. Removal is the module directory, its <module> line, the reference page and its include:: line.

Stacked on #2076.

@jimador
jimador added this pull request to stack #2067 September 27, 2026 20:48
@jimador jimador changed the title prototype/annotated decision spec Prototype: read decision specs from annotated records Sep 27, 2026
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 0a0d675 to db3d0ce Compare September 27, 2026 21:14
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 47ebedc to 14ca1cc Compare September 27, 2026 22:10
@jimador
jimador removed this pull request from stack #2067 September 27, 2026 22:10
@jimador
jimador added this pull request to stack #2081 September 27, 2026 22:11
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 14ca1cc to 46d1912 Compare September 28, 2026 01:38
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 46d1912 to b2da4c2 Compare September 28, 2026 02:15
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch 2 times, most recently from fbfc459 to 7598bf2 Compare September 28, 2026 04:26
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 7598bf2 to c6f93d5 Compare September 28, 2026 05:07
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from c6f93d5 to df7bb24 Compare September 28, 2026 05:19
@jimador
jimador requested a review from igordayen September 28, 2026 06:14
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 2431900 to 5a1baa9 Compare September 28, 2026 16:07
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 5a1baa9 to 019639e Compare September 28, 2026 16:48
@jimador
jimador removed this pull request from stack #2081 September 28, 2026 16:48
@jimador
jimador added this pull request to stack #2087 September 28, 2026 16:49
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 019639e to 228756f Compare September 28, 2026 23:47
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 228756f to 6f7e6f8 Compare September 29, 2026 01:27
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from 461d753 to a66f006 Compare September 29, 2026 21:45
…otations

Adds embabel-agent-decision-annotations, an optional and unpublished
module that holds the question annotations for reading decision specs
from Java types. An architecture test keeps main classes to the core
decision types, Jackson and the JDK, and checks annotation retention
and targets.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
AnnotatedDecisions reads a DecisionSpec from a record or bean under one
Jackson mapper and caches each successful result per type. Question
names, option ids, level ids and question order come from the mapper's
creation view of the type and its serialized enum form. The parser
collects every problem in the type and reports them together in one
AnnotatedDecisionException, each naming the member, the cause and the
fix.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
AnnotatedDecision.project(response) checks that the response answers the
type's spec and converts the answers through the mapper that read the type.
project(response, otherProperties) also sets the properties without question
annotations and reports every missing, unknown or question-named key at once.
It returns the value alone because DecisionProjection has no public way to
hold values that did not come from the answers.

The support-triage spec is checked in as JSON. One test compares the
generated JSON with it and checks that it names no Java class. Another
executes it through the stub service using only the core decision types.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…t findings

The support-triage example reads one record under a plain mapper and under
a Spring Boot mapper configured for snake case. The question names and spec
id follow the mapper, and projection works under both. The Dice-shaped
example projects a proposition review whose identity comes from
otherProperties, and a policy reads the entity and the response provenance.
An inconclusive answer fails projection and the policy defers.

The Kotlin test checks that the DSL spec equals the Java record spec, and
records annotation placement on a data class. Only @get: reads. The default
site and @PARAM: also land on copy() parameters, and Jackson drops the
private backing field that @field: annotates.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… data classes

A Kotlin data class repeats each constructor parameter annotation on the
matching parameter of its generated copy(). The orphan scan skips a copy()
parameter that carries the same question annotations as the constructor
parameter, so the default site and @PARAM: read on a data class and give
the same spec and definition id as the Java record.

A field or method whose name matches a property that Jackson built from
other members gets its own message. It names the property and the members
Jackson reads for it, and adds the Kotlin use-site targets that work when
the type is a Kotlin class.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Kotlin test sources compile before Java test sources, so the Kotlin test
declares its own enums and loads the Java record by name. The test also
records that @PARAM: and @field: together fail on the backing field, which
is the placement a later Kotlin default site produces, and marks the data
class fixtures for the reference docs.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…nd its evaluation

The reference section covers when to use annotations, the annotations and
their targets, enums for options and levels, names and order from Jackson,
projection and its limits, the portable spec JSON, the error messages and
the Kotlin placements. The evaluation section records ergonomics, code
size, supported types, costs, the removal steps and the recommendation.
The example tests carry the tagged regions the section includes.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…he constructor annotation

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The cache stores each parsed decision with its class, so a class and its
loader are collected once the application drops them. A parse failure
throws out of computeValue, which stores nothing, and the type is read
again on the next call.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ethods outside a property

The orphan scan walks every interface of the type and its superclasses,
each once, and reports annotated members that belong to no Jackson
property. An interface method counts as covered when a property method
has its signature, including a getter inherited from a superclass that
does not implement the interface.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…data annotation type

The parser loads kotlin.Metadata once through its own class loader and
checks each class with isAnnotationPresent. The type is null when Kotlin
is absent, so the module keeps no Kotlin dependency.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…maller methods

Property reading, question checks, enum entry reading and the orphan scan
each move into their own private methods. The per-parse collections become
fields of the one-shot parser. Every problem message stays the same.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ests

Each assertThrows lambda makes one throwing call, with its arguments built
beforehand. Question names use a method reference, the multi-line message
uses a text block, and the failed-parse check uses assertNotSame. The cache
test waits for collection on a ReferenceQueue with a timeout.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…alue before projecting

Responses no longer carry a spec id, so project checks the response
with requireMatches.

- A mismatch still throws DecisionProjectionException. The message now
  says what differs and keeps the hint to ask with spec().
- A response to reworded questions still projects, since instructions
  are not compared. A test pins this.
- Tests compare specs by equality and check responses with
  requireMatches.
- The prototype page drops the ids and updates the size figures.

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>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
… enum

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ion spec regardless of other properties

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>
…m its new package

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
@jimador
jimador force-pushed the prototype/annotated-decision-spec branch from a66f006 to 1cb574f Compare September 29, 2026 23:21
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant