Request Isolation Testing: What The Lloyds App Defect Teaches Trading Platforms, And The FCA PS26/2 Incident Reporting Clocks In Code
Lloyds Banking Group's final letter to the Treasury Committee, published on 13 October 2026, traces its 12 March app incident to a concurrency defect introduced during an overnight change: requests made within fractions of a second of one another 'were not always kept separate'. This guide builds request isolation testing for trading platforms in TypeScript: a parallel-customers test, a fail-closed ownership guard, a change-risk gate, and an incident record that computes the FCA PS26/2 clocks that apply from 18 March 2027.
AlchmAI Engineering19 min read
446,915
Maximum customers in Lloyds Banking Group's final count for the 12 March 2026 app incident, plus a further 80,508 joint account holders
4h 40m
Length of the incident window, 03.28 to 08.08 on 12 March 2026, in the bank's letter to the Treasury Committee
£36,500
Approximate further goodwill payments made between 21 April and 31 August 2026. No financial loss or fraud was identified
24 hours
Initial report deadline under FCA PS26/2 from 18 March 2027, from determining the threshold is met (4 hours from detection for payment service providers)
On 17 September 2026 Lloyds Banking Group sent the House of Commons Treasury Committee its final update on the app incident of 12 March 2026, and the Committee published the letter on 13 October 2026. The root cause is a concurrency defect, the class of bug request isolation testing exists to catch: 'When two or more customers requested transaction information within very small fractions of a second of one another, the defect meant those requests were not always kept separate.' The bank calls it 'a software defect introduced during an overnight change' that 'was not identified through the review and testing processes we had in place ahead of the change being made'. The letter puts the scope at 'a maximum of 446,915 customers', plus 'a further 80,508 individuals' who were joint account holders, between 03.28 and 08.08 on 12 March. No financial loss and no fraud were identified. In our reading it is the most useful public root-cause statement a UK bank has made this year, because it names a defect that any broker, prop firm or fintech app serving per-customer data can ship, and that unit tests rarely see.
Reports in March, including The Register's on 27 March 2026 and Computer Weekly's account of the bank admitting a coding fault, used a figure of 447,936. The final letter's maximum is 446,915: read the first as the March count and the second as the final one. Lloyds says it made further goodwill payments of approximately £36,500 between 21 April and 31 August, kept the FCA, PRA and ICO informed, and is making 'enhancements to our controls on how changes are categorised for their potential customer impact, and then how they are reviewed, tested, released and overseen.' From 18 March 2027 the FCA's PS26/2, published alongside final PRA rules, puts reporting clocks on incidents like this one. The letter does not say which component failed, and this guide does not guess. It builds controls for the class of defect: a test that fires many synthetic customers at one endpoint in the same instant, a fail-closed ownership guard in production, a change-risk gate, and an incident record that computes the PS26/2 clocks.
1. What Is Request Isolation Testing, And Why Do Unit Tests Miss It?
A unit test sends one request and checks one answer, so it cannot see a defect that needs two customers to arrive within microseconds of each other. Code review struggles too: the defect is not in any one line of the diff but in how two executions of it interleave. That fits the bank's own account of a change that went through review and testing and failed in production. The common mechanisms, in any stack, are below. They describe the class of defect, not Lloyds' systems.
- Shared mutable state. A singleton field, a static or a module-level variable holds something about the caller, and a second request overwrites it between the first request's await and its next read. It passes every single-request test.
- Cache keys without the principal. A response or query cache keyed on the path and parameters alone serves the second customer the first customer's entry. An edge cache that honours a cacheable header on a per-customer response does the same thing at the network edge.
- Async-context leakage. Request context that is set and never cleared, or that a pooled thread, a connection pool callback or a batching library carries into another request. The code that reads the context is correct; the context it reads is not.
The test that finds these runs a crowd, not a customer. Seed a staging environment with synthetic customers who own disjoint accounts, so any row that is not the caller's is unambiguous. Hold every customer's request at a barrier and release them all in the same event-loop turn, so they reach the service within fractions of a millisecond of each other. Then assert that every returned row belongs to the caller and that no response can be stored by a shared cache. Repeat with a shuffled order, so different neighbours collide, and with mixed endpoints, so state shared between handlers is exercised too.
// Node 22+: node:test, node:assert and the global fetch. Runs against staging, seeded with synthetic
// customers who own disjoint accounts, so any row that is not the caller's is unambiguous.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
interface SyntheticCustomer {
customerId: string;
accountIds: string[];
token: string; // short-lived staging token minted by the seed job
seededRows: number; // rows the seed job wrote for this customer
}
interface TransactionRow {
transactionId: string;
customerId: string;
accountId: string;
amountMinor: number;
bookedAt: string;
}
interface IsolationFailure {
round: number;
caller: string;
path: string;
status: number;
reasons: string[];
foreignRows: Array<{ transactionId: string; owner: string }>; // identifiers only, never amounts
}
function mustEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
const BASE_URL = mustEnv("ISOLATION_BASE_URL");
const FIXTURES = mustEnv("ISOLATION_FIXTURES"); // JSON written by the seed job
const ROUNDS = Number(process.env.ISOLATION_ROUNDS ?? 40);
const PATHS = ["/v1/transactions?limit=50", "/v1/transactions/recent", "/v1/statements/current/lines"];
function shuffle<T>(items: readonly T[]): T[] {
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
async function check(round: number, c: SyntheticCustomer, path: string): Promise<IsolationFailure | null> {
const res = await fetch(new URL(path, BASE_URL), {
headers: { authorization: `Bearer ${c.token}`, "x-isolation-round": String(round) },
});
const fail = (reasons: string[], foreignRows: IsolationFailure["foreignRows"] = []): IsolationFailure =>
({ round, caller: c.customerId, path, status: res.status, reasons, foreignRows });
// A fail-closed ownership guard turns a leak into a 503, so a 5xx here is a leak the guard caught.
if (!res.ok) return fail([`HTTP ${res.status} under concurrent load`]);
const reasons: string[] = [];
const cacheControl = res.headers.get("cache-control") ?? "";
if (!/\b(private|no-store)\b/.test(cacheControl)) reasons.push(`cache-control "${cacheControl}" lets a shared cache store it`);
const { rows } = (await res.json()) as { rows: TransactionRow[] };
const own = new Set(c.accountIds);
const foreign = rows
.filter((r) => r.customerId !== c.customerId || !own.has(r.accountId))
.map((r) => ({ transactionId: r.transactionId, owner: r.customerId }));
if (foreign.length) reasons.push(`${foreign.length} rows owned by another customer`);
if (rows.length === 0 && c.seededRows > 0) reasons.push("empty response for a customer with data");
return reasons.length ? fail(reasons, foreign) : null;
}
/** Every customer waits at a barrier, then all are released in the same event-loop turn. */
async function burst(round: number, customers: SyntheticCustomer[], pathFor: (i: number) => string) {
let release!: () => void;
const barrier = new Promise<void>((resolve) => { release = resolve; });
const calls = customers.map((c, i) => barrier.then(() => check(round, c, pathFor(i))));
release();
const results = await Promise.all(calls);
return results.filter((r): r is IsolationFailure => r !== null);
}
test("synthetic customers in parallel only ever receive their own rows", { timeout: 600_000 }, async () => {
const customers = JSON.parse(await readFile(FIXTURES, "utf8")) as SyntheticCustomer[];
assert.ok(customers.length >= 100, "seed at least 100 synthetic customers");
const failures: IsolationFailure[] = [];
for (let round = 1; round <= ROUNDS; round++) {
const order = shuffle(customers); // different neighbours in every burst
for (const path of PATHS) failures.push(...(await burst(round, order, () => path))); // one endpoint, one instant
failures.push(...(await burst(round, order, (i) => PATHS[i % PATHS.length]))); // mixed endpoints, one instant
}
const leaks = failures.filter((f) => f.foreignRows.length > 0).length;
assert.equal(failures.length, 0,
`${failures.length} isolation failures (${leaks} with foreign rows). First five: ${JSON.stringify(failures.slice(0, 5))}`);
});- Prove the harness can fail. Once, run it against a build with a planted module-level 'current customer' variable and confirm it reports foreign rows. A concurrency test that has never failed has not shown that it can see the bug.
- Scale the crowd until staging latency under the test matches your production peak. The interleavings that matter appear when the service is busy, and a quiet release window is exactly when it is not.
- Run it in CI for every change the gate in section 2 marks Class A, and nightly with a larger crowd and more rounds.
2. How Do You Stop A Leaked Row Reaching A Customer In Production?
Tests lower the odds; they do not prove the defect is absent. The production control is an ownership guard that checks every customer-owned row against the authenticated caller before the response is serialised, and sends nothing if one row fails. The guard takes identity from the request object, while data access reads the caller from the async context. Those are two independent paths to the same answer, and the guard exists for the moment they disagree.
In Fastify 5 the guard is three hooks. onRequest authenticates and honours a per-route kill switch. preHandler binds the principal with AsyncLocalStorage, in callback style, so the handler and everything it awaits run inside that context. preSerialization checks the rows of any route that declares an ownedRows field and forces a private, no-store cache-control header. On a mismatch the guard records the breach with identifiers only, returns a 503, and trips the route's kill switch after a set number of breaches. A 503 is a bad morning for customers. Another customer's transactions on screen is a reportable incident.
// fastify 5.x, Node 22+. Identity is request-scoped, cache keys carry the principal, and every
// customer-owned row is checked against the caller before the response is serialised.
import { AsyncLocalStorage } from "node:async_hooks";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
export interface Principal {
customerId: string;
accountIds: ReadonlySet<string>;
requestId: string;
}
declare module "fastify" {
interface FastifyRequest { principal: Principal | null }
interface FastifyContextConfig { ownedRows?: string } // payload field that holds customer-owned rows
}
const requestContext = new AsyncLocalStorage<Principal>();
/** Data access learns who is asking from here, never from a module-level variable. No principal, no query. */
export function currentPrincipal(): Principal {
const p = requestContext.getStore();
if (!p) throw new Error("no principal in async context: refusing to touch customer data");
return p;
}
/** The only cache-key builder for customer data, so a key without the principal cannot be written. */
export function principalCacheKey(resource: string, params: Record<string, string | number> = {}): string {
const query = Object.keys(params).sort().map((k) => `${k}=${encodeURIComponent(String(params[k]))}`).join("&");
return `cust:${currentPrincipal().customerId}:${resource}?${query}`;
}
export interface Breach {
at: string; requestId: string; route: string; caller: string;
foreignRows: Array<{ id: string; owner: string }>; // identifiers only: the breach log must not become a second leak
}
export interface GuardDeps {
authenticate(request: FastifyRequest): Promise<{ customerId: string; accountIds: string[] } | null>;
recordBreach(b: Breach): Promise<void>; // append-only store; also pages the on-call engineer
killSwitch: { isOn(route: string): Promise<boolean>; trip(route: string, why: string): Promise<void> };
tripAfterBreaches: number; // per route, per process
}
interface OwnedRow { transactionId?: string; customerId?: unknown; accountId?: unknown }
export function registerOwnershipGuard(app: FastifyInstance, deps: GuardDeps): void {
const breaches = new Map<string, number>();
app.decorateRequest("principal", null);
app.addHook("onRequest", async (request, reply) => {
const route = request.routeOptions.url ?? "unmatched";
if (await deps.killSwitch.isOn(route)) return reply.code(503).send({ error: "temporarily_unavailable" });
const who = await deps.authenticate(request);
request.principal = who && { customerId: who.customerId, accountIds: new Set(who.accountIds), requestId: request.id };
});
// Callback style on purpose: the handler is invoked inside run(), so everything it awaits sees this principal.
app.addHook("preHandler", (request, reply, done) => {
if (!request.principal) {
if (request.routeOptions.config.ownedRows) { reply.code(401).send({ error: "unauthenticated" }); return; }
done();
return;
}
requestContext.run(request.principal, () => done());
});
app.addHook("preSerialization", async (request, reply, payload: unknown) => {
const field = request.routeOptions.config.ownedRows;
if (!field) return payload;
reply.header("cache-control", "private, no-store");
// Compare with the request object, not the async context: if the two disagree, that is the leak.
const p = request.principal;
const rows = payload && typeof payload === "object" ? (payload as Record<string, unknown>)[field] : undefined;
if (!p || !Array.isArray(rows)) return block(request, reply, [{ id: "n/a", owner: "unexpected payload shape" }]);
const foreign = (rows as OwnedRow[])
.filter((r) => r.customerId !== p.customerId || typeof r.accountId !== "string" || !p.accountIds.has(r.accountId))
.map((r) => ({ id: String(r.transactionId ?? "unknown"), owner: String(r.customerId) }));
return foreign.length ? block(request, reply, foreign) : payload;
});
// Fail closed: nothing from the payload is sent. If recordBreach throws, Fastify's error handler sends a 500 instead.
async function block(request: FastifyRequest, reply: FastifyReply, foreignRows: Breach["foreignRows"]) {
const route = request.routeOptions.url ?? "unmatched";
await deps.recordBreach({
at: new Date().toISOString(), requestId: request.id, route,
caller: request.principal?.customerId ?? "none", foreignRows,
});
const count = (breaches.get(route) ?? 0) + 1;
breaches.set(route, count);
if (count >= deps.tripAfterBreaches) await deps.killSwitch.trip(route, `${count} ownership breaches`);
reply.code(503);
return { error: "temporarily_unavailable", requestId: request.id };
}
}
/** Data access takes the caller from the context, so a handler cannot pass the wrong customer ID. */
export async function recentTransactions(db: { query<T>(sql: string, args: unknown[]): Promise<T[]> }, limit = 50) {
const { customerId } = currentPrincipal();
return db.query<OwnedRow>(
'SELECT transaction_id AS "transactionId", customer_id AS "customerId", account_id AS "accountId", ' +
'amount_minor AS "amountMinor", booked_at AS "bookedAt" FROM transactions ' +
"WHERE customer_id = $1 ORDER BY booked_at DESC LIMIT $2",
[customerId, limit],
);
}
// Route declaration: app.get("/v1/transactions", { config: { ownedRows: "rows" } }, async () => ({ rows: await recentTransactions(db) }));The same path serves AI features. An assistant that summarises a customer's transactions or explains a margin call reads through the same repository, and AI controls for financial services start with the model only ever seeing the caller's own rows. Put the guard in front of the tool or retrieval call that feeds the model, not only in front of the HTTP route the client sees.
The Change-Risk Gate In Front Of The Release
Lloyds' list of fixes starts with how changes are 'categorised for their potential customer impact'. In a pipeline that is a gate which classifies each change from the files and configuration it touches, not from its title, and attaches requirements to each class.
- Class A, customer-data concurrency: caching, session or authentication middleware, connection or thread pools, async context, serialisers, or any query that returns customer-owned rows. Needs the parallel-customers test at production-like load, a reviewer who owns the component, a staged release, and someone watching the guard's breach counter through the first traffic peak after release.
- Class B, customer-facing but stateless: interface, copy and reference data. Normal tests and review.
- Class C, no customer data path. The normal pipeline.
- Record the class on the change record, so an incident record can link to it and a final report can say which tests ran before release.
3. What Does FCA PS26/2 Require When An Isolation Failure Reaches Customers?
PS26/2, the FCA's policy statement on operational incident and third-party reporting, applies from 18 March 2027, and the PRA published its final rules alongside it. As TLT summarises the timelines: an initial report within 24 hours of determining that the reporting threshold is met, or within 4 hours of detection for payment service providers; a final report within 30 working days of resolution, including the root cause; and, under data protection law, personal data breaches reported to the ICO within 72 hours. Those clocks run from timestamps that many incident tools do not hold as fields: when the threshold decision was made, when the firm became aware of a personal data breach, and when the incident was resolved.
The Lloyds figures show a second requirement. A count of affected customers moves as the investigation goes on: 447,936 in March reporting, a maximum of 446,915 in the September letter. An incident record should hold every estimate with its time, its stage and its method, so the final report can reconcile them without anyone rebuilding a spreadsheet. The record below computes each clock, marks it met, late or open, and lists what blocks the final report: a missing root cause, no final count, or a root cause not linked to the change record from section 2.
// Node 22+. Clocks as TLT summarises the FCA and PRA final rules (FCA PS26/2), in force from 18 March 2027.
// Descriptive engineering, not legal advice: confirm thresholds, clocks and day counting with compliance.
type Regulator = "FCA" | "PRA";
export interface CountEstimate {
at: string; // ISO instant the figure was produced
stage: "initial" | "update" | "final";
customers: number; // stated as a maximum, not a point estimate
otherIndividuals?: number; // e.g. joint account holders
method: string; // how it was counted, so the next figure can be reconciled
}
export interface OperationalIncident {
id: string;
summary: string;
regulators: Regulator[];
isPaymentServiceProvider: boolean;
detectedAt: string;
thresholdMetAt: string | null; // when the firm determined the reporting threshold was met
resolvedAt: string | null;
personalDataBreach: boolean;
awareOfBreachAt: string | null;
impactWindow: { from: string; to: string } | null;
linkedChange: { changeId: string; riskClass: "A" | "B" | "C"; testsRun: string[] } | null;
estimates: CountEstimate[];
rootCause: string | null;
notifications: Array<{ to: Regulator | "ICO"; kind: "initial" | "final" | "breach"; sentAt: string; ref: string }>;
}
export interface Deadline {
obligation: string;
due: string; // ISO instant, or a Europe/London date meaning the end of that day
basis: string;
status: "met" | "late" | "open" | "not started";
blockers: string[];
}
const HOUR = 3_600_000;
const londonDate = (iso: string): string =>
new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/London", year: "numeric", month: "2-digit", day: "2-digit" })
.format(new Date(iso));
/** Bank holidays from the GOV.UK feed; pick the division that matches the firm's working calendar. */
export async function loadBankHolidays(division = "england-and-wales"): Promise<Set<string>> {
const res = await fetch("https://www.gov.uk/bank-holidays.json");
if (!res.ok) throw new Error(`GOV.UK bank holidays: HTTP ${res.status}`);
const feed = (await res.json()) as Record<string, { events: Array<{ date: string }> }>;
const events = feed[division]?.events;
if (!events) throw new Error(`unknown division ${division}`);
return new Set(events.map((e) => e.date));
}
/** Counts from the day after fromDate, skipping weekends and bank holidays. Refuses to guess past the feed. */
export function addWorkingDays(fromDate: string, days: number, holidays: ReadonlySet<string>): string {
const lastYearCovered = Math.max(...[...holidays].map((d) => Number(d.slice(0, 4))));
const d = new Date(`${fromDate}T12:00:00Z`);
for (let left = days; left > 0; ) {
d.setUTCDate(d.getUTCDate() + 1);
if (d.getUTCFullYear() > lastYearCovered) throw new Error(`bank holiday feed ends in ${lastYearCovered}`);
const iso = d.toISOString().slice(0, 10);
if (d.getUTCDay() !== 0 && d.getUTCDay() !== 6 && !holidays.has(iso)) left--;
}
return d.toISOString().slice(0, 10);
}
function statusFor(due: string, sentAt: string | undefined, now: Date): Deadline["status"] {
const isDate = due.length === 10;
if (sentAt) return (isDate ? londonDate(sentAt) <= due : Date.parse(sentAt) <= Date.parse(due)) ? "met" : "late";
const passed = isDate ? londonDate(now.toISOString()) > due : now.getTime() > Date.parse(due);
return passed ? "late" : "open";
}
export function computeDeadlines(i: OperationalIncident, holidays: ReadonlySet<string>, now = new Date()): Deadline[] {
const firstSent = (to: Regulator | "ICO", kind: string) =>
i.notifications.filter((n) => n.to === to && n.kind === kind).map((n) => n.sentAt).sort()[0];
const plusHours = (iso: string, h: number) => new Date(Date.parse(iso) + h * HOUR).toISOString();
const out: Deadline[] = [];
for (const reg of i.regulators) {
// Initial report: payment service providers 4 hours from detection; others 24 hours from the threshold decision.
if (i.isPaymentServiceProvider) {
const due = plusHours(i.detectedAt, 4);
out.push({ obligation: `${reg} initial report`, due, basis: "4 hours from detection (payment service provider)",
status: statusFor(due, firstSent(reg, "initial"), now), blockers: [] });
} else if (i.thresholdMetAt) {
const due = plusHours(i.thresholdMetAt, 24);
out.push({ obligation: `${reg} initial report`, due, basis: "24 hours from determining the threshold is met",
status: statusFor(due, firstSent(reg, "initial"), now), blockers: [] });
} else {
out.push({ obligation: `${reg} initial report`, due: "", basis: "threshold not yet assessed",
status: "not started", blockers: ["record when, and by whom, the threshold decision was made"] });
}
// Final report: 30 working days from resolution, and it must carry the root cause.
const blockers: string[] = [];
if (!i.rootCause) blockers.push("root cause not written");
if (!i.estimates.some((e) => e.stage === "final")) blockers.push("no final affected-customer count");
if (i.rootCause && !i.linkedChange) blockers.push("root cause not linked to a change record");
if (i.resolvedAt) {
const due = addWorkingDays(londonDate(i.resolvedAt), 30, holidays);
out.push({ obligation: `${reg} final report`, due, basis: "30 working days from resolution, end of London day",
status: statusFor(due, firstSent(reg, "final"), now), blockers });
} else {
out.push({ obligation: `${reg} final report`, due: "", basis: "clock starts at resolution", status: "not started", blockers });
}
}
if (i.personalDataBreach) {
const aware = i.awareOfBreachAt;
const due = aware ? plusHours(aware, 72) : "";
out.push({ obligation: "ICO personal data breach report", due, basis: "72 hours from becoming aware",
status: aware ? statusFor(due, firstSent("ICO", "breach"), now) : "not started",
blockers: aware ? [] : ["record when the firm became aware of the breach"] });
}
return out;
}
/** Every revision of the count with its delta, so the final report can explain why the number moved. */
export function countHistory(i: OperationalIncident): string[] {
const sorted = [...i.estimates].sort((a, b) => a.at.localeCompare(b.at));
return sorted.map((e, n) => {
const prev = n > 0 ? sorted[n - 1] : undefined;
const diff = prev ? e.customers - prev.customers : 0;
const delta = prev ? `, ${diff >= 0 ? "+" : ""}${diff} on the previous figure` : "";
const others = e.otherIndividuals ? ` and ${e.otherIndividuals} other individuals` : "";
return `${e.at} ${e.stage}: up to ${e.customers} customers${others} (${e.method})${delta}`;
});
}- Working days come from the GOV.UK bank holidays feed, and the calculator throws rather than guess past the last year the feed covers. It counts from the day after resolution; agree that reading with your compliance lead.
- Let the ownership guard's breach log open an incident record automatically, with detectedAt set from the first breach. The clocks then start from a machine timestamp, not from the moment someone noticed a dashboard.
- This is descriptive engineering, not legal advice. Thresholds, clocks and who reports to whom come from PS26/2 and the PRA rules, read with your compliance team.
“A concurrency defect is not found by reading the diff. It is found by making many customers ask at once, and checking every answer.”
Where This Belongs: Brokers, Prop Firms, Fintech Teams And Risk Dashboards
- 01Brokers and CFD providers: positions, transaction history, statements and margin alerts are all customer-owned rows. Put the ownership guard on every account endpoint and on per-account WebSocket channels, where the subscription key must include the principal.
- 02Prop trading firms: trader dashboards, evaluation results and payout statements are per-trader data on a shared platform. Leaderboards are shared on purpose; declare them as public routes with no ownedRows field rather than leaving them unguarded by accident.
- 03Fintech and trading startups: add the ownedRows declaration and the guard with the first endpoint. Moving a codebase that passes customer IDs by hand onto request-scoped identity later is a rewrite, and due diligence will ask how a cross-customer leak would be detected.
- 04Risk and compliance teams: show the breach counter, open incident records and their PS26/2 clocks on the same risk and surveillance dashboard, so the timestamps the rules count from are visible to the person who has to file.
The Bottom Line
Request isolation testing is the control the Lloyds Banking Group letter of 17 September 2026 points to without naming it. A defect meant that requests made 'within very small fractions of a second of one another' were 'not always kept separate', it passed the review and testing in place before an overnight change, and the letter puts the scope at a maximum of 446,915 customers plus 80,508 joint account holders. Build four things: request-scoped identity with principal-keyed caches; a parallel-customers test that runs on every Class A change; a fail-closed ownership guard in production; and an incident record whose clocks follow PS26/2 from 18 March 2027. That is the trading workflow architecture AlchmAI, a London trading technology and AI studio, builds into broker, prop firm and fintech platforms, and it turns the next concurrency defect into a 503 and a breach log entry rather than a letter to a select committee.
References & Further Reading
- UK Parliament, Treasury Committee - Correspondence from Lloyd's Banking Group to the Chair relating to an update on the March 2026 IT incident, dated 17 September 2026 (published 13 October 2026). committees.parliament.uk/publications/55167/documents/306788/default
- UK Parliament - Committees API, publication 55167 record. committees-api.parliament.uk/api/Publications/55167
- The Register - Lloyds app glitch exposed transactions to almost 500k users (27 March 2026). theregister.com/software/2026/03/27/lloyds-app-glitch-exposed-transactions-to-almost-500k-users/5221640
- Computer Weekly - Lloyds admits coding fault exposed customer transactions. computerweekly.com/news/366640568/Lloyds-admits-coding-fault-exposed-customer-transactions
- FCA - PS26/2: Operational incident and third-party reporting. fca.org.uk/publications/policy-statements/ps26-2-operational-incident-third-party-reporting
- TLT - FCA and PRA publish final rules on operational incident reporting: firms have to be ready by 18 March 2027. tlt.com/insights-and-events/insight/fca-and-pra-publish-final-rules-on-operational-incident-reporting-firms-have-to-ready-by-18-march-2027
- GOV.UK - UK bank holidays. gov.uk/bank-holidays
- Node.js - Asynchronous context tracking (AsyncLocalStorage). nodejs.org/api/async_context.html
- Node.js - Test runner. nodejs.org/api/test.html
- Fastify - Hooks reference. fastify.dev/docs/latest/Reference/Hooks
- AlchmAI - Brokers and CFD providers. alchmai.com/solutions/broker-trading-platforms
- AlchmAI - Risk and surveillance dashboards. alchmai.com/solutions/risk-and-surveillance-dashboards
AlchmAI Engineering
Engineering, London
Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.
Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information