Cadence
Audit

Findings

14 control weaknesses, 8 still open. Each records the condition, the evidence for it, and what was done about it.

Each open finding was accepted deliberately. Each carries a dated management response naming who accepts it, why it stands today, and the specific thing that would close it, so a reader can disagree with the acceptance rather than guess at it.

F1, F5 and F6 came from trace data. The application reported success on a run in which the primary provider failed 24 times.

Each finding names the control objective it bears on, by id from the library of 10. F5 names none of them. The risk map excludes contractual and vendor risk from its scope, so third-party provider risk was never given an objective, and running the tests surfaced the omission.

F1

Per-row model provenance was hardcoded

Fixed

CO1 · Every AI attachment is inventoried, owned and classified

Condition
Every categorised row and every rationale was stamped as coming from Gemini regardless of which provider answered, so one response could name Groq in its summary and Gemini on each row beneath it.
Evidence
3 evaluation runs answered by Groq (gen_ai.provider.name: groq, model openai/gpt-oss-120b) while the row-level source read gemini in all three.
Disposition
The source field now names the PATH taken (seed, rules, model) and claims no vendor. A separate provider field carries whichever provider answered, set from the failover result.
F2

The judge's provider was discarded

Fixed

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The application kept no record of which model scored a rationale, so self-preference bias could not be tested for: a model and its judge can come from the same family, and models rate their own family's output higher.
Evidence
judgeRationale destructured only the value from the failover result, dropping the provider.
Disposition
JudgeScores now carries judgedBy. Validating the judge against human labels has still never been done, and remains the larger gap.
F3

The categorisation cache was not keyed by model

Fixed

CO7 · Model changes reach change management

Condition
The cache stored a model column documented for invalidation but keyed on the transaction alone, and wrote with onConflictDoNothing. A label produced by one model was therefore served indefinitely after the model changed, and the first provider to answer a line owned that label permanently.
Evidence
getMany accepted keys only, with no model parameter. The cache is global and explicitly never cleared by a demo reset.
Disposition
The key now includes the model id, so a model change is a cache miss. A line has one key per configured provider, hits resolve in failover priority order, and writes are an upsert.
F4

The categoriser is not reproducible, so neither is its score

Open

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
Repeated runs over identical inputs return different answers, so a change in accuracy between 2 versions cannot be distinguished from noise. No confidence interval is reported anywhere.
Evidence
3 runs over the same 8 hard cases at temperature 0.1 returned 50.0%, 37.5% and 50.0%. The evaluation set's inputs are frozen by a seeded generator; the measurement taken against them is not.
Disposition
Open. Partly inherent to the provider. Unapplied mitigations: temperature 0, a fixed seed where supported, and reporting an interval from repeated runs rather than a single figure.
Management response

The variance comes from the provider's own sampling. The mitigations that would reduce it, temperature 0 and a fixed seed, change how the categoriser behaves, which would invalidate the evaluation-set figures published beside it. Disclosing the variance is preferred to trading it for a silent change in accuracy.

Closes when. A run at temperature 0 with a fixed seed is scored against the same evaluation set, and the harness reports an interval from repeated runs in place of a single figure.

Accepted 2026-09-05 · Product owner, lending decisioning

F5

A cost-only view of provider risk ranks the providers backwards

Open

No control objective covers this

Condition
The cost dashboard shows the primary provider at zero and the fallback carrying all reported spend, which reads as the primary being the cheap, safe default. Cost is the wrong axis for both of them. Each runs on a free tier, so neither has ever cost money, and what actually binds each one is a quota.
Evidence
The primary runs on a free tier, evidenced by its own rate-limit response naming service tier on_demand. The fallback runs on one too: a run on 2026-09-05 was refused with "Quota exceeded for metric generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20". Zero cost was accurate for both. The 0.21 dollars once reported against the fallback was the observability tool applying a published list price to a request that was never billed, which is the same error already withdrawn against the primary.
Disposition
Open, with one part now handled. Since 2026-09-07 both models carry an explicit zero price in the observability backend, so a new generation records $0.00000, and the traces written before that keep the figure they were written with. The rest stands. The 2 quotas have different shapes: the primary allows 8,000 tokens a minute and recovers within the minute, while the fallback allows 20 requests a day and does not recover until the next day. A run that exhausts the fallback cannot be rescued by waiting, and no view in the system distinguishes those 2 failure modes. Remediation is to monitor quota consumption per provider as a first-class signal, which F6's failure capture makes possible, and to retire cost as a provider-risk axis while both tiers are free.
Management response

