Skip to content
Deployment & Production

Agentic AI UK Regulation Starts At The ICO: The Agent Ledger, Purpose-Scoped Memory And Shutdown Drill To Build Before 20 November

On 8 October 2026 the ICO opened a call for evidence on agentic AI, closing on 20 November, to shape its statutory code of practice on AI and automated decision-making. US and euro-zone supervisors are asking banks the same questions about tracking, limits and shutdown. This guide builds the answers for a UK broker, prop firm or fintech: a hash-chained agent action ledger, purpose-scoped memory enforced by the database, and a graded shutdown drill that writes its own evidence report.

AlchmAI Engineering28 min read

20 Nov

Closing date of the ICO's call for evidence on agentic AI, opened on 8 October 2026 to inform its statutory code of practice on AI and automated decision-making

63%

Of the 57 firms in the Bank of England's 2026 H2 Systemic Risk Survey named AI among their top five risks to the UK financial system, up 32 percentage points

10

Foundation model developers, including Amazon, Anthropic, Google, Meta, Microsoft and OpenAI, that made or committed to data protection changes after ICO scrutiny

3

US bank regulators, the Fed, the FDIC and the OCC, reported by Reuters on 13 October to be asking banks whether they can track, limit and shut down AI agents

Agentic AI UK regulation now has a date on it. On 8 October 2026 the Information Commissioner's Office (ICO) opened a call for evidence on agentic AI, closing on 20 November 2026, and said the responses will support its 'forthcoming statutory code of practice on AI and automated decision-making'. The same day the ICO said it had made enquiries with OpenAI, Anthropic, Meta and the UK AI Security Institute about recent agent testing and deployment in which some agents reportedly bypassed protections, used unauthorised communication channels and accessed external systems such as Hugging Face. For a UK broker, prop firm or fintech running AI agents near client data, the practical meaning is plain: how you track what an agent did, what it remembers and how you stop it is about to be written into a statutory code, and the ICO is asking now what is workable.

Richard Nevinson, the ICO's Director of Technology Regulation, put it in one line: 'the fact AI agents act with autonomy is not an excuse for poor compliance.' The Bank of England has framed the stability side. The Financial Policy Committee's record of its 25 September meeting, published on 30 September 2026, says frontier AI test incidents in 2026 Q3 showed that 'increasingly autonomous models could take unexpected actions, including exploiting vulnerabilities and accessing systems beyond their intended task'. In the Bank's 2026 H2 Systemic Risk Survey, published the same day, 63% of the 57 responding firms named AI among their top five risks to the UK financial system, up 32 percentage points on the 2026 H1 survey, with participants pointing to the pace of change 'alongside lack of controls and governance'.

Then on 12 October 2026 the government, in a release from the Cabinet Office, the AI Security Institute, the National Cyber Security Centre (NCSC) and DCMS, said it would consider 'how serious AI incidents and near misses can be identified and reported' through the Cyber Security and Resilience Bill regime, and warned that 'a major incident involving AI agents could play out quicker and wider than other major incidents'. Our view: the ICO is asking the right engineering questions, and firms that answer with working evidence will help shape a code they can actually run. This guide builds that evidence in Python on PostgreSQL. It is engineering guidance, not legal advice.

1. How Do You Track Every Action An AI Agent Takes?

Start with accountability, because every other section of the call leans on it. The ICO's draft guidance on automated decision-making, updated on 31 March 2026 for the Data (Use and Access) Act 2025, says organisations should keep a record of how a human reviewed a decision and use a system that can 'deliver an audit trail showing the key decision points'. Article 22C of the UK GDPR requires four safeguards for significant decisions based solely on automated processing: information about the decision, a way to make representations, human intervention, and a way to contest it. An agent makes dozens of small decisions per task, so the audit trail belongs at the tool boundary, where the agent touches data and systems.

Each incident the ICO described maps to a column. Unauthorised channels and external systems become an external system field, checked against an allowlist for each purpose. Bypassed protections become a kill switch the agent can read but never write, checked before every call. The ledger below writes a start row before each tool call and an end row after it, or one refused row, each hash-chained to the one before. The lawful basis comes from the firm's record of processing activities (ROPA), never from the model. Arguments and results are stored as digests, so the ledger is not a second copy of client data. And the database refuses to record the start of a solely automated significant decision with no contest channel, so that call cannot run even if the Python checks are bypassed.

pythonledger/agent_ledger.py
"""Agent action ledger and tool middleware.

Every tool call is written to an append-only, hash-chained table: a 'start'
row before the tool runs and an 'end' row after it, or one 'refused' row when
a control stops it. Arguments and results are stored as SHA-256 digests, so
the ledger never becomes a second copy of client data.

Targets Python 3.12, psycopg 3.3 and PostgreSQL 15 or later.
"""
from __future__ import annotations

import functools
import hashlib
import json
import os
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable

import psycopg

GENESIS = "0" * 64
LEDGER_LOCK = 0x4C444752  # advisory lock key: one writer extends the chain at a time

