Who this is for¶
An application on Quarkus that calls Bedrock through the AWS SDK for Java v2,
software.amazon.awssdk:bedrockruntime, with the client built by a bean, and
that calls converse or invokeModel with an Anthropic model.
Everything on this page was run, on 2026-11-06, with digline installed from
this repository at b28fdef8, Quarkus 3.40.1.1 and the SDK 2.57.8. No call
reached AWS or a model. A stand-in for Bedrock on 127.0.0.1 answered the
application and the judge, and every answer, score and token count below is
the stand-in's. The SDK and the judge were pointed at it with
AWS_ENDPOINT_URL_BEDROCK_RUNTIME, which both read; on your machine that
variable is left out and Bedrock answers. Paths are shortened to ….
Day one: recording¶
Three files. Copy DiglineRecorder.java, DiglineAws.java and
DiglineMessages.java from recorders/java/src/main/java/dev/digline/ into
your project, in the package dev.digline
(which files, for which client).
One line on the builder, in the bean that builds the client:
@ApplicationScoped
public class BedrockClients {
@ConfigProperty(name = "digline.root")
Path root;
DiglineRecorder recorder;
BedrockRuntimeClient client;
@PostConstruct
void build() {
recorder = DiglineRecorder.development(root, "acme", "triage-app");
client = BedrockRuntimeClient.builder()
.overrideConfiguration(c -> c.addExecutionInterceptor(new DiglineAws(recorder)))
.build();
}
public DiglineRecorder recorder() {
return recorder;
}
public BedrockRuntimeClient client() {
return client;
}
}
Other beans reach the client and the recorder through client() and
recorder(). Read through an injected bean's fields, both were null: what
is injected is the bean's proxy.
The root comes from configuration, digline.root, and not from the
working directory: the jar below and mvn test were each given theirs, and
the tests recorded or not by which one they were given (the last section).
Here it is set by the environment, DIGLINE_ROOT, the name MicroProfile
Config maps it to.
The suite is named around the call. A turn of the agent, Converse until the model stops asking for a tool, is one episode, and its calls name the suite inside it:
try (var episode = bedrock.recorder().episode("triage", Map.of("ticket", ticket));
var calls = DiglineRecorder.suite("triage")) {
try {
String answer = turn(ticket);
episode.output(answer);
return answer;
} catch (RuntimeException failed) {
episode.interrupted();
throw failed;
}
}
A single call names its suite through one method every call goes through:
<T> T ask(String feature, Supplier<T> call) {
try (var suite = DiglineRecorder.suite(feature)) {
return call.get();
}
}
return ask("triage", () -> bedrock.client().invokeModel(r -> r
.modelId(MODEL)
.body(SdkBytes.fromString(body, StandardCharsets.UTF_8)))
.body().asUtf8String());
Development and production. DiglineRecorder.development files every
recording under one tenant, and a switch file that leaves its values out
takes the defaults. An application that resolves its tenant per request is
production:
It refused to start on the file digline record on writes, which declares
none of the three values production asks for. It said:
"switches recording on in production with no declared redaction (write
redact = "none", the one this release implements); no declared sampling
(write sample = p, above 0 and at most 1); no ceiling on the size of its
recordings (write ceiling = N, in bytes). In production all three are
declared (ADR 0058 §10)".
Switch it on, and run the application, from the project's root. The
first block was run again on 2026-11-07, with digline at 1b5cf083, after
the sentence on production came to name Node:
$ digline record on --tenant acme
recording on for tenant 'acme': …/triage/.digline/acme/recorder.toml
values: on true, sample 1.0 (default), redact none (default), ceiling 104857600 bytes (default), interval 5.0 s (default)
development: what the file leaves out is a default; an application that resolves its tenant per request is production (wrap(tenant=callable) in Python, DiglineRecorder.production in Java and Node; the proxy's --tenant is never production), and refuses this file unless it declares sample, redact and ceiling
$ DIGLINE_ROOT=$PWD AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:18790 AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake java -jar target/quarkus-app/quarkus-run.jar
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2026-11-06 14:49:40,303 INFO [io.quarkus] (main) triage 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.40.1.1) started in 0.574s. Listening on: http://0.0.0.0:8080
2026-11-06 14:49:40,308 INFO [io.quarkus] (main) Profile prod activated.
2026-11-06 14:49:40,308 INFO [io.quarkus] (main) Installed features: [cdi, rest, smallrye-context-propagation, vertx]
digline recorder: invoke_model on amazon.titan-text-express-v1: this model family is not recorded
The application answered seven requests: two tickets through invokeModel,
four through the Converse turn, and one invokeModel on Titan, whose line is
the last above. The tickets went in through the application's own endpoint:
$ curl -s -X POST --data 'Where is my refund for the blue jacket?' http://127.0.0.1:8080/triage/converse
{"category": "refund"}
$ digline record status --tenant acme
tenant 'acme' · recording on
switch: …/triage/.digline/acme/recorder.toml
values: on true, sample 1.0 (default), redact none (default), ceiling 104857600 bytes (default), interval 5.0 s (default)
writes to: …/triage/.digline/acme/recordings
size: 11732 bytes of recordings (as written; disk use is higher), ceiling 104857600
suite triage: 6 calls, 4 inside episodes, 0 orphans, 4 episodes; since the last import: not read (pass --suite)
not counted: 1 process keeps recordings and no counts, so what was not recorded there is not known
Six calls: the two invokeModel calls and the four Converse calls, each
inside its episode. The Titan call is not among them, and the last line is
why the status cannot count it: the Java writer keeps no counts file.
Day two: from recordings to a suite¶
A first suite, proposed, from a directory eval/ in the project:
$ digline suggest --suite triage --tenant acme --repository private --root ..
digline suggest: wrote …/triage/eval/suite.proposed.toml. It is a proposal, not a suite: no command of digline reads it, nothing in it is a verdict, and no answer was marked. Adopt its lines into your suite, and the commit is yours
assumed:
the form: suite.toml, the suite does not exist yet; the proposal is always TOML, and a target that is a function needs a suite.py (docs/declarative.md, What it cannot say)
the setup its target reproduces: 9bd4f0e5 for single calls; 27430ce2 for episodes
to read how two setups differ, once the proposal is your suite: digline record compare --tenant acme --suite <your suite> --setup 9bd4f0e5 27430ce2 --show-setup
the judge: none read: llm_rubric is proposed with judge = "provider/model", a placeholder that is refused until you name the provider plugin and the model, which are yours to choose; the provider plugins installed here: anthropic, bedrock
each check: 1. is_json, every one of 6 recordings answers JSON, top level object
each check: 2. json_schema, the draft requires the keys every one of 6 recordings carries
each check: 3. never, declared to the model and called in none of 6 recordings
each check: 4. never, declared to the model and called in none of 6 recordings
each check: 5. max_steps, the model's turns, in an agent's episode; limit is a placeholder, the strictest the check accepts: no run measured it
each check: 6. latency_budget, every one of 6 recordings carries it, and the target has to report it too; max_ms is a placeholder, the strictest the check accepts: no run measured it; tolerance is a placeholder
each check: 7. llm_rubric, the judged check the capture reviews answers for, asked three times with two in three to agree; threshold is a placeholder, the strictest the check accepts: no run measured it; the rubric, the judge and the tolerance are placeholders
each starting number: no run read; 7 placeholders, each the strictest value its check accepts and marked in the file, failing until a measured number replaces it
the calibration band: a skeleton for llm_rubric, with the answer to paste and the band to place, strictly between 0 and 1, both yours
the counterexample: counterexample-1, constructed and marked as constructed: its input is yours to write, one whose answer llm_rubric must refuse, and nothing in it was recorded
the regime: repository = 'private': without an assistant, the file quotes no recorded input or answer; the rules read from the recordings, a tool's name or a key, are in its checks
not yet examined: group 1 · single calls, text · 2 answers from 2 recordings
not yet examined: group 2 · episodes, text · 4 answers from 4 recordings
to examine them, once the proposal is your suite: digline record import --tenant acme --suite <your suite>
The two never checks are the two tools every call declares,
lookup_order and escalate, which no recording called. In the file, max_steps reads:
[[assertions]]
# the model's turns, in an agent's episode; limit is a placeholder, the
# strictest the check accepts: no run measured it
type = "max_steps"
limit = 1 # placeholder: yours to write
count = "model"
A placeholder is the strictest value its check accepts, written where no
run measured one, and marked # placeholder. It is replaced by hand with a
number the runs measured (the guide, chapters 4 and 5). Which
placeholder can fail depends on the cases. Here every case is a reviewed
answer: the judged check runs on each of them, and the checks on the steps on
each episode, so limit = 1 is asked of every turn. is_json, json_schema
and latency_budget produced no verdict on these cases, and wait for a case
the target answers.
Adopting it is copying the file to suite.toml and writing what it leaves
to you: where the suite runs, the rubric and the judge.
$ diff suite.proposed.toml suite.toml
11c11
< # environment = WRITE: mandatory, with no default: where it runs
---
> environment = "dev"
75,76c75,76
< rubric = "WRITE: one question a good answer says yes to" # placeholder: yours to write
< judge = "provider/model" # placeholder: yours to write, docs/declarative.md#the-judge-by-coordinates
---
> rubric = "Is the category right for the ticket?"
> judge = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
A person's yes or no, on the page digline view serves with
--allow-record:
$ digline view --suite eval/suite.toml --allow-record
digline view on http://127.0.0.1:7373/?launch=… — the recorder's writes enabled, for the first browser that opens this address; --allow-promote to promote — ctrl-c to stop
Its Recordings view lists what digline record import lists, in two
groups: a single call, text, the two invokeModel answers, and an
episode, text, steps model, finish stop, the four turns. Each answer is a
row with its id, when it was first recorded, its setup and its answer, and
each that can be written has two choices, --good and --bad. Under them
is one field, Your name, the mark each case carries (--by), and one button,
Write the answers named. Under the button is the command that does what
the button does, served as digline record import --tenant acme --suite
eval/suite.toml; the page says it follows the form as you fill it in, where
its script runs. With the choices and the name written out, it is:
$ digline record import --tenant acme --suite eval/suite.toml --good cap-2ff5a5465dc6d823-f8b3a680 --good cap-a6bd9f85bafda9c3-cdd80da0 --bad cap-379139c501bdf803-cdd80da0 --good cap-85b5e8c071506b9e-f8b3a680 --by 'Ada Lovelace'
tenant 'acme' · suite triage · writes to eval/captured.json · repository private · check llm_rubric
wrote cap-85b5e8c071506b9e-f8b3a680
tenant 'acme' · suite triage · writes to eval/captured.json · repository private · check llm_rubric
wrote cap-2ff5a5465dc6d823-f8b3a680
tenant 'acme' · suite triage · writes to eval/captured.json · repository private · check llm_rubric
wrote cap-a6bd9f85bafda9c3-cdd80da0
tenant 'acme' · suite triage · writes to eval/captured.json · repository private · check llm_rubric
wrote cap-379139c501bdf803-cdd80da0
4 cases written to eval/captured.json: the diff is the review and the commit is the signature; nothing was committed, run or promoted (ADR 0023 §7)
The button and this command, given the same four answers and the same name,
wrote the same captured.json, byte for byte. The no is the turn that
answered other to "I want my money back for the shoes."
The run, and the gate. The suite's cases are reviewed answers, so the run asks the judge and never the application:
$ AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:18790 AWS_DEFAULT_REGION=us-east-1 AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake digline run --suite eval/suite.toml
digline: tenant 'acme' · 0 cases × 3 samples = 0 calls to the target; 4 reviewed answers judged 36 times, with no call to the target; each answer is judged 3 times by llm_rubric
digline: judge: 36 calls · counts: 756 in / 216 out · cost: 0.002020 USD
2026-11-06T13-50-44-669754-00-00-6ef68ce91ee1a1dd
digline: recorded .digline/acme/runs/triage/2026-11-06T13-50-44-669754-00-00-6ef68ce91ee1a1dd.json
digline: next: digline promote --suite eval/suite.toml --run latest --replacing none # keeps this run as the reference
The judge's region is boto3's, which reads AWS_DEFAULT_REGION and not
AWS_REGION. A run without it took the region of the machine's profile,
where the judge's model had no price, and no answer could be judged. The run
exited 0, and a person made it the reference:
$ digline promote --suite eval/suite.toml --run latest --replacing none
tenant 'acme' · triage baseline set to 2026-11-06T13-50-44-669754-00-00-6ef68ce91ee1a1dd
When a check on the steps contradicts a yes. A later ticket made the turn
ask for lookup_order, so the turn took two model calls and a tool call:
$ curl -s -X POST --data 'I want a refund for order 42.' http://127.0.0.1:8080/triage/converse
{"category": "refund"}
The answer is right, and the person said yes to it:
$ digline record import --tenant acme --suite eval/suite.toml --good cap-baa842f3dfae2b30-f8b3a680 --by 'Ada Lovelace'
tenant 'acme' · suite triage · writes to eval/captured.json · repository private · check llm_rubric
wrote cap-baa842f3dfae2b30-f8b3a680
1 case written to eval/captured.json: the diff is the review and the commit is the signature; nothing was committed, run or promoted (ADR 0023 §7)
$ AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:18790 AWS_DEFAULT_REGION=us-east-1 AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake digline run --suite eval/suite.toml
digline: tenant 'acme' · 0 cases × 3 samples = 0 calls to the target; 5 reviewed answers judged 45 times, with no call to the target; each answer is judged 3 times by llm_rubric
digline: judge: 45 calls · counts: 945 in / 270 out · cost: 0.002524 USD
digline: checks on the steps contradicted a person on 1 reviewed answer: never, on case cap-baa842f3dfae2b30-f8b3a680; max_steps, on case cap-baa842f3dfae2b30-f8b3a680.
2026-11-06T13-51-08-432933-00-00-6ef68ce91ee1a1dd
digline: recorded .digline/acme/runs/triage/2026-11-06T13-51-08-432933-00-00-6ef68ce91ee1a1dd.json
digline: next: digline compare --suite eval/suite.toml --run latest # says whether anything got worse
$ digline explain --suite eval/suite.toml --run latest
What ran
Checks on the steps contradicted a person on 1 reviewed answer: never, on case cap-baa842f3dfae2b30-f8b3a680; max_steps, on case cap-baa842f3dfae2b30-f8b3a680, and each fails: cap-baa842f3dfae2b30-f8b3a680 for never, failed where the person said yes; cap-baa842f3dfae2b30-f8b3a680 for max_steps, failed where the person said yes. They are counted in no aggregate, and the target was not asked for them.
5 cases ran.
17 checks ran.
Every case could be judged.
No case is suspended.
The suite is unchanged from the reference.
What differed underneath
Nothing in this group.
What moved
cap-baa842f3dfae2b30-f8b3a680 · never failed at 0.000000, and a person said yes to the turn: its steps contradict the person, and the case fails.
cap-baa842f3dfae2b30-f8b3a680 · max_steps failed at 0.000000, and a person said yes to the turn: its steps contradict the person, and the case fails.
A yes on a turn is a yes to its steps too. Here the person vouched for a turn
that called lookup_order, which the suite says is never called, and that
took two model turns, past the placeholder limit = 1. Which of the two is
wrong, the rule or the yes, is the person's to say.
digline compare --suite eval/suite.toml --run latest exits 1.
What you get, and what stays out¶
- Recorded: Converse, in a turn or as a single call, and
invokeModelwith an Anthropic model, whose body is Messages. Not recorded:invokeModelon another family. The Titan call above wrote no recording and said so on standard error (which calls). - Converse and
invokeModelrecord different setups. Mixed as single calls in one suite, they are two setups of one form, and only the newest one's answers can be written: run with theinvokeModelcalls first and four Converse calls after, the import listed the twoinvokeModelanswers as not electable: recorded only under earlier setups. The Java page recommends Converse for new code. mvn testrecords, with the switch on, when the tests run against the root that holds it: a Quarkus test that called the turn once, withDIGLINE_ROOTat the project, added two recordings, the call and its episode. WithDIGLINE_ROOTattarget/, which holds no.digline/, it added none.- No call in this story reached AWS or a model. The application's answers, the judge's scores and the token counts the costs are printed from are a stand-in's.
The pages this one leans on: the Java recorder,
digline suggest, digline record and
digline view.