The quota failures are captured and not yet shown beside cost. What the finding guards against is a wrong ranking of 2 providers, which in a prototype affects a provider choice rather than a credit decision. The cost figure being wrong in the safe direction, reporting spend that never happened, is the reason it went unchallenged for so long.

Closes when. The provider view reports quota consumption and quota failures per provider per run, separating a per-minute ceiling from a daily one, and the cost column is removed or marked as list price rather than spend.

Accepted 2026-09-05 · Product owner, lending decisioning

F6

Failover between providers was silent

Partly fixed

CO7 · Model changes reach change management

Condition
The primary provider failed under load and the work passed to the paid fallback with nothing surfacing the substitution, so a single evaluation run was answered by 2 different models and the headline accuracy was a blend.
Evidence
24 error observations of 88 in one run, all the same cause: rate limit reached on tokens per minute, limit 8000. The work transferred to the fallback provider on that run. An earlier version of this finding called that a transfer of spend, on a 0.21 dollar figure the observability tool computed from a list price. The fallback is also on a free tier, so no money changed hands and the consequence is a transfer of quota. See F5.
Disposition
Visibility fixed: failures are classified and written into the append-only audit trail. The cause stands. Concurrency 3 at roughly 4,500 tokens a call is about 13,500 against an 8,000 ceiling, whatever the code comment claims.
Management response

Closing the cause means either reducing batch size and concurrency, which slows every evaluation run, or upgrading a provider to a paid tier. Neither is warranted for a prototype, and the substitution is now written where a reviewer reads it. The scripts that need a complete run pace themselves instead, which is a workaround rather than a fix.

Closes when. Batch size and concurrency are brought under the 8,000 token-per-minute ceiling, or the primary provider is upgraded to a paid tier.

Accepted 2026-09-05 · Product owner, lending decisioning

F7

The evaluation is structurally biased toward the incumbent

Open

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The generator writes both the transactions and their labels, and the rules categoriser matches its vocabulary, so 98.7% of the set is close to circular for the rules path. The headline metric cannot justify the feature it exists to justify.
Evidence
Over the full 1,067-case set the deterministic rules score 99.3% and the language model 96.1%. Only the 14 hand-authored hard cases carry real discriminating power, and 14 is too small a sample to settle anything.
Disposition
Open and structural, not closable by a code change. The harness now always reports overall accuracy alongside the hard and seeded subsets so the headline cannot be read alone, which is mitigation rather than remediation.
Management response

The only real remediation is a hard-case set labelled by people, and generating one synthetically would be writing the evidence rather than earning it. The metric is disclosed as unfair instead, with the overall figure always reported beside the subsets.

Closes when. A hard-case set is labelled independently by at least 2 people, agreement between them is reported, and the model is scored against that set rather than against the generator's own labels.

Accepted 2026-09-05 · Quality owner, evaluation harness

F8

The accuracy threshold cannot see the group most at risk

Open

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The categoriser is governed by one figure, overall accuracy against the evaluation set, with a declared threshold of 95.0% that is checked by hand. Accuracy is not uniform underneath that average and is lowest for the applicants a wrong label hurts most, so the threshold stays quiet while the disparity it exists to catch is already present.
Evidence
The evaluation set was run and scored group by group on 2026-09-05, answered throughout by GPT-OSS 120B on Groq with no provider failures. Overall 96.3%, comfortably above the gate. Everyone else 97.1%. Irregular income and a thin file, 32 cases, 84.4%, which is 12.7 points below the rest. Unusual statement vocabulary, 14 cases, 64.3%, which is 32.8 points below. The disparity is present now rather than predicted. On the same figures a group of 13 cases or fewer can drop to zero accuracy without the gate reacting, and the 14-case group is inside that limit, so it can fail completely and the gate stays quiet. In the irregular-income group the errors are one repeated failure: project fees arriving as transfers labelled recurring income or other rather than salary, so income the affordability calculation should count is miscounted or dropped.
Disposition
Open, and now measured rather than reasoned. The remedy has 2 parts that should not be confused: a threshold on the groups that carry the risk, which makes the disparity something a deploy can react to, and work on the categoriser itself to close it. Neither is done. One caution on the figures: this is a single run and the categoriser is not reproducible (F4), so a point or two is noise while a gap of 32.8 points is not. A second caution: the seeded part of the set is near-circular (F7), which flatters the group scored at 97.1% more than the hand-authored group scored at 64.3%, so the true gap is likelier to be understated than overstated.
Management response

The disparity is disclosed on the evaluation page beside the accuracy figure it qualifies, rather than being held until it could be fixed. Setting a group threshold off a single run would put a gate on a number that moves between runs, and the reproducibility problem has to be settled first or the new gate fires on noise.