DDL = """
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'agent_runtime') THEN
    CREATE ROLE agent_runtime NOLOGIN;   -- agents: read controls, append to the ledger
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'agent_ops') THEN
    CREATE ROLE agent_ops NOLOGIN;       -- people and the drill: set and clear controls
  END IF;
END $$;
CREATE TABLE IF NOT EXISTS agent_ledger (
  seq              bigserial   PRIMARY KEY,
  call_id          uuid        NOT NULL,
  phase            text        NOT NULL CHECK (phase IN ('start', 'end', 'refused')),
  at               timestamptz NOT NULL,
  agent_id         text        NOT NULL,
  agent_version    text        NOT NULL,
  principal_id     text        NOT NULL,
  purpose          text        NOT NULL,
  lawful_basis     text        NOT NULL,
  data_categories  text[]      NOT NULL,
  tool             text        NOT NULL,
  external_system  text,
  significant      boolean     NOT NULL,
  solely_automated boolean     NOT NULL,
  human_reviewer   text,
  contest_channel  text,
  body             text        NOT NULL,
  prev_hash        text        NOT NULL,
  entry_hash       text        NOT NULL UNIQUE,
  -- UK GDPR Article 22C: a solely automated significant decision needs a way to contest it.
  CONSTRAINT contest_channel_present CHECK
    (phase = 'refused' OR NOT (significant AND solely_automated) OR contest_channel IS NOT NULL),
  CONSTRAINT reviewer_present CHECK
    (phase = 'refused' OR NOT significant OR solely_automated OR human_reviewer IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS agent_ledger_call ON agent_ledger (call_id);
CREATE INDEX IF NOT EXISTS agent_ledger_principal ON agent_ledger (principal_id, tool, at);
CREATE OR REPLACE FUNCTION agent_ledger_refuse_rewrite() RETURNS trigger
  LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'agent_ledger is append-only'; END $$;
CREATE OR REPLACE TRIGGER agent_ledger_no_rewrite BEFORE UPDATE OR DELETE ON agent_ledger
  FOR EACH ROW EXECUTE FUNCTION agent_ledger_refuse_rewrite();
CREATE OR REPLACE TRIGGER agent_ledger_no_truncate BEFORE TRUNCATE ON agent_ledger
  FOR EACH STATEMENT EXECUTE FUNCTION agent_ledger_refuse_rewrite();

-- The kill switch lives outside the agent's reach: agents can read it, never write it.
CREATE TABLE IF NOT EXISTS agent_controls (
  id         bigserial   PRIMARY KEY,
  scope      text        NOT NULL CHECK (scope IN ('tool', 'agent', 'fleet')),
  target     text        NOT NULL,
  reason     text        NOT NULL,
  set_by     text        NOT NULL,
  set_at     timestamptz NOT NULL DEFAULT now(),
  cleared_at timestamptz,
  active     boolean     GENERATED ALWAYS AS (cleared_at IS NULL) STORED
);
-- Calls still open when a drill or incident drains the fleet go to a person, not a retry queue.
CREATE TABLE IF NOT EXISTS ops_handover (
  call_id      uuid        PRIMARY KEY,
  agent_id     text        NOT NULL,
  tool         text        NOT NULL,
  principal_id text        NOT NULL,
  started_at   timestamptz NOT NULL,
  reason       text        NOT NULL,
  picked_up_by text
);
GRANT SELECT, INSERT ON agent_ledger TO agent_runtime, agent_ops;
GRANT SELECT, INSERT, UPDATE ON ops_handover TO agent_ops;
GRANT USAGE ON SEQUENCE agent_ledger_seq_seq TO agent_runtime, agent_ops;
GRANT SELECT ON agent_controls TO agent_runtime;
GRANT SELECT, INSERT, UPDATE ON agent_controls TO agent_ops;
GRANT USAGE ON SEQUENCE agent_controls_id_seq TO agent_ops;
"""

# Mirrors the firm's record of processing activities (ROPA). The model never
# chooses its own lawful basis, data categories or reachable systems.
PURPOSES: dict[str, dict[str, Any]] = {
    "complaint_triage": {
        "lawful_basis": "legal_obligation",
        "categories": {"contact", "account", "transaction"},
        "external_systems": {"crm.internal", "notify.internal"},
        "memory_ttl": timedelta(days=30),
    },
    "margin_call_notice": {
        "lawful_basis": "contract",
        "categories": {"contact", "account", "position"},
        "external_systems": {"risk.internal", "notify.internal"},
        "memory_ttl": timedelta(days=2),
    },
}


@dataclass(frozen=True)
class ToolSpec:
    name: str
    external_system: str | None
    data_categories: frozenset[str]
    significant: bool = False       # legal or similarly significant effect on a person
    needs_approval: bool = False    # a named person approves before it runs
    daily_limit_per_principal: int = 50


@dataclass(frozen=True)
class CallContext:
    agent_id: str
    agent_version: str              # SHA-256 of the manifest: model id, system prompt, tool list
    principal_id: str               # the client or account the action is for
    purpose: str
    solely_automated: bool
    human_reviewer: str | None = None
    contest_channel: str | None = None


class ToolRefused(Exception):
    pass


def digest(value: Any) -> str:
    return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()


def blocked_reason(conn: psycopg.Connection, agent_id: str, tool: str) -> str | None:
    """The graded kill switch, checked before every call. Widest control wins."""
    row = conn.execute(
        """SELECT scope, target, reason FROM agent_controls
           WHERE active AND (scope = 'fleet'
                 OR (scope = 'agent' AND target = %s) OR (scope = 'tool' AND target = %s))
           ORDER BY CASE scope WHEN 'fleet' THEN 0 WHEN 'agent' THEN 1 ELSE 2 END, id
           LIMIT 1""",
        (agent_id, tool),
    ).fetchone()
    return f"kill switch: {row[0]} {row[1]} ({row[2]})" if row else None


