Skip to content

Feature: typed decision specs, results and Jackson bindings - #2075

Open
jimador wants to merge 47 commits into
feature/llm-decision-providerfrom
feature/decision-spec-contracts
Open

jimador wants to merge 47 commits into
feature/llm-decision-providerfrom
feature/decision-spec-contracts

Conversation

@jimador

@jimador jimador commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

This PR adds typed decision specs. A spec holds one or more named questions, and a DecisionRequest pairs 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.

var triage = DecisionSpec.builder()
    .proposition("is_urgent", question -> question
        .asking("Does this convey urgency?"))
    .choice("department", question -> question
        .asking("Which team should handle this?")
        .option("billing", "Payments, invoicing, refunds")
        .option("technical", "Bugs, outages, integrations")
        .option("sales", "Pricing, upgrades, new accounts"))
    .rating("frustration", question -> question
        .asking("How frustrated is the customer?")
        .level("Calm")
        .level("Frustrated")
        .level("Very angry"))
    .build();

Named questions are reusable handles for typed results:

var urgent = Questions.named("is_urgent")
    .proposition("Does this convey urgency?")
    .build();

var department = Questions.named("department")
    .choice("Which team should handle this?")
    .option("billing", "Payments, invoicing, refunds")
    .option("technical", "Bugs, outages, integrations")
    .option("sales", "Pricing, upgrades, new accounts")
    .build();

var frustration = Questions.named("frustration")
    .rating("How frustrated is the customer?")
    .level("Calm")
    .level("Frustrated")
    .level("Very angry")
    .build();

var triage = DecisionSpec.of(urgent, department, frustration);
var request = DecisionRequest.of(ticketText, triage);

Kotlin gets a receiver DSL over the same builders. It builds a spec equal to the Java one.

val triage = decisionSpec {
    proposition("is_urgent") {
        asking("Does this convey urgency?")
    }
    choice("department") {
        asking("Which team should handle this?")
        option("billing", "Payments, invoicing, refunds")
        option("technical", "Bugs, outages, integrations")
        option("sales", "Pricing, upgrades, new accounts")
    }
    rating("frustration") {
        asking("How frustrated is the customer?")
        level("Calm")
        level("Frustrated")
        level("Very angry")
    }
}

A spec is an immutable value, so it can be built once and shared:

@Configuration(proxyBeanMethods = false)
class SupportDecisionConfiguration {

    @Bean
    DecisionSpec supportTriageSpec() {
        return DecisionSpec.builder()
            .proposition("is_urgent", question -> question
                .asking("Does this convey urgency?"))
            .choice("department", question -> question
                .asking("Which team should handle this?")
                .option("billing", "Payments, invoicing, refunds")
                .option("technical", "Bugs, outages, integrations"))
            .build();
    }
}

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:

PropositionResult urgency = response.answer(urgent);
ClassificationResult team = response.answer(department);
RatingResult anger = response.answer(frustration);

A reader that only has a name uses the sealed answer types:

String route = switch (response.answer("department")) {
    case DecisionAnswer.Choice choice
        when choice.getOutcome() instanceof ClassificationResult.Selected selected -> selected.getCategoryId();
    case DecisionAnswer.Choice choice -> "triage-queue";
    case DecisionAnswer.Proposition proposition -> "not a choice";
    case DecisionAnswer.Rating rating -> "not a choice";
};

Jackson

JsonMapper mapper = JsonMapper.builder().build();

String specJson = mapper.writeValueAsString(triage);
DecisionSpec specRead = mapper.readValue(specJson, DecisionSpec.class);

String responseJson = mapper.writeValueAsString(response);
DecisionResponse responseRead = mapper.readValue(responseJson, DecisionResponse.class);
ClassificationResult team = responseRead.answer(department);

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, FailureReason and ModelProvenance, 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_PROPERTIES turned 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 under src/test/resources/decision/golden pin the spec, request, response, failed response and capabilities JSON.

Application types

A Spring Converter handles each outcome itself:

static class SupportRouteConverter implements Converter<DecisionResponse, SupportRoute> {

    private final PropositionQuestionSpec urgent;
    private final ChoiceQuestionSpec department;

    SupportRouteConverter(PropositionQuestionSpec urgent, ChoiceQuestionSpec department) {
        this.urgent = urgent;
        this.department = department;
    }