Closes when. The categoriser is made reproducible enough to report an interval per group, a threshold is set on the exposed groups from that interval, and whatever applies the overall figure reads it alongside them.

Accepted 2026-09-05 · Product owner, lending decisioning

F9

The prompt shows the model 74 of the cases it is scored on

Open

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The few-shot exemplars in the categoriser prompt carry description strings that also appear in the evaluation set, each with its correct label attached. On those cases the score measures recall of the prompt as much as it measures categorisation.
Evidence
5 of the 6 exemplars in CATEGORISER_FEWSHOT use a description that appears verbatim in the set, covering 74 of 1,067 cases, and on all 74 the label the exemplar displays is the one scored as correct. A further 246 cases, 23.1% of the set, open with a description stem the prompt displays. No exemplar matches a case on description and amount together, so the prompt repeats the wording of a line rather than the whole line.
Disposition
Open. It compounds F7 from the other side: F7 flatters the deterministic rules baseline, and this flatters the language model on the 6.9% of cases whose wording the prompt has already shown it. The harness reports a single accuracy figure across both parts.
Management response

The exemplars teach the label format and the German transaction vocabulary, and rewriting them changes how the categoriser behaves, which would break comparison with every figure published under the current prompt version. Reporting the overlapping cases separately is the smaller change and it is open work.

Closes when. The harness reports accuracy over the 74 overlapping cases separately from the rest, or the exemplars are rewritten with descriptions the set does not contain and the run is repeated under a new prompt version.

Accepted 2026-09-07 · Quality owner, evaluation harness

F10

The rationale hard gates have only ever scored the deterministic template

Open

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The 5 deterministic checks are declared as hard gates on the drafted rationale. Every case they run against carries the deterministic template instead, which is assembled from the same decision package the checks compare it to, so groundedness and outcome faithfulness pass by construction. The text a language model drafts, which is what the checks exist to constrain, has yet to be scored.
Evidence
personaRationaleCases() in src/lib/rationale-eval-cases.ts scores buildRationale(), the deterministic template, and its own docstring describes those cases as a regression guard on the rationale generator. A run on 2026-09-07 returned 8 of 8 personas passing all 5 dimensions, 40 checks in total. The 6 hand-authored known-bad cases were each caught on the dimension they violate, which measures the checkers rather than the drafting. generateRationale() in src/lib/llm.ts, the path whose text reaches a reviewer, has yet to be put through the checks.
Disposition
Open. The checkers are calibrated and the standard is written, and the path they govern is unmeasured. A pass rate of 40 of 40 published beside the phrase hard gate describes the template that would be served if the model failed.
Management response

Scoring drafted rationales needs a live run across every persona and outcome, and the drafting runs at temperature 0.3, so a single run reports a figure that changes on the next one, which is F4 again. Publishing the template's pass rate as though it covered the model would be the larger error, so the scope of the figure is stated instead.

Closes when. generateRationale() is run across the personas, scored by the same 5 checks, and its pass rate is published separately from the template's, with the spread across repeated runs reported beside it.

Accepted 2026-09-07 · Quality owner, evaluation harness

F11

The evaluation page published 2 populations under one label

Fixed

CO5 · Model performance is monitored against a defined threshold with an owner and an action

Condition
The /eval page headlined every figure as overall accuracy. It loaded with the deterministic rules baseline over all 1,067 labelled lines and, once a viewer ran the live model, replaced that with a 40-line sample under the same label. The 95.0% threshold is defined over the full set, so a reader comparing the number on screen against it drew a conclusion about a population the number never described.
Evidence
sampleEvalCases() in src/lib/llm.ts takes all 14 hard cases first and fills the remaining 26 slots by striding the 1,053 seeded lines, so hard cases are 35.00% of the live sample against 1.31% of the full set, an over-weighting of 26.7 times. Applying the group rates measured on 2026-09-05, 64.3% on hard cases and 97.1% elsewhere, the sample is expected near 85.6% while the full set reads 96.3%. An observed run returned 82.5%, made up of 8 of 14 hard cases and 25 of 26 seeded lines, and that seeded figure of 96.2% matches the full-set behaviour. The categoriser was steady across both and the mixture changed.
Disposition
Fixed. The headline tile now reads sample accuracy when a sample is scored and names the hard-case share, the line count states the split and the population it was drawn from, and a panel above states that the threshold governing a deploy is defined over all 1,067 cases. The sampling itself is deliberate and stays as it is, because a demo that hides its hard cases teaches a reader the wrong thing.
F12

2 areas passed the scoping criteria and received no objective

Partly fixed

No control objective covers this