class Ledger:
    def __init__(self, dsn: str | None = None) -> None:
        self.conn = psycopg.connect(dsn or os.environ["LEDGER_DSN"], autocommit=True)

    def migrate(self) -> None:
        self.conn.execute(DDL)

    def append(self, call_id: uuid.UUID, phase: str, ctx: CallContext, spec: ToolSpec,
               detail: dict[str, Any]) -> str:
        now = datetime.now(timezone.utc)
        policy = PURPOSES.get(ctx.purpose, {})
        record = {
            "call_id": str(call_id), "phase": phase, "at": now.isoformat(),
            "agent_id": ctx.agent_id, "agent_version": ctx.agent_version,
            "principal_id": ctx.principal_id, "purpose": ctx.purpose,
            "lawful_basis": policy.get("lawful_basis", "none_declared"),
            "data_categories": sorted(spec.data_categories), "tool": spec.name,
            "external_system": spec.external_system, "significant": spec.significant,
            "solely_automated": ctx.solely_automated, "human_reviewer": ctx.human_reviewer,
            "contest_channel": ctx.contest_channel, "detail": detail,
        }
        body = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)
        with self.conn.transaction():
            self.conn.execute("SELECT pg_advisory_xact_lock(%s)", (LEDGER_LOCK,))
            last = self.conn.execute(
                "SELECT entry_hash FROM agent_ledger ORDER BY seq DESC LIMIT 1").fetchone()
            prev = last[0] if last else GENESIS
            entry_hash = hashlib.sha256((prev + body).encode()).hexdigest()
            self.conn.execute(
                """INSERT INTO agent_ledger (call_id, phase, at, agent_id, agent_version,
                       principal_id, purpose, lawful_basis, data_categories, tool,
                       external_system, significant, solely_automated, human_reviewer,
                       contest_channel, body, prev_hash, entry_hash)
                   VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
                (call_id, phase, now, ctx.agent_id, ctx.agent_version, ctx.principal_id,
                 ctx.purpose, record["lawful_basis"], record["data_categories"], spec.name,
                 spec.external_system, spec.significant, ctx.solely_automated,
                 ctx.human_reviewer, ctx.contest_channel, body, prev, entry_hash),
            )
        return entry_hash

    def verify_chain(self) -> dict[str, Any]:
        prev, checked = GENESIS, 0
        with self.conn.transaction(), self.conn.cursor(name="verify_chain") as cur:
            cur.execute("SELECT seq, body, prev_hash, entry_hash FROM agent_ledger ORDER BY seq")
            for seq, body, prev_hash, entry_hash in cur:
                if prev_hash != prev or hashlib.sha256((prev + body).encode()).hexdigest() != entry_hash:
                    return {"intact": False, "first_bad_seq": seq, "checked": checked}
                prev, checked = entry_hash, checked + 1
        return {"intact": True, "checked": checked, "head": prev}


def preflight(ctx: CallContext, spec: ToolSpec, ledger: Ledger) -> str | None:
    """Return a refusal reason, or None if the call may run. Kill switch first, always."""
    if reason := blocked_reason(ledger.conn, ctx.agent_id, spec.name):
        return reason
    policy = PURPOSES.get(ctx.purpose)
    if policy is None:
        return f"purpose {ctx.purpose!r} is not in the record of processing"
    if extra := spec.data_categories - policy["categories"]:
        return f"tool touches {sorted(extra)}, outside purpose {ctx.purpose}"
    if spec.external_system and spec.external_system not in policy["external_systems"]:
        return f"{spec.external_system} is not an approved system for {ctx.purpose}"
    if spec.needs_approval and not ctx.human_reviewer:
        return "approval required and no named reviewer"
    if spec.significant and ctx.solely_automated and not ctx.contest_channel:
        return "solely automated significant decision with no contest channel"
    used = ledger.conn.execute(
        """SELECT count(*) FROM agent_ledger WHERE principal_id = %s AND tool = %s
           AND phase = 'start' AND at > now() - interval '1 day'""",
        (ctx.principal_id, spec.name),
    ).fetchone()[0]
    if used >= spec.daily_limit_per_principal:
        return f"daily limit of {spec.daily_limit_per_principal} reached for {spec.name}"
    return None


def ledgered(spec: ToolSpec, ledger: Ledger) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
        @functools.wraps(fn)
        def inner(ctx: CallContext, **kwargs: Any) -> Any:
            call_id = uuid.uuid4()
            if reason := preflight(ctx, spec, ledger):
                ledger.append(call_id, "refused", ctx, spec, {"reason": reason, "args": digest(kwargs)})
                raise ToolRefused(reason)
            ledger.append(call_id, "start", ctx, spec, {"args": digest(kwargs)})
            started = time.monotonic()
            try:
                result = fn(ctx, **kwargs)
            except Exception as exc:
                ledger.append(call_id, "end", ctx, spec, {
                    "outcome": "error", "error": type(exc).__name__,
                    "ms": round((time.monotonic() - started) * 1000)})
                raise
            ledger.append(call_id, "end", ctx, spec, {
                "outcome": "ok", "result": digest(result),
                "ms": round((time.monotonic() - started) * 1000)})
            return result
        return inner
    return wrap


def client_messaging_tool(ledger: Ledger, send: Callable[..., dict[str, str]]) -> Callable[..., Any]:
    """The only path by which an agent can message a client: kill switch, purpose,
    named approver, three messages per client per day, and a ledger entry each way."""
    spec = ToolSpec("send_client_message", "notify.internal", frozenset({"contact", "account"}),
                    needs_approval=True, daily_limit_per_principal=3)
    return ledgered(spec, ledger)(lambda ctx, **kwargs: send(**kwargs))
  • The agent version is a SHA-256 of the manifest (model ID, system prompt, tool list), not a version number someone forgets to bump. When a supplier changes a model underneath you, the ledger shows the day it happened.
  • A database owner can disable a trigger. Publish the chain head every day to write-once storage outside the database, and a rewrite becomes detectable by anyone with yesterday's head: edit one row with the trigger off and verify_chain reports the first altered sequence number.
  • Refused rows are the most valuable rows you have. They prove a control fired, which is the first thing a supervisor asks to see. The ledger names clients, so give it its own retention period and ROPA entry.

2. How Do You Stop Agent Memory Breaking Purpose Limitation?

Purpose limitation is the data protection principle that data collected for one purpose is not reused for an incompatible one, and the ICO has given it a section of the call, alongside fairness. Agent memory is where agents break it without anyone noticing. A complaints agent learns on Monday that a client is struggling to meet margin calls. On Thursday the same agent, now drafting a margin call notice, recalls it. Nobody decided to reuse that data; a similarity search did. Memory with no expiry is also indefinite retention under another name.

Do not enforce purpose in the prompt. A system prompt that says 'only use complaint data for complaints' is a request. A PostgreSQL row-level security policy is a control: the agent's database role can only see rows whose purpose and client match the scope the runtime declares for that transaction, and expired rows disappear from its view the moment they expire. FORCE ROW LEVEL SECURITY binds even the table owner; superusers and BYPASSRLS roles are never bound, so the agent never connects as either. The scope is set per transaction, so it cannot leak across pooled connections, and the runtime sets it from the call context: the model never gets a SQL tool.

pythonmemory/purpose_memory.py
"""Purpose-scoped agent memory, enforced by PostgreSQL row-level security.

Each memory row carries its purpose, principal, data categories and expiry.
The agent_runtime role only ever sees rows whose purpose and principal match
the scope declared for the current transaction, so no prompt can talk the
agent into another purpose's memory: the database never returns those rows.

Targets Python 3.12, psycopg 3.3 and PostgreSQL 15 or later.
"""
from __future__ import annotations

import os
import uuid
from typing import Any, Iterable

import psycopg

from ledger.agent_ledger import PURPOSES, CallContext, Ledger, ToolSpec, digest

DDL = """
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'memory_janitor') THEN
    CREATE ROLE memory_janitor NOLOGIN;  -- nightly job: deletes expired rows, reads nothing else
  END IF;