    @Override
    public SupportRoute convert(DecisionResponse response) {
        boolean isUrgent = switch (response.answer(urgent)) {
            case PropositionResult.Answered answered -> answered.getAnswer();
            case PropositionResult.Inconclusive inconclusive -> true;
            case PropositionResult.Failure failure -> true;
        };
        String team = switch (response.answer(department)) {
            case ClassificationResult.Selected selected -> selected.getCategoryId();
            case ClassificationResult.NoMatch noMatch -> "general";
            case ClassificationResult.Inconclusive inconclusive -> "triage-queue";
            case ClassificationResult.Failure failure -> "triage-queue";
        };
        return new SupportRoute(isUrgent, team);
    }
}

DecisionProjection maps 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 in getQuestions().

record SupportRoute(boolean is_urgent, String department) {}

var projection = DecisionProjection.of(response, SupportRoute.class);
SupportRoute route = projection.getValue();

Classification is a decision

A classification is a decision with one choice question. ClassificationSpec in com.embabel.common.ai.classification extends DecisionSpec and holds exactly one ChoiceQuestionSpec. The builder takes asking and category, and names the question classification. ClassificationSpec.of(question) wraps a choice question you already have and keeps its name. getQuestion() returns it, and selected(...) and validate(result) check a selection against the spec's categories.

ClassificationSpec departments = ClassificationSpec.builder()
    .asking("Which team should handle this?")
    .category("billing", "Payments, invoicing, refunds")
    .category("technical", "Bugs, outages, integrations")
    .build();

Kotlin has classificationSpec { }:

val departments = classificationSpec {
    asking("Which team should handle this?")
    category("billing", "Payments, invoicing, refunds")
    category("technical", "Bugs, outages, integrations")
}

ClassificationService.classify(input, spec) is a default method that calls classify(ClassificationRequest.of(input, spec)):

record TicketRouter(ClassificationService classifier, ClassificationSpec departments) {
    String route(String ticketText) {
        ClassificationResult team = classifier.classify(ticketText, departments);
        return switch (team) {
            case ClassificationResult.Selected selected -> selected.getCategoryId();
            case ClassificationResult.NoMatch noMatch -> "triage";
            case ClassificationResult.Inconclusive inconclusive -> "triage";
            case ClassificationResult.Failure failure -> "retry-later";
        };
    }
}

A decision service answers it too, since a classification spec is a decision spec and a ClassificationRequest is a DecisionRequest. response.answer(spec.getQuestion()) returns the ClassificationResult.

  • ClassificationRequest extends DecisionRequest and is built with ClassificationRequest.of(input, spec). The public constructor is gone. This changes the request shape Feature: implementation of classification and decision service contracts #2062 introduced.
  • CategoryMapping takes (instructions, values), and fromEnum(type, instructions, describe) takes the instructions too. spec() returns the ClassificationSpec built from them.
  • Providers pass the instructions through. Jev puts them in its Choice in place of a fixed sentence, and the prompted classifier adds a Question: line before the categories.
  • A classification spec or request writes the same JSON as the equivalent decision spec or request. That JSON reads back as either type when it holds one choice question. Reading any other shape as a ClassificationSpec fails.
  • DecisionSpec and DecisionRequest are now open with internal constructors, so only the classification types extend them. equals and hashCode are final, so a classification spec equals a DecisionSpec holding 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 <|.. Rating
Loading
  • RatingResult holds 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.
  • DecisionCapabilities describes 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 typed answer(...) overload per question kind. Passing the wrong outcome type is a compile error.
  • Existing source changes are limited to opening DecisionSpec and DecisionRequest, the classification types, ClassificationService, the observed classification and decision services (which observe classify(input, spec) too), and the Jev and prompted classifiers. reference.adoc gains one include for the new page, and the classification page covers ClassificationSpec. 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.

@jimador
jimador added this pull request to stack #2067 September 27, 2026 14:03
@jimador jimador changed the title feature/decision spec contracts Feature: typed decision specs, results and Jackson bindings Sep 27, 2026
@jimador
jimador marked this pull request as ready for review September 27, 2026 14:03
@jimador
jimador force-pushed the feature/decision-spec-contracts branch from 8601264 to 0bde2aa Compare September 27, 2026 17:19
@jimador
jimador requested a review from igordayen September 27, 2026 21:09
@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 feature/decision-spec-contracts branch from f17ce5d to b46a83d Compare September 28, 2026 01:29
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
jimador force-pushed the feature/decision-spec-contracts branch from 565259d to d833603 Compare September 28, 2026 05:19
@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