Condition
The scope statement read 12 areas of 22 and listed the 10 excluded areas by name, so a reader took the 12 to be the areas carrying an objective. The library holds 10 objectives. Drift and re-evaluation, and prompt injection and adversarial testing, met the 3 criteria and produced nothing, and the page named neither.
Evidence
UNIVERSE.inScope was stored as the literal 12, reached by subtracting the 10 named exclusions from 22, and the 12 were never enumerated, so no list existed against which the count could be checked. Mapping the checklist onto the library leaves area 13 and area 17 with no objective. CO5 cites the ECB drift expectation in its source line while its test asks only for a threshold, an owner and a consequence. CO8 cites OWASP LLM06, Excessive Agency, which is a different control from prompt injection at LLM01, and no test puts adversarial input through the categoriser or the rationale drafter.
Disposition
Partly fixed. The scope page now enumerates the 12 areas, names the objective each produced, and states what is uncovered for the 2 that produced none. Every count is computed from the lists, and the control-test run reconciles the scope against the library and stops on a mismatch, so a stored number can no longer disagree with the list beneath it. The 2 objectives themselves are still unwritten, which is what would close this.
Management response

Writing an objective for adversarial testing means building the tests behind it, and an objective with no evidence behind it returns an opinion, which is the third scoping criterion this library set for itself. Stating the gap where a reader meets the scope is the honest position until those tests exist.

Closes when. An objective is written for drift and re-evaluation and for prompt injection and adversarial testing, each with a re-performable test behind it, and both appear in the control-test run.

Accepted 2026-09-07 · Quality owner, evaluation harness

F13

CO5 borrows its drift expectation from a guide that does not cover this system

Open

No control objective covers this

Condition
CO5 names the ECB guide to internal models as one of the 2 sources setting its bar. That guide governs internal models used to calculate own funds requirements for credit, market and counterparty credit risk. The categoriser is not such a model and this system calculates no own funds requirement, so the expectation is borrowed by analogy while the source line reads as though it were owed.
Evidence
The June 2026 edition takes its subject from Articles 143, 283 and 325 of Regulation (EU) No 575/2013 and addresses the calculation of own funds requirements for credit, market and counterparty credit risk. Model drift appears in its chapter on overarching principles, where the machine learning section requires a documented monitoring process able to recognise model drift, meaning changes in the model's performance. Nothing in the guide addresses a transaction categoriser feeding an affordability calculation. Read on 2026-09-07 from the published PDF. One correction to an earlier note: footnote 39 of that chapter points to paragraph 63 for the drift expectations, and paragraph 63 of the chapter as published reads on explainability, so the paragraph number is not cited here.
Disposition
Open, and disclosed here rather than on the objective. The reference stays because the expectation it states is the right one to borrow and no closer source sets a bar for a system of this kind. Read CO5 as resting on the BaFin principles of June 2021, which do cover algorithm-based decision-making at a supervised institution, with the ECB guide alongside as an analogy.
Management response

Dropping the reference would leave CO5 on the BaFin principles alone, and those say less about drift than the ECB guide does. Keeping it without saying it is borrowed would let a reader take a capital-model expectation as one this system owes.

Closes when. A source that addresses monitoring for a model of this kind is found and cited in its place, or the borrowing is stated on the objective itself rather than in this register.

Accepted 2026-09-07 · Quality owner, evaluation harness

F14

Half of CO8's source names no document

Open

No control objective covers this

Condition
CO8 cites a BaFin access control expectation and OWASP LLM06. The OWASP half resolves to a published risk. The BaFin half names no document, so a reader cannot reach the bar it claims to set, and criterion 3 asks every objective to name the source that sets its bar.
Evidence
The source field reads 'BaFin access control expectation; OWASP LLM06'. The candidate document is BAIT, and BAIT is being withdrawn: institutions required to run ICT risk management under DORA were taken out of its scope on 17 January 2025, and it is repealed completely with the expiry of 31 December 2026. Neither BAIT nor DORA has been read against this objective, so the claim that a BaFin expectation sets this bar is untested.
Disposition
Open. CO8 is the one failing control in the run, so the objective does its job whatever its source, and the defect is in the citation rather than in the test. Naming BAIT now would date the objective within months.
Management response

Pinning it means reading DORA Article 9 and the technical standards under it to establish whether either reaches a non-human identity holding an API key. That is a reading task rather than a lookup, and citing a source that has not been read would repeat the defect this finding records.

Closes when. A named article of DORA, or of the standards under it, is read against this objective and cited in place of the unnamed BaFin expectation, or the BaFin half is dropped and CO8 rests on OWASP LLM06 alone.

Accepted 2026-09-07 · Quality owner, evaluation harness