END $$;
CREATE TABLE IF NOT EXISTS agent_memory (
  id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  purpose         text        NOT NULL,
  principal_id    text        NOT NULL,
  agent_id        text        NOT NULL,
  data_categories text[]      NOT NULL,
  content         text        NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  expires_at      timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS agent_memory_scope ON agent_memory (purpose, principal_id, expires_at);
ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_memory FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS agent_same_scope ON agent_memory;
CREATE POLICY agent_same_scope ON agent_memory FOR ALL TO agent_runtime
  USING (purpose = current_setting('app.purpose', true)
         AND principal_id = current_setting('app.principal', true)
         AND expires_at > now())
  WITH CHECK (purpose = current_setting('app.purpose', true)
              AND principal_id = current_setting('app.principal', true));
DROP POLICY IF EXISTS janitor_sees_expired ON agent_memory;
CREATE POLICY janitor_sees_expired ON agent_memory FOR SELECT TO memory_janitor
  USING (expires_at <= now());
DROP POLICY IF EXISTS janitor_purges_expired ON agent_memory;
CREATE POLICY janitor_purges_expired ON agent_memory FOR DELETE TO memory_janitor
  USING (expires_at <= now());
GRANT SELECT, INSERT ON agent_memory TO agent_runtime;
GRANT SELECT, DELETE ON agent_memory TO memory_janitor;
"""

SPECIAL_CATEGORIES = {"health", "biometric", "genetic", "ethnic_origin", "religion",
                      "political_opinion", "trade_union", "sex_life", "sexual_orientation"}


class PurposeViolation(Exception):
    pass


class PurposeMemory:
    """A memory handle bound to one agent, one principal and one purpose for its whole life."""

    def __init__(self, ctx: CallContext, ledger: Ledger, declared_purposes: Iterable[str],
                 dsn: str | None = None) -> None:
        self.ctx, self.ledger = ctx, ledger
        if ctx.purpose not in set(declared_purposes) or ctx.purpose not in PURPOSES:
            self._refuse(f"{ctx.agent_id} is not declared for purpose {ctx.purpose}")
        self.policy = PURPOSES[ctx.purpose]
        # Connect as a login role that is a member of agent_runtime, never as the table owner.
        self.conn = psycopg.connect(dsn or os.environ["MEMORY_DSN"], autocommit=True)

    def _spec(self, name: str, categories: Iterable[str]) -> ToolSpec:
        return ToolSpec(name, None, frozenset(categories))

    def _refuse(self, reason: str) -> None:
        self.ledger.append(uuid.uuid4(), "refused", self.ctx, self._spec("memory", []), {"reason": reason})
        raise PurposeViolation(reason)

    def _scope(self) -> None:
        # is_local = true: the scope dies with the transaction, so a pooled connection cannot leak it.
        self.conn.execute(
            "SELECT set_config('app.purpose', %s, true), set_config('app.principal', %s, true)",
            (self.ctx.purpose, self.ctx.principal_id))

    def remember(self, content: str, categories: set[str]) -> uuid.UUID:
        if special := categories & SPECIAL_CATEGORIES:
            self._refuse(f"special category data {sorted(special)} is never written to agent memory")
        if outside := categories - self.policy["categories"]:
            self._refuse(f"categories {sorted(outside)} are outside purpose {self.ctx.purpose}")
        call_id, spec = uuid.uuid4(), self._spec("memory.remember", categories)
        self.ledger.append(call_id, "start", self.ctx, spec, {"content": digest(content)})
        with self.conn.transaction():
            self._scope()
            row = self.conn.execute(
                """INSERT INTO agent_memory (purpose, principal_id, agent_id, data_categories,
                       content, expires_at)
                   VALUES (%s, %s, %s, %s, %s, now() + %s) RETURNING id""",
                (self.ctx.purpose, self.ctx.principal_id, self.ctx.agent_id, sorted(categories),
                 content, self.policy["memory_ttl"]),
            ).fetchone()
        self.ledger.append(call_id, "end", self.ctx, spec, {"outcome": "ok", "id": str(row[0])})
        return row[0]

    def recall(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
        call_id, spec = uuid.uuid4(), self._spec("memory.recall", self.policy["categories"])
        self.ledger.append(call_id, "start", self.ctx, spec, {"query": digest(query)})
        with self.conn.transaction():
            self._scope()
            # No "WHERE purpose = ..." here on purpose: the policy adds it, and a bug in
            # this file cannot take it away.
            rows = self.conn.execute(
                """SELECT id, content, data_categories, created_at FROM agent_memory
                   WHERE to_tsvector('english', content) @@ plainto_tsquery('english', %s)
                   ORDER BY created_at DESC LIMIT %s""",
                (query, limit),
            ).fetchall()
        self.ledger.append(call_id, "end", self.ctx, spec, {"outcome": "ok", "rows": len(rows)})
        return [{"id": r[0], "content": r[1], "categories": r[2], "created_at": r[3]} for r in rows]

    def recall_across(self, other_purpose: str, query: str) -> list[dict[str, Any]]:
        """Exists so that cross-purpose reuse is an explicit, ledgered refusal, not a silent gap."""
        self._refuse(f"cross-purpose recall from {other_purpose} into {self.ctx.purpose}: {digest(query)}")
        return []
  • Put a test in CI that writes a memory under one purpose and asserts that recall under another returns zero rows, and that a bare SELECT after the transaction returns nothing at all.
  • Keep embeddings in the same table under the same policy. A separate vector store with its own access rules is a side door around everything above.
  • Special category data, such as a health condition mentioned in a vulnerability conversation, is refused outright. If a purpose genuinely needs it, your data protection officer decides that in the ROPA, not an agent at runtime. Agent memory is a working cache; the complaint file keeps its own retention rule.

3. Can You Switch Your Agents Off And Restart Them Safely?

Our reading of the British approach is that existing regulators apply existing law and ask firms for evidence. Kanishka Narayan, the Minister for AI, told an audience at Imperial College London on 12 October 2026: 'And if you cannot adequately control the risks, do not release the system.' He was speaking to model developers, but the test applies just as well to a broker deploying an agent. The government's release that day says the NCSC and the AI Security Institute are working on how incidents involving AI agents should be responded to. A drill that proves you can stop your agents and restart them cleanly is the most direct evidence a UK firm can produce.

Supervisors elsewhere are asking for the same proof more directly. Reuters reported on 13 October 2026 that the Federal Reserve, the FDIC and the OCC are questioning US banks on what limits are placed on AI systems, how their actions are monitored and approved, whether banks can track and audit AI agent decisions, what safeguards stop agents exceeding their authority, whether humans stay involved in key decisions and how banks can shut autonomous systems down. The sources said the regulators are 'not yet being prescriptive'. Moody's noted on 5 October that the April 2026 US interagency model risk guidance, SR 26-2, explicitly excluded generative and agentic AI from its scope, and that traditional model risk management frameworks 'were not designed to address' those systems. In the euro area, the ECB gave lenders an end-October deadline to produce plans to counter AI-enabled cyber threats, and euro-zone banks are being asked to map AI use across their businesses and third-party providers and to ensure they can switch off and restart systems in a controlled manner. UK supervisors, according to the same report, are encouraging the largest banks to examine so-called bare metal recovery plans, systems entirely independent of a bank's main technology, and intend to work collaboratively with industry.

The drill below answers all of them at once. It is graded: level 1 pauses one tool, level 2 revokes one agent, level 3 halts the fleet. Revocation is a row, because the gateway, not the agent, keeps the downstream credentials; if your agents carry their own API keys, moving those keys behind a gateway is the first fix. Halting takes one person and the drill takes two, because a fast stop is a safety control and a fast restart is a risk. In-flight calls are drained, not killed, and anything still open at the deadline goes to a person with its ledger reference. Nothing is retried automatically, because a retried client message is a second client message. Restart happens only if every agent's manifest matches its known-good hash and the ledger chain verifies, widest control first, with the client-facing tool last.

pythonops/kill_switch.py
"""Graded kill switch and a controlled shutdown and restart drill.

Level 1 pauses one tool, level 2 revokes one agent, level 3 halts the fleet.
The gateway checks agent_controls before every call (blocked_reason), and the
gateway, not the agent, keeps the downstream credentials, so revoking an agent
is a row, effective on its next call, with no cooperation from the model.

Halting in a real incident takes one person:
  python -m ops.kill_switch halt --by j.osei --reason "agent reached unapproved host"
The drill halts the production fleet, so it takes two:
  python -m ops.kill_switch drill --tool send_client_message --agent complaints-agent
      --approved-by a.khan,j.osei --drain-seconds 120

Targets Python 3.12, psycopg 3.3 and PostgreSQL 15 or later.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import psycopg

from ledger.agent_ledger import CallContext, Ledger, ToolSpec, blocked_reason

MANIFESTS = Path(os.environ.get("AGENT_MANIFESTS", "deploy/manifests"))
KNOWN_GOOD = Path(os.environ.get("KNOWN_GOOD", "deploy/known_good.json"))  # {"agent-id": "sha256"}
REPORTS = Path(os.environ.get("DRILL_REPORTS", "drills/reports"))

def set_control(conn: psycopg.Connection, scope: str, target: str, reason: str, by: str) -> int:
    return conn.execute(
        "INSERT INTO agent_controls (scope, target, reason, set_by) VALUES (%s, %s, %s, %s) RETURNING id",
        (scope, target, reason, by)).fetchone()[0]


def clear_control(conn: psycopg.Connection, control_id: int) -> None:
    conn.execute("UPDATE agent_controls SET cleared_at = now() WHERE id = %s", (control_id,))


def probe(ledger: Ledger, agent_id: str, tool: str, deadline_s: float = 30.0) -> float:
    """Seconds until the gateway check refuses a synthetic call. Point this at your gateway's
    own check if it caches controls: propagation time is the number that matters."""
    ctx = CallContext(agent_id, "drill", "drill-principal", "drill", solely_automated=True)
    started = time.monotonic()
    while time.monotonic() - started < deadline_s:
        if reason := blocked_reason(ledger.conn, agent_id, tool):
            ledger.append(uuid.uuid4(), "refused", ctx, ToolSpec(tool, None, frozenset()),
                          {"reason": reason, "drill": True})
            return round(time.monotonic() - started, 3)
        time.sleep(0.05)
    raise RuntimeError(f"control on {agent_id}/{tool} not effective after {deadline_s}s")


def in_flight(conn: psycopg.Connection) -> list[tuple[Any, ...]]:
    return conn.execute(
        """SELECT s.call_id, s.agent_id, s.tool, s.principal_id, s.at FROM agent_ledger s
           WHERE s.phase = 'start' AND s.at > now() - interval '1 hour' AND s.agent_version <> 'drill'
             AND NOT EXISTS (SELECT 1 FROM agent_ledger e WHERE e.call_id = s.call_id AND e.phase = 'end')""",
    ).fetchall()


def drain(conn: psycopg.Connection, seconds: int) -> dict[str, Any]:
    """Let in-flight calls finish. Whatever is still open at the deadline goes to a person
    with its ledger reference. Never retry automatically: a retried client message is a second one."""
    started = time.monotonic()
    while (pending := in_flight(conn)) and time.monotonic() - started < seconds:
        time.sleep(1)
    for call_id, agent_id, tool, principal_id, at in pending:
        conn.execute(
            """INSERT INTO ops_handover (call_id, agent_id, tool, principal_id, started_at, reason)
               VALUES (%s, %s, %s, %s, %s, 'open at drain deadline') ON CONFLICT DO NOTHING""",
            (call_id, agent_id, tool, principal_id, at))
    return {"drain_s": round(time.monotonic() - started, 1),
            "handed_to_people": [{"call_id": str(p[0]), "agent": p[1], "tool": p[2]} for p in pending]}


def manifest_drift() -> dict[str, dict[str, str | None]]:
    expected = json.loads(KNOWN_GOOD.read_text())
    drift = {}
    for agent_id, want in expected.items():
        path = MANIFESTS / f"{agent_id}.json"
        got = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
        if got != want:
            drift[agent_id] = {"expected": want, "found": got}
    return drift


def evidence(conn: psycopg.Connection) -> dict[str, Any]:
    """Thirty days of ledger, summarised into the inventory supervisors ask for."""
    systems = conn.execute(
        """SELECT agent_id,
                  array_agg(DISTINCT purpose) FILTER (WHERE phase = 'start'),
                  array_agg(DISTINCT external_system) FILTER (WHERE phase = 'start'
                                                              AND external_system IS NOT NULL),
                  array_agg(DISTINCT external_system) FILTER (WHERE phase = 'refused'
                                                              AND external_system IS NOT NULL),
                  count(*) FILTER (WHERE phase = 'refused')
           FROM agent_ledger WHERE at > now() - interval '30 days' AND agent_version <> 'drill'
           GROUP BY agent_id ORDER BY agent_id""").fetchall()
    decisions = dict(conn.execute(
        """SELECT CASE WHEN solely_automated THEN 'solely_automated_with_contest_channel'
                       ELSE 'named_human_reviewer' END, count(*)
           FROM agent_ledger WHERE phase = 'start' AND significant AND at > now() - interval '30 days'
           GROUP BY 1""").fetchall())
    return {"agents": [{"agent": a, "purposes": p or [], "systems_reached": ok or [],
                        "systems_refused": no or [], "refusals": r} for a, p, ok, no, r in systems],
            "significant_decisions_30d": decisions}


def run_drill(tool: str, agent: str, approvers: list[str], drain_seconds: int) -> Path:
    if len(set(approvers)) < 2:
        sys.exit("two named approvers required: this drill halts the production fleet")
    ledger, by = Ledger(), ",".join(approvers)
    conn = ledger.conn
    fleet = sorted(json.loads(KNOWN_GOOD.read_text()))
    report: dict[str, Any] = {"drill_id": str(uuid.uuid4()),
                              "started_at": datetime.now(timezone.utc).isoformat(),
                              "approved_by": approvers, "chain_before": ledger.verify_chain()}

    c1 = set_control(conn, "tool", tool, "drill level 1", by)
    level1 = probe(ledger, agent, tool)
    c2 = set_control(conn, "agent", agent, "drill level 2", by)
    level2 = probe(ledger, agent, "any_tool")
    c3 = set_control(conn, "fleet", "*", "drill level 3", by)
    level3 = max(probe(ledger, a, "any_tool") for a in fleet)
    report["shutdown"] = {"pause_tool_s": level1, "revoke_agent_s": level2,
                          "halt_fleet_s": level3, "fleet_size": len(fleet), **drain(conn, drain_seconds)}

    drift, chain = manifest_drift(), ledger.verify_chain()
    report["restart_checks"] = {"manifest_drift": drift, "chain": chain}
    if drift or not chain["intact"]:
        report["restart"] = {"status": "withheld", "why": "drift or broken chain; fleet stays halted"}
    else:
        started = time.monotonic()
        for control_id in (c3, c2, c1):  # widest first; the client-facing tool comes back last
            clear_control(conn, control_id)
        report["restart"] = {"status": "restarted", "order": ["fleet", f"agent {agent}", f"tool {tool}"],
                             "restart_s": round(time.monotonic() - started, 3),
                             "still_blocked": blocked_reason(conn, agent, tool)}
    report["evidence_30d"] = evidence(conn)
    report["finished_at"] = datetime.now(timezone.utc).isoformat()
    REPORTS.mkdir(parents=True, exist_ok=True)
    out = REPORTS / f"{report['started_at'][:19].replace(':', '')}Z-shutdown-drill.json"
    out.write_text(json.dumps(report, indent=2, default=str))
    return out


if __name__ == "__main__":
    parser = argparse.ArgumentParser(prog="ops.kill_switch")
    sub = parser.add_subparsers(dest="cmd", required=True)
    halt = sub.add_parser("halt", help="real incident: halt the whole fleet now")
    halt.add_argument("--by", required=True)
    halt.add_argument("--reason", required=True)
    drill = sub.add_parser("drill", help="graded shutdown, drain, verified restart, report")
    drill.add_argument("--tool", required=True)
    drill.add_argument("--agent", required=True)
    drill.add_argument("--approved-by", required=True, help="comma-separated, at least two people")
    drill.add_argument("--drain-seconds", type=int, default=120)
    args = parser.parse_args()
    if args.cmd == "halt":
        print(set_control(Ledger().conn, "fleet", "*", args.reason, args.by))
    else:
        print(run_drill(args.tool, args.agent, args.approved_by.split(","), args.drain_seconds))
  • Run it monthly against production controls in a quiet window, and quarterly against a full replica with real in-flight work to drain.
  • The probe here reads the control table directly. If your gateway caches controls, point the probe at the gateway instead: propagation time is the number a supervisor will ask about.
  • Bare metal recovery is a large-bank expectation for now. For a 40-person broker, the proportionate version is this drill plus a rehearsed way to run the business with every agent off.

4. Shaping Agentic AI UK Regulation: What To Send The ICO By 20 November

The drill writes a JSON report; the example below has illustrative values. The shutdown timings answer the US question about shutting autonomous systems down. The restart checks answer the euro-zone request for a controlled switch-off and restart. The 30-day evidence block, read straight from the ledger, answers the ICO's accountability questions and maps AI use, including which external systems each agent reached and which it was refused.

jsondrills/reports/2026-10-14T063004Z-shutdown-drill.json
{
  "drill_id": "b2ab2797-6167-4d7e-81d9-90960b916e3a",
  "started_at": "2026-10-14T06:30:04.779852+00:00",
  "approved_by": ["a.khan", "j.osei"],
  "chain_before": {
    "intact": true,
    "checked": 412907,
    "head": "7d4a438916096b84b8782a1cb8b674f91b946552ce38372d784e9671c56257ba"
  },
  "shutdown": {
    "pause_tool_s": 0.004,
    "revoke_agent_s": 0.003,
    "halt_fleet_s": 0.006,
    "fleet_size": 3,
    "drain_s": 41.0,
    "handed_to_people": [
      {"call_id": "3ea4865d-f358-4c75-94d3-d224ad15e44d", "agent": "margin-agent", "tool": "send_client_message"}
    ]
  },
  "restart_checks": {
    "manifest_drift": {},
    "chain": {
      "intact": true,
      "checked": 412931,
      "head": "044c2361622ffad5260239ac52b0d0da38838a36a63dfc5393ba5fd22a3eafc0"
    }
  },
  "restart": {
    "status": "restarted",
    "order": ["fleet", "agent complaints-agent", "tool send_client_message"],
    "restart_s": 0.006,
    "still_blocked": null
  },
  "evidence_30d": {
    "agents": [
      {
        "agent": "complaints-agent",
        "purposes": ["complaint_triage"],
        "systems_reached": ["crm.internal", "notify.internal"],
        "systems_refused": ["notify.internal"],
        "refusals": 52
      },
      {
        "agent": "margin-agent",
        "purposes": ["margin_call_notice"],
        "systems_reached": ["notify.internal", "risk.internal"],
        "systems_refused": [],
        "refusals": 3
      },
      {
        "agent": "research-agent",
        "purposes": ["market_commentary_research"],
        "systems_reached": ["filings.internal", "marketdata.internal"],
        "systems_refused": ["huggingface.co"],
        "refusals": 11
      }
    ],
    "significant_decisions_30d": {
      "named_human_reviewer": 214,
      "solely_automated_with_contest_channel": 37
    }
  },
  "finished_at": "2026-10-14T06:31:52.118204+00:00"
}

Why respond at all? Because this evidence will shape a statutory code, and once the code exists, expect your agent stack to be measured against it. Stephanie Lees of Pinsent Masons called the call 'a significant regulatory moment that organisations cannot afford to ignore.' We agree, for a narrower reason: if only the largest banks and the model labs respond, the code will describe controls sized for them. A small broker or prop firm that can show a working ledger, scoped memory and a drill report is evidence that proportionate controls exist.

The timing is crowded. John Healey presents his first Budget as Chancellor on 28 October 2026, just over three weeks before the ICO window closes, and Westminster's interest in AI is high: the Treasury Committee heard evidence from the FCA's chief executive, Nikhil Rathi, on 13 October, and the same day published a letter from Kanishka Narayan to its chair, Dame Meg Hillier, which says 'There is no credible choice between being ambitious about AI and being serious about its risks. We must do both.' The operational detail for agents, though, is being written at the ICO. A response built from your own evidence needs a paragraph or two per section:

  • Accountability: the ledger schema, what produces a refused row, and how long the ledger is kept.
  • Data security: the allowlist of external systems for each purpose, and the refusals it produced last month.
  • Automated decision-making: how you decide a decision is significant, who reviews, where a client contests, and the 30-day counts from the drill report.
  • Fairness and purpose limitation: how memory is scoped and when it expires, enforced by the database rather than the prompt.
  • Additional questions: what is genuinely hard, such as transparency in multi-agent systems, which TLT notes the call covers. Saying what you cannot yet do well is useful evidence too.

“Autonomy changes who acts. It does not change who answers for it.”


Where This Belongs

  1. 01Brokers and CFD providers: put the ledger in front of every agent tool that reads client data or messages clients, register complaints and client notices as separate purposes, and bring the client-facing tool back last after a halt.
  2. 02Prop trading firms: treat a refused payout or a failed evaluation decided by an agent as a significant decision, with a named reviewer or a contest channel recorded in the ledger, and keep evaluation memory out of any marketing purpose.
  3. 03Fintech and trading startups: build the ledger and the drill before the first agent goes live. Both are cheap on day one and expensive to retrofit, and the drill report answers the due diligence question about AI controls.
  4. 04Research and quant teams: less personal data, but research agents are the most likely to reach for external model hubs and data sites. The per-purpose allowlist and the refused systems list matter most in that part of your trading workflow architecture.

The Bottom Line

Agentic AI UK regulation is being written now, at the ICO, through a call for evidence that closes on 20 November 2026 and feeds a statutory code of practice on AI and automated decision-making. The Bank of England's Financial Policy Committee has flagged increasingly autonomous models, the government is preparing for agent incidents and near misses, and US and euro-zone supervisors are already asking banks to prove they can track, limit and stop their agents. The engineering answer is the same in every jurisdiction: a hash-chained ledger at the tool boundary with purpose, lawful basis and external system on every row; memory that the database itself keeps inside its purpose and expiry; and a graded shutdown drill that restarts only from a known-good state and writes its own evidence. AlchmAI, a London trading technology and AI studio, builds controls like these for brokers, prop firms and fintech teams. Whoever builds yours, run the drill before you write your response.

References & Further Reading

agentic AI UK regulationAgentic AI finance codeAgentic AI London codeAI Automation London code related to AITrading Workflow architectureICO call for evidenceAI agent kill switchUK GDPR automated decision-making
Share Email
AI

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