プロローグ:Agent が実データを書き換えた瞬間、問いの性質が変わる

この 2 年間の Agent に関する議論の大半は、「何ができるか」に使われてきた。文書を読む、DB を引く、レポートを書く、SQL を生成する、いくつかの API を叩く。この種のシーンに共通するのは、Agent の一度の失敗は高々「答えが少し悪くなる」程度で、ユーザーがもう一度聞くか人が手で直せば回復するという点だ。この種のシステムについては、ここでは「提案型 Agent」と呼んでおく。正しさの判定基準は「出力に参考価値があるか」だ。

一度 Agent に write 権限を与えると、性質は変わる。注文のステータスを「発送待ち」から「発送済み」に書き換える、顧客のお金を A 口座から B 口座へ振り分ける、2 万件の受信者に 1 通ずつメールを送る、契約書に数字をひとつ残す——ありふれた操作である。出力し間違えれば書き直せるが、ステータスを間違えれば、現実の世界には出荷が 1 回、振込みが 1 回、対外的な約束が 1 回という事実が残る。システムは「コンテンツを生成する道具」から「外部世界に副作用をもたらす実行者」へと変わる。こうなると問うべきものはモデルの賢さではなく、権限をどう線引きするか、トランザクションをどう扱うか、事故が起きたときに誰がどう責任を取るかになる。

これも、多くのチームが同じ順序で踏ん張るパターンだ。Agent の read 側の実装は美しくできても、write 側を繋いだ 1 版目は動く。2 版目から事故が起き、そして事故のあとに Rollback の手段がまったくないことに気づく。下流は第三者 SaaS で、トランザクションも取り消し API も提供していないからだ。

  • データを読んだだけの Agent が間違えても、最悪の結末は時間の浪費である。データを書き込んだ Agent が間違えたとき、最悪の結末は取り消せない外部の事実である。

一、まず危険度を階級分けする——すべての write 操作が同じくらい不可逆とは限らない

「Agent に write 権限を与えるべきか」という問題で止まるチームが多いのは、これを yes/no の問題だと捉えているからだ。実際には正しい問いは「この write 操作はどれだけ撤回しにくいのか」である。ツールを撤回可能性で 4 つの階級に分けると、統治手段はまったく異なる。混ざったまま論じれば、「全部 NG か全部 OK か」という無用な結論にしか到達しない。

階級典型的な操作失敗時の帰結現実的な統治手段
その場で書き直せる備考・タグ・下書きのタイトルを変更新しい値が上書きされるが、損失は限定的書き込み前スナップショット + ロールバック可能な監査
論理的には取り消せる注文のキャンセル、認可の取り消し、返金逆操作はあるが、下流に観測されている可能性がある補償トランザクション + 取り消し監査
財務的に不可逆支払い、口座振替、外部カードへの返金お金は既に動いたため、照合かクレームしか手段がないhuman 確認の強制 + 金額しきい値 + 2 名レビュー
対外的に不可逆メール/SMS の送信、Webhook 通知、規制報告の提出外部世界は既に受け取ったので、なかったことにはできない送信 gateway + 事前コンテンツ審査 + 全量記録

この表の価値は、「危険」を形容詞から、コードに落地できる分類変数へ変えることにある。分類変数は 3 つのことを決める。この操作がどの承認経路を通るか、失敗時にどの補償経路を通るか、ログにどの粒度の証拠を残すか。この後の節で説明する idempotency key、human 確認点、監査フィールドは、いずれもこの表を軸に展開していく。

強調すべきは、不可逆性が静的な属性ではないという点である。「顧客に通知メールを 1 通送る」というツールは、一見すると単なる対外通知だが、中に埋め込まれた business 的な意味が「ご注文の商品は発送されました」である可能性がある。EC の文脈でこの文は要約と約束の境界線にあり、誤送信は紛争やプラットフォームの制裁を招く。したがって実務上の正解は、「HTTP Verb」ではなく「business 的な意味」で階級分けすることだ。同じ POST でも、POST /draft と POST /shipment/confirm ではリスクが 2 桁違う。

二、Agent のシーンで「API を叩く」が急に難しくなる理由

従来の連携コードが第三者 API と向き合うときには、型の定まった手順がある。リクエストは失敗しうる、timeout しうる、相手は retry する、トランザクション境界は自分側にあった。service 層を書いて timeout と retry を処理すれば、そのタスクは完了である。開発者のメンタルモデルにおいて、API 呼び出しとは同期的で、成否を判定できる関数呼び出しのことである。

Agent はこの前提を打ち壊す。具体的には 3 つの転換がある。

  • 呼び出しはモデルが発起し、引数もモデルが生成する。従来の連携では、引数は検証済みの business コードから来る。Agent 側では、引数は自然言語をモデルが解析した結果である。フィールドの取り違え、桁の誤り、単位の不一致が起きる失敗率は本質的に高く、しかも失敗前に止めてくれる静的チェックは何ひとつない。
  • 呼び出しはループの一部である。従来の連携では「この手順が失敗した」ら例外を投げて終了するが、Agent では「この手順が失敗した」らが retry を誘発したり、引数を替えるしたり、別のツールでやり直したりする。同じ business の意図が複数の物理的な呼び出しとして表現されることは、異常ではなく常态である。
  • 人間がループに含まれており、しかも重要経路上にはいない。従来のフローでは承認はフローチャートの 1 ノードにすぎないが、Agent のフローでは承認が中間に置かれたり、モデルが「すでに承認された」と判断したりする。したがって human 確認点をどこに置くかは本物の問題になり、承認フローを通すだけで済むわけではない。

この 3 つの転換は、それぞれ後述の設計案に対応する。引数の問題は実行前に business レベルの検証を行い、検証の失敗を例外ではなく「情報」としてモデルへ返すこと。複数の物理的な呼び出しには idempotency か重複排除が必要なこと。人間の介在は、一度きりの会話ではなく、署名つきの証跡として設計することである。

三、この記事のロードマップ

以下は「境界を先に引き、次に仕組みを語り、最後に事後の責任を追う」という順序で展開する。

  1. 権限境界を機能ごとではなく read/write/cross-tenant の 3 軸で切る必要がある理由と、OAuth scope の粒度を自分で決められないときの対処。
  2. idempotency key が相手の API で効かないときに使える代替手段と、逆に新しい問題を引き込む代替手段。
  3. SaaS をまたぐ長いトランザクションが Saga 一本しか残らない理由、補償の強弱の階級分け、そして「呼んだのに確認が来ない」suspending 状態の扱い。
  4. Agent のシーンにおいて 2PC が本当に落地する 3 つの形態と、それぞれの失效条件。
  5. human 確認点をどこに置くべきか、確認時に何を見せるべきか、確認疲労と TOCTOU をどう避けるか。
  6. 事後追認のために監査ログへ残すべきもの、そして書き込み前スナップショットと「その時点のシステム状態」が操作結果より重要な理由。

全文の中心判断はこうだ。実業務システムへ接続することの難易度は「繋がるかどうか」ではなく、「間違えたときに説明できるか、撤回できるか、人を辿れるか」である。この 3 つの能力が、Agent 連携をおもちゃから production へ引き上げる境界線になる。

四、権限境界:機能ではなく read・write・cross-tenant の 3 軸で切る

機能単位の認可では足りない理由

最も多いのは「機能」単位での認可である。Agent に orders ツールを渡せば注文の参照も変更もできる、refunds ツールを渡せば返金を開始できる。直感的にはきわめて合理的なので、穴が 3 つある。いずれも production で表面化する。

1 つめは爆発半径である。「注文を変更する」ツールが実際に変更できるフィールドには、shipping 住所、shipping の担当者、電話番号、備考、ステータス、数量が含まれる可能性がある。権限が「注文を変更できる」か「できない」かしか区別しないなら、モデルの hallucination 1 回で貨物を誤った住所へ送ってしまう。システムに止める仕組みは何ひとつない。止めるには、ツールを「住所を変更する」と「ステータスを変更する」という別のツール、別の認可へ分割する必要がある。

2 つめはread と write の混在である。ほとんどの SaaS の OAuth 連携では、read する API と write する API がサーバー側で同じ scope を共有することが多く、オブジェクト単位の権限セットまで共有することもある。つまり write 権限を渡した瞬間に、Agent の批量読み取りも一緒に開いてしまう。そして読み取りは、多くの security 評価で書き込みと同等以上の危害度を持つ——全顧客データを持ち出せる。

3 つめはcross-tenantである。複数顧客がいる場合、権限名は「注文を操作できる」だが、本当の境界は「どのテナントの注文を操作できるか」。この 2 つの軸は同時に検査しなければならず、片方だけが欠ければ事故になる。検査の位置はサーバー側で credential を解決した後、ツール実行の前でなければならない。モデルに引数として tenant_id を埋めてから後で祈る、というやり方は駄目だ。

3 軸の認可モデル

3 つの穴をまとめると、実行可能な認可モデルには少なくとも 3 つの直交軸が要る。動作(read/write/delete/export)、対象範囲(どの resource type、どの field、どの tenant)、リスク等級(最初の節の不可逆性区分表に対応する)。3 つの共通部分だけが通る。

重要な設計判断はこうだ。この 3 軸はサーバー側か独立した認可レイヤーで必ず判定し、モデルに委ねたりモデルにパラメータで表現させたりしてはいけない。モデルは「注文 12345 のステータスを発送済みにする」という要求は出せても、「注文 12345 を変更する権限がある」ことを自分で決めてはならない。前者は意図、後者は認可であり、ここを混ぜた瞬間に権限システムはすでに機能していない。

実務の做法は能力チケット(capability ticket)を 1 枚挟むことである。Agent の planning フェーズが出すのは直接の API 呼び出しではなく、制限され、署名つきで、1 種類の操作しかできず、決まった対象群にしか触れないチケットだ。ツール実行層はチケットだけを受け付け、素の引数は受け付けない。チケット自体は検査でき、期限切れにでき、破棄でき、誰かが一度見るだけで今回何が許されるかがわかる。

# 能力票据:把"意图"和"授权"拆成两件事
# 模型只能产出 grant 里的 action / resource / constraints,
# 至于"能不能"由这里的 policy 层说了算。

import time
import hmac
import hashlib
import json
from dataclasses import dataclass, field

# 不可逆性分级,与文章第一节的表格对应
RISK = {
    "draft.write": 1,        # 可原地改写
    "order.status.write": 2, # 逻辑可撤销
    "refund.create": 3,      # 财务不可逆
    "notice.send": 3,        # 对外不可逆
}

# 超过这个等级必须人工确认
CONFIRM_THRESHOLD = 3


@dataclass
class Grant:
    action: str                       # 动作,如 order.status.write
    resource_type: str                # 资源类型,如 order
    resource_ids: list                # 允许触碰的具体对象
    fields: list                      # 允许修改的字段白名单
    tenant_id: str                    # 租户,由凭证解析得出,不由模型提供
    max_calls: int = 1                # 票面额度,用完即焚
    constraints: dict = field(default_factory=dict)
    issued_at: float = 0.0
    expires_at: float = 0.0
    nonce: str = ""


class PolicyError(Exception):
    pass


class CapabilityBroker:
    """唯一的授权出口。工具层只认票据,不认模型给的参数。"""

    def __init__(self, secret: bytes):
        self.secret = secret
        self.used = set()

    def issue(self, *, action, resource_type, resource_ids, fields,
              tenant_id, ttl=120, max_calls=1, constraints=None):
        if action not in RISK:
            raise PolicyError("未登记的动作不允许发放票据: " + action)
        now = time.time()
        grant = Grant(
            action=action,
            resource_type=resource_type,
            resource_ids=list(resource_ids),
            fields=list(fields),
            tenant_id=tenant_id,
            max_calls=max_calls,
            constraints=constraints or {},
            issued_at=now,
            expires_at=now + ttl,
            nonce=hashlib.sha256(
                (str(now) + resource_type).encode()).hexdigest()[:16],
        )
        return grant, self._sign(grant)

    def redeem(self, grant, signature, *, args, caller_tenant):
        self._verify(grant, signature)
        if time.time() > grant.expires_at:
            raise PolicyError("票据已过期")
        if grant.nonce in self.used:
            raise PolicyError("票据已被使用,拒绝重放")
        if caller_tenant != grant.tenant_id:
            raise PolicyError("跨租户访问被拒绝")
        if grant.action not in args:
            raise PolicyError("请求动作与票面不符")
        extra = set(args[grant.action].keys()) - set(grant.fields)
        if extra:
            raise PolicyError("超出字段白名单: " + ",".join(sorted(extra)))
        if self.used_count(grant) >= grant.max_calls:
            raise PolicyError("票面额度耗尽")
        self.used.add(grant.nonce)
        return grant.action, args[grant.action]

    def needs_human(self, grant):
        return RISK.get(grant.action, 99) >= CONFIRM_THRESHOLD

    def _sign(self, grant):
        raw = json.dumps(grant.__dict__, sort_keys=True, default=str)
        return hmac.new(self.secret, raw.encode(), hashlib.sha256).hexdigest()

    def _verify(self, grant, signature):
        if not hmac.compare_digest(self._sign(grant), signature):
            raise PolicyError("票据签名校验失败")

    def used_count(self, grant):
        return 1 if grant.nonce in self.used else 0

OAuth scope の粒度を自分で決められないとき

上のモデルはきれいだが、現実には硬い制約が 1 つある。credential の粒度は第三者 SaaS が決める。動作とフィールドで権限を分けたいと思っても、渡されるのは orders.write だけ、という場合は読み書きもフィールドもテナントも分かない。このとき現実的な打ち手は 3 つである。

  • credential の種類で区別し、細かい単位の認可は狙わない。最も単純で効果的な方法は、read 用と write 用の 2 セットの token を同時に保持し、ツール層が動作に応じて該当の側で呼び出すこと。相手が scope が粗くても、自分のプロセス内であれば read と write の分離は実現できて、少なくとも「read 経路が侵害された」場合でも write credential を渡さずに済む。
  • 自社側で shadow write を行う。write 操作をまずローカルの shadow テーブルへ落とし、第三者への呼び出しが成功した後に、単一地点のコンポーネントが状態を「公開」する。このローカルテーブルは自分の管理下なので、フィールド単位の検証も、前後の値の記録も、未公開の書き込みの取り消しもできる。代償は 1 hop の latency と 1 回の状態同期である。
  • 粗い粒度の認可のリスクを明示的に書き残し、ゲートを置く。「このツールが一度でも呼ばれたら、全量の write 権限を渡したのと同じ」と明記し、そのツールに個別の確認点、rate 上限、alert を設定する。これは最も綺麗ではないが最も誠実なやり方で、多くの production システムはこの手前で止まっている。

やってはいけないのは、scope が十分に細かいと装い、ドキュメントに「我々は最小権限を徹底しています」と書くことである。権限モデルの本当の境界はコードに書かれていて、ドキュメントには書かれていない。そしてコードの境界は、相手がくれた粗い scope そのものだ。

read 権限も attack surface である

注意を write 側だけに集中させるのも、よくある偏りである。Agent の read 操作には 2 つの固有リスクがある。1 つは批量取得で、モデルがループの中で read API を連続して呼び出し、テーブル全体を拖り去ることがある。1 回ずつ見れば正当な処理だが、もう 1 つは読み取った機密データが context に入ることで、context はモデル提供者に送られるため、機密データが境界を出た瞬間に自分の管理範囲から外れる。

1 つめのリスクには read 側にゲートを置くべきである。タスク 1 回あたりの read 量の上限、1 オブジェクトあたりの行数の上限、そして「同じ resource type を N 回連続 read したら説明を要求する」。2 つめのリスクには、ツールの返却結果に field 単位のマスキングをかけるのが妥当である。モデルが見るのは「電話番号末尾 4321」であって、完全な電話番号ではない。完全な値が必要なら、別途の承認つき取得 action を用意する。マスキングはツール層で行う。「電話番号を出力しないでください」と prompt に書くのではなく、それは access control を確率モデルに委ねることになる。

  • 「モデルの context に出現させたくないデータ」は、ツール層の段階で切って落とすべきで、モデルの自覚を期待するものではない。モデルは受動的な受け手であり、データ保護ポリシーを実行する能力を持っていない。

五、idempotency key が相手の API で効かないとき

idempotency の 3 つの出所

まず 1 つ澄清しておきたいことがある。idempotency はクライアントの挙動ではなく、サーバー側の契約である。「同じ Idempotency-Key を渡せば 1 回だけ効く」と相手がその仕様をサポートしていることがあって初めて idempotency が成立する。相手が認識できないヘッダなら、何度送っても意味がない。エンジニアリング上で依拠できる idempotency の出所は 3 つしかなく、現実には大半の第三者 SaaS がその前半 2 つのうち半分しか提供していない。

出所仕組み典型的な可用性
クライアントが提供リクエストに idempotency key を持たせ、サーバー側に一意制約がある一部の決済系 API のみ。かつ key のスコープも有効期限も API ごとに異なる
business 自然キーbusiness 上の唯一性で担保する。例えば同じ注文番号につき発送記録は 1 件だけ最も一般的。ただし自分で見つけ出す必要がある
自分の shadow レイヤ自分のストレージに一意制約を置き、先に DB へ書いてから外へ送る常に使える。ただし書き込みが 1 回増える

1 つ目が存在しない場合、大多数のチームの既定の反応は同じリクエストを 3 回投げて祈ることである。最悪の選択肢だ。最良の場合でも 3 回分の枠を無駄にし、最悪の場合では 3 件の本当の記録が生まれる。したがって本当の課題はこうだ。サーバー側の idempotency サポートがない状況を、retry を安全にするにはどうするか。

手段 1:クライアント側の idempotency チケット——一意性は自前で持つ

最も汎用的で、まず実装する価値がある方案である。核心はリクエストに引数を足すことではなく、自分の側で「これをやるつもりだ」という一意な記録を先に作り、そのあとで外部を呼び出すことにある。外部呼び出しが重複しても問題ない。外部から返ってきた結果は自分の台帳に吸収されるからだ。

実装上の要点は 3 つある。1 つは台帳は必ず永続化してから呼ぶことで、順序を逆にすると「呼び出しは成功したのに台帳には書かれなかった」という replay の窓が生まれる。2 つ目は一意制約はデータベース層に置く必要があり、アプリケーション層の「先に検索してから挿入」ではいけない。並行下では 2 つの retry が同時に「存在しない」ことを見つけてしまう。3 つ目は状態が明示的な 3 値でなければならず、真偽値ではないこと。pending / succeeded / unknown である。真偽値だけを持つのは典型的な誤りだ。「やっていない」と「やったが result が消えた」を区別できず、この 2 つの対処はまったく逆だからである。

# 幂等台账:外部 API 不认幂等键时,把去重责任收到自己这边。
# 关键点是三态而不是布尔 —— unknown 是最容易被忽略的一态。

import sqlite3
import time
import json
import uuid

PENDING, SUCCEEDED, UNKNOWN, FAILED = "pending", "succeeded", "unknown", "failed"


class DedupeLedger:
    def __init__(self, path):
        self.db = sqlite3.connect(path, isolation_level=None)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS op (
                key         TEXT PRIMARY KEY,
                op_type     TEXT NOT NULL,
                natural_key TEXT NOT NULL,
                tenant_id   TEXT NOT NULL,
                state       TEXT NOT NULL,
                request     TEXT NOT NULL,
                response    TEXT,
                external_id TEXT,
                created_at  REAL NOT NULL,
                updated_at  REAL NOT NULL
            )
        """)
        # 天然键兜底:即使外部调用重复发生,业务上也不该出现第二条
        self.db.execute("""
            CREATE UNIQUE INDEX IF NOT EXISTS op_natural
            ON op (op_type, tenant_id, natural_key)
        """)

    def reserve(self, key, op_type, natural_key, tenant_id, request):
        """先落盘再外发。冲突时返回已存在的记录,而不是报错。"""
        now = time.time()
        try:
            self.db.execute(
                "INSERT INTO op (key, op_type, natural_key, tenant_id, state,"
                " request, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
                (key, op_type, natural_key, tenant_id, PENDING,
                 json.dumps(request, sort_keys=True), now, now))
            return {"state": PENDING, "key": key, "external_id": None}
        except sqlite3.IntegrityError:
            row = self.db.execute(
                "SELECT state, response, external_id FROM op WHERE key=?",
                (key,)).fetchone()
            # 已有记录:重放是安全的,把上次的结果交回去
            return {"state": row[0], "key": key, "external_id": row[2],
                    "response": row[1]}

    def mark(self, key, state, response=None, external_id=None):
        now = time.time()
        if state == UNKNOWN:
            # 超时或连接中断:既不能判成功也不能判失败,留给对账
            self.db.execute(
                "UPDATE op SET state=?, response=?, updated_at=? WHERE key=?",
                (state, json.dumps(response or {}) if response else None, now, key))
            return
        self.db.execute(
            "UPDATE op SET state=?, response=?, external_id=?, updated_at=?"
            " WHERE key=?",
            (state, json.dumps(response or {}, ensure_ascii=False),
             external_id, now, key))

    def recheck_after_timeout(self, key, probe):
        """对账用的探测:用一个只读接口确认那次调用到底生效没有。"""
        row = self.db.execute(
            "SELECT state, external_id, request FROM op WHERE key=?",
            (key,)).fetchone()
        if row[0] == UNKNOWN:
            found = probe(json.loads(row[2]))
            if found:
                self.mark(key, SUCCEEDED, external_id=found)
                return SUCCEEDED
        return row[0]

    def pending_older_than(self, seconds):
        """悬挂扫描:超过窗口还没结论的,必须有人工介入或对账。"""
        cutoff = time.time() - seconds
        return self.db.execute(
            "SELECT key, op_type, natural_key, state FROM op"
            " WHERE state IN (?,?) AND updated_at < ?",
            (PENDING, UNKNOWN, cutoff)).fetchall()

手段 2:自然キーによる重複排除——「同じこと」を唯一の事実として表す

多くの business では、そもそも idempotency key が要らない。business 自体に唯一性があるのだ。同じ注文番号の発送記録は 1 件だけ、同じ精算サイクルの対帳書は 1 枚だけ、同じ携帯電話番号が 1 日に受け取れる認証コードは 1 回だけ。この自然キーを見つければ、重複排除は検索側ででき、しかも書き込み側より安定する。

自然キーの見つけ方は、自分に 1 つの問いを投げかけることである。もしこの書き込みが本当に 2 回実行されたら、DB の中にどんな無理なデータができるか。答えはたいてい自然キーそのものになる。この聞き方は API ドキュメントから Idempotency-Key を探すより有効である。後者はそもそも存在しないことが多いからだ。

手段 3:事前チェック、そして、それが失敗する理由

最も直感的なのは「書く前にちょっと確認して、あれば書かない」ことである。この手は多くのシーンで有効だが、押さえておくべき落とし穴が 3 つある。

  • TOCTOU。検索と書き込みの間に時間幅があり、並行する 2 つの実行(たとえばユーザーが retry した、あるいは別の経路が同時に処理している)がともにチェックを通過し、両方が書き込む。事前チェックは重複の確率を下げるだけで、なくはしない。
  • コストが倍増する。書き込みごとに読み取りが 1 回増え、API 呼び出し回数がそのまま倍増する。レート制限のある第三者 API では、バッチ処理中に頻繁に上限へ突き当たら、逆に偶発的な重複より悪い結果になる。
  • 「なければ書く」という前提を business ロジックに埋め込んでいる。もし「ないこと」自体が business 判断の一部なら(たとえば「この顧客に返金記録があるかどうか」が「返金できるか」ではなく「重複して返金しないか」を決める)、事前チェックは business レイヤーに状態機械を持ち込むことになる。その複雑さは直接 idempotency を実装するより低くならない。

したがって判断基準はこうだ。事前チェックは「重複すると business エラーになる」不変条件の検証には適するが、idempotency の唯一の実現手段には適さない。前者は business の要求、後者は信頼性の要求であり、両者は両立できるが、同じコードに混ぜてはいけない。

いつ idempotency を諦めて、頻度を制限すべきか

idempotency 化しにくい(呼び出しごとに実際に 1 回起こる必要がある。カウンタの増加やランダムな認証コードの送信など)、かつ台帳を用意する価値もない操作が 1 種類ある。正しい対処は幂等を追い続けることではなく、それが本質的に非冪等であることを認めて、「高々 1 回」の厳格な枠に押し込めることである。この種の操作の成功回数をタスク周期ごとに上限で封じ、上限に達したら人が来るまで止める。モデル自身に「もう 1 回試すか」を決めさせるのはやめる。

この種の操作は能力チケットの中で個別に flag を立てる必要がある。たとえば at_most_once=true で、枠は 1、消化したら再送しない。人間のキューに入るだけだ。利点はセマンティクスが正直なことである。システムに「この操作は idempotency を保証しない、枠と人間でカバーする」と明記している。「理論上は重複しない」というコメントを残すのとは根本的に違う。

  • システムに「たぶん重複しない」と書くことと、「高々 1 回実行し、重複したら人が対応する」と書くことは、まったく別のエンジニアリング状態である。前者は願いで、後者は検証可能な契約である。

六、長いトランザクションと部分失敗:SaaS をまたぐと Saga 一本しか残らない

XA がここの選択肢にならない理由

2PC の古典的な形態は、すべての参加者が prepare と commit の 2 つの protocol method を実装していることに依存する。そうして初めて、coordinator はグローバルなトランザクションを提案できる資格を得る。しかし参加者が第三者 SaaS の REST API なら、この前提は成り立たない。相手があなたのトランザクションのために prepare を実装してくれるはずはなく、クラッシュした後もあなたの決定を待つためにロックを保持し続けることも強制できない。

したがって Agent が実業務システムに接続するシーンにおいて「2PC を使うべきか」は技術選定の問題ではなく、前提が成り立つかどうかの問題である。自分が制御できないプロセス境界をまたいだ瞬間、分散トランザクションに残るのは 2 つの形だけだ。どちらかは補償(最終一致)へ退化し、グローバル原子性を捨てて各ローカル手順の原子性だけを保証する。そのあいだに第 3 の道は存在しない。

補足しておくと、ローカル手順の原子性さえもないことが多い。第三者 API の「注文ステータスの変更」は「マスター更新 + 操作ログの書き込み + 通知の発火」の 3 アクションであるかもしれない。あなたの 1 回の HTTP 呼び出しからは内部の過程は見えず、得られるのはそれらの最終的な状態だけである。依拠できるのは相手が返す response と後続の照会であって、相手の内部トランザクションではない。

補償はロールバックではない——まず強弱を階級分けする

チームが最も多く陷入している誤解は、Saga の補償を DB のロールバックだと理解してしまうことだ。DB のロールバックには原子性と隔離性の保証があるが、補償にはない。補償とは新しい前向きな操作で、古い前向きな操作を打ち消す過程であり、失敗し、一部だけ失敗し、下流に中間状態として観測されることもある。したがって補償は必ず先に強弱を階級分けし、そのうえで依存してよいか否かを決めるべきだ。

補償強度形態例必要な前提条件
強補償取り消し API が下流に副作用を起こさないまだ使われていないクーポン 1 枚の取り消し下流が取り消しによって通知を送らず、目に見える痕跡も残さないことを保証する
弱補償取り消しが痕跡を残す、あるいは取り消し自体が非同期に観測される注文のキャンセルで「注文をキャンセルしました」通知が出る痕跡を受け入れる。あるいは事前に痕跡も期待値に含めておく
補償なしそもそも逆方向の操作が存在しないコミット済みの銀行振込、送信済みの対外メール事前には介入することしかできず、事後処理はできない
偽の補償取り消したように見えるが、実際は自分のシステムで状態を変えただけローカルでは注文をキャンセル済みにしたのに、下流は依然として発送済みと思っている照合作業による安全網が必須で、ユーザーにも明確に伝える必要がある

最後の 1 行が最も危険だ。システム上は成功した補償と区別がつかないからだ。判断基準は至って単純である。補償操作のあと、下流を 1 回調べて、状態は実際に変わったか。変わっていなければ偽の補償だ。偽の補償そのものをしてはいけないわけではないが、「当方ではキャンセル済みと表示されていますが、相手のシステムではまだ発送済みと表示されている可能性があります」とユーザーへ伝えなければならない。UI 之上できれいな成功状態を描き続けるわけではない。

呼んだのに確認が来ない——suspending は異常ではなく常态

Agent シーンの長いトランザクションには、従来の分散トランザクションでは目立たなかった新しい問題がある。呼び出しは相手に届いていたのに、応答がネットワーク層で消えた。従来のシステムなら、ユーザーが timeout を見て retry する、という形で現れるのが通常で、スケールも大きくない。しかし Agent シーンでは、1 つのループの中の複数 retry がこの「結果が不明」な呼び出しを増幅させ、モデルは timeout を受け取るとすぐ引数を替えてもう 1 度試すことが多い。

ここから、データモデル上で明示的に表現しなければならない状態が生まれる。suspending(in-doubt)である。書き込み操作の結果が不明なとき、成功とも失敗とも記録できず、suspending のまま留まり、周期的な照合フローに参加しなければならない。suspending を無視する実装は、直接的に 2 つの結果のどちらかに至る。未知を成功として扱えば、ユーザーは「完了」と見るが相手にはデータがない。未知を失敗と扱って retry すれば、相手側に重複記録が生まれる。

suspending への対処の順序はこうあるべきだ。まず読み取り専用の probe を試す(相手が照会 API を提供しているか)。probe で判別できなければ人間へ回す。決して盲目的に再送しない。人間のステップで必要な材料は、その呼び出しの完全なパラメータ、発効した時刻、そのときに返ってきたエラーの特徴、そしてそのときに取得した response body の断片である。これらが記録されていなければ、人間にも判断できない。

business レベルの補償と技術レベルの補償

もう 1 つ区別しておかなければならないのは、補償の层次である。技術レベルの補償は「今やった書き込みを取り消す」ことで、動作は固定されており、機械的に実行できる。business レベルの補償は「business を合法な状態に戻す」ことで、動作は business の文脈に依存し、元の操作とはまったく無関係かもしれない。

たとえば Agent が顧客に 1 箱余計に発送したとする。技術レベルの補償は「この注文明細をキャンセルする」ことで、business レベルの補償は「注文明細をキャンセルし、顧客ランクに応じて客服追従を 1 回発生させる」かもしれない。前者は機械的で信頼できるが、business 上、厄介な状態を残す可能性がある。後者は実際の要求に近いが、モデルかルールが生成する必要がある。そしてそこにこそ危険がある。モデルに補償動作を生成させることは、不可逆な操作を確率的なコンポーネントへ委ねることと同じ。

実用的な設計はこうである。技術レベルの補償は決定論的なコードが実行し、モデルは補償意図の提示だけを担当する(どの項目を取り消すのか、理由は何か)。補償の着地点は、ルール化されたマッピング表が決める。モデルがこの層に出してこなければ、責任が明確になる。

実装:Saga 台帳と suspending スキャン

# Saga 编排器:只负责"记录做了什么"和"决定下一步",
# 不负责回滚 —— 跨系统没有回滚,只有补偿。

import time
import json
from dataclasses import dataclass, field, asdict


class StepState:
    PENDING = "pending"
    DONE = "done"
    FAILED = "failed"
    COMPENSATED = "compensated"
    COMPENSATE_FAILED = "compensate_failed"
    IN_DOUBT = "in_doubt"


# 每个步骤必须预先声明补偿能力,声明不了就不该进 Saga
STEP_CONTRACT = {
    "create_ticket":   {"compensate": "cancel_ticket",   "strength": "strong"},
    "reserve_stock":   {"compensate": "release_stock",   "strength": "strong"},
    "charge_card":     {"compensate": None,              "strength": "none"},
    "send_notice":     {"compensate": None,              "strength": "none"},
    "assign_courier":  {"compensate": "unassign_courier","strength": "weak"},
}


@dataclass
class Step:
    name: str
    state: str = StepState.PENDING
    request: dict = field(default_factory=dict)
    response: dict = field(default_factory=dict)
    attempts: int = 0
    last_error: str = ""
    updated_at: float = 0.0


class Saga:
    def __init__(self, saga_id, steps):
        self.id = saga_id
        self.steps = [Step(name=n) for n in steps]
        self.created_at = time.time()

    def snapshot(self):
        return {
            "saga_id": self.id,
            "created_at": self.created_at,
            "steps": [asdict(s) for s in self.steps],
        }

    def plan_compensation(self):
        """逆序补偿 —— 已完成的步骤按倒序一个个撤,撤一个确认一个。"""
        plan = []
        for s in reversed(self.steps):
            if s.state != StepState.DONE:
                continue
            contract = STEP_CONTRACT[s.name]
            if contract["compensate"] is None:
                # 无补偿:不能悄悄跳过,必须显式记录为"需要人工"
                plan.append({"step": s.name, "action": None,
                             "action_required": "manual",
                             "reason": "该动作无反向接口"})
                continue
            plan.append({"step": s.name,
                         "action": contract["compensate"],
                         "action_required": "auto",
                         "strength": contract["strength"]})
        return plan

    def record(self, name, state, response=None, error=""):
        s = self._get(name)
        s.state = state
        s.attempts += 1
        s.updated_at = time.time()
        if response is not None:
            s.response = response
        s.last_error = error
        return s

    def in_doubt(self):
        return [s.name for s in self.steps
                if s.state in (StepState.IN_DOUBT, StepState.PENDING)
                and time.time() - s.created_at > 300]

    def _get(self, name):
        for s in self.steps:
            if s.name == name:
                return s
        raise KeyError(name)


def classify_outcome(response, transport_error):
    """把一次调用的结果归成三态。这一步是整个 Saga 的地基。"""
    if transport_error is None:
        return StepState.DONE, response
    if getattr(transport_error, "definitely_not_sent", False):
        # 连接都没建起来,对方一定没收到,可以安全重试
        return StepState.PENDING, None
    if getattr(transport_error, "server_replied", False):
        # 拿到了错误响应:这是确定性的失败
        return StepState.FAILED, getattr(transport_error, "body", None)
    # 发出去了但没等到回音 —— 最难处理的一类
    return StepState.IN_DOUBT, None

この実装の中には、よく飛ばされがちな設計判断が 1 つある。plan_compensation は、逆向きの API を持たない手順に対してスキップを返さず、「要人工対応」という明示的な記録を返す。多くの実装はここで単純に continue してしまい、その結果、失敗した取引がシステム上では「一部完了」と表示され、補償できない手順は取り消されたことも、人に知らされたこともない。明示的に列挙しなければ、誰かが対応する。

七、Agent のシーンで 2PC が本当に落地する 3 つの形態

ここまで見てきたとおり、システムをまたぐ古典的な 2PC は存在しない。しかし「まず prepare、次に commit」というこの意図そのものが極めて価値がある。「コミットが途中まで進んだ」という種類の問題をまるごと取り除けるからだ。したがって Agent が実システムに接続するシーンでは、2PC のセマンティクスを劣化させて 3 つの実現可能な形態へ再構築し、それぞれの形態の失效条件をはっきりさせておく価値がある。

形態 1:二段階の確認(準備態 + コミット態)

前提は、相手の API が「下書きの作成」と「確定の適用」の 2 つのアクションを備えていることである。財務系やチケット系の SaaS にはしばしばこの設計がある。まず POST で下書き番号を返し、続いて PUT で確定する。使えるならこれが最もきれいな選択だ。prepare の段階で相手がすでにリソースを確保しているからである。

この形態の重要なエンジニアリング上の要点はcoordinator のクラッシュ復旧である。coordinator はまず「これから X をコミットする、相手の下書き番号は D」という記録を永続化してから、confirm を発行しなければならない。順序を逆にすると——先に confirm を発行してから記録する——途中でプロセスが落ちたとき、どちらの下書きを確定すべきか永遠に分からなくなる。

形態 2:shadow テーブル + アトミックな publish

相手に下書きの仕組みがないときは、自分の側で作り込める。すべての書き込みをまずローカルの shadow テーブルへ落とし、状態は staged として、ユーザーは閲覧でき、変更でき、取り消せる。確定のあと、ひとつの publisher が shadow 行を相手の API 呼び出しへ翻訳し、成功したら shadow 行に published を付け、相手が返した ID を書き込む。

この形態は「コミットするかどうか」の決定を完全に自分の側に握る。したがって確認点是あらゆる UI アクションに置けて、例えば「publish の dry run」(パラメータだけを計算して実際には呼ばない)にも対応できる。代償は二重書き込みと整合性の遅延である。shadow テーブルは成功を主張するが、相手は失敗しているかもしれない。対策は shadow 行に published 状態と sync_error 状態を持たせ、両者を後から揃える定期ジョブを用意することだ。

形態 3:書き込み前スナップショット + 一方向のコミット

最も弱いが最も汎用性のある形態である。あらゆる書き込みの前に、対象オブジェクトの完全な現時点の状態を取得して保存し、書き込みそのものは単発で分割不可能な呼び出しとする。後から問題が見つかったら、スナップショットで復元する。この形態が求めるのは原子性ではなく、トレーサビリティ(可追回性)である。

適用範囲は前の 2 つよりずっと広い。前の 2 つはどちらも相手の協力が要るからだ。欠点は、復元操作そのものがまた書き込みである点であり、理論上はそれも失敗しうる。したがってスナップショットは復元を決定的にできるだけの情報を持たなければならない。金額、状態、バージョン番号はすべて保存し、diff だけを保存してはいけない。

失效条件:どんなときに二段階は崩れるか

3 つの形態の失效条件を並べて見ると、いずれも最終的に同じ一組の根本原因に収束していることが分かる。

失效条件現象根本原因緩和手段
prepare の後に coordinator がクラッシュ相手のリソースが恒久的に占有され、業務は下書きを見られないsuspending transactionprepare には必ず TTL を付け、TTL 到達で相手が自動解放する。coordinator は復旧スキャンを持つ
commit リクエストが失われる相手は commit 済みだが自分は失敗と記録し、重複 commit してしまう不確実な結果を失敗として扱うcommit 段階では必ず照会してから retry し、台帳は unknown 状態を区別する
human 確認が二段階をまたぐユーザーが確認時に見たのは 5 分前のデータで、実際の実行時にはデータが変わり残っているTOCTOU確認チケットはオブジェクトのバージョン番号に紐づけ、実行前に再検証し、不一致なら破棄してやり直す
cross-tenant のデータが同じ saga に入るテナント A の確認が、テナント B の commit を通してしまうチケットがテナントに紐づいていないcapability チケットは tenant_id を必須とし、署名がその値を含む
下書きのセマンティクスが保証されない下書き作成が実は下流の通知を発生させている「prepare」が本当 prepare ではない接続時に実測する。ドキュメントの「draft」という語を信じるな
shadow テーブルが無制限に増える過去の staged 行のセマンティクスが現在の行と衝突するshadow テーブルに lifecycle 管理がないstaged 行は TTL で掃除し、掃除前に必ず published か取り消し済みであること
publisher が単一障害点publisher が落ちると、すべての staged 行が滞留するキュー化されていないpublish は永続化キューを通し、再演可能とし、idempotency key を shadow 行の ID に紐づける
バージョン番号をモデルが与える楽観的ロックが形だけになる並行制御のパラメータを信頼できないバージョン番号は読み取りツールが返し、そのまま返す。ツール層が出所を検証する

この表で最も強調すべきは 2 行目と 3 行目である。2 行目は「重複 commit」の根源であり、その筋が通らない点はこうだ。すでに半分は正解(台帳に unknown 状態がある)を実装しているのに、コードに if error: retry と書いたせいで、その設計そのものを壊してしまう。3 行目は Agent シーン特有である。従来のフローでは確認と実行の間はミリ秒単位だが、Agent シーンでは数分、あるいは人手待ちのキュー 1 本分の隙間があり得る。

二段階を使わないほうがいいとき

二段階にはコストがある。往復が 1 回増え、状態が 1 つ増え、復旧ロジックが 1 つ増え、維持すべき不変条件が 1 つ増える。操作そのものが低リスクで、補償可能で、失敗時の影響範囲が小さいなら、二段階を足すのは正味の赤字だ。

実行可能な判断基準はこうだ。この書き込みが失敗したとき、それが「部分的に適用された」状態になることを許容でき、ユーザーがその状態を UI で理解できるか。許容できるなら二段階は不要だ。単純にやって、信頼性がある。許容できない場合、特に金銭や対外的な約束に関わる場合は、二段階あるいはそれと同等の強い確認機構を検討する。

  • 二段階が解きますのは「コミットが途中まで進んだ」問題であって、「コミットを間違えた」問題ではない。間違えたのは検証、確認、補償で解決する。両者を混同すると、正しい層ではない場所で時間を費やすことになる。

八、長いトランザクションの timeout は誰が持つか

SaaS をまたぐ複数ステップの操作は本質的に長いトランザクションであり、長いトランザクションは避けて通れない問い——「いつ諦めるか」——に突き当たる。ここには具体的なエンジニアリング判断がある。timeout の予算は、各ステップが自分の既定値を持つのではなく、Saga の coordinator が残りのステップ数に応じて配分すべきである。

理由は直接的である。5 ステップの Saga で各ステップに 30 秒の timeout を置けば総予算は 150 秒になるが、ステップ間の待ち時間は計上されていない。逆に各ステップを 30 秒で計算すると、ユーザーは最初のステップで 29 秒待ってから失敗し、残りの 4 ステップには一切到達しない。正しいやり方は、Saga 全体に総予算を与え、coordinator のなかで残予算を維持し、各ステップの呼び出しを発する前に残量がそのステップの p99 遅延を賄えるかを先に判定することだ。足りなければ発さずにすぐ補償へ入る。

もう 1 つ見落とされがちな点がある。human 確認にかかる時間は技術的な timeout には算入すべきではないが、business 上の時間制限には算入すべきである。1 つの操作が 3 日間の人手承認を待つなら、30 分の技術的 timeout は意味を持たない。「3 日経ってもこの予約が在庫を占めたままだ」のは立派なインシデントである。正しいやり方は、予約リソースの TTL を human 確認の想定時間より明示的に長く設定し、その長さについて統計と alert を取ることだ。

九、human 確認点はどこに入れるべきか

確認点の設計は、この種のシステムでもっともトレードオフが難しい部分である。少なすぎるとリスクの露出が大きく、多すぎると人は「確認を押す」という筋肉記憶のような習慣が芽生え、確認がない場合より危険になる。多くのシステムの失敗モードは後者だ。確認ダイアログを押す人は内容を見ておらず、承認フローの形式的意味が実際的意味を上回っている。

したがって正しい問いは「確認するかどうか」ではなく、どの位置で確認するか、各位置で人に何を見せるか、そして確認という行為自体が信頼に値するか、である。

3 つの判準が確認の必要性を決める

最初の節の不可逆性の区分を使えば、1 回の書き込みに確認が必要かどうかが 3 つの判準で一緒に決まる。1 つ目は不可逆の程度、つまり「取り消した後も外部世界はなかったことにできるか」である。2 つ目は影響範囲で、1 回の操作がいくつのオブジェクト、いくらの金、いく人に及ぶか。3 つ目は検知可能性で、この操作を間違えたときに早く発見して訂正できるか。発見できる操作では、確認の価値は低くなる。後から直すコストが高くないからだ。

この 3 つの判準を組み合わせると実用的な結論が出る。不可逆で、影響範囲が広く、気づきにくい操作は必ず確認する。不可逆だが影響範囲が小さい操作は、確認と大量処理を組み合わせる。可逆な操作には確認をしない。可逆な操作に確認を付けるのは純粋なコストで、ユーザーは毎回押すが、押し間違えても取り消せる。

6 つの候補位置と、それぞれで見せるべきもの

典型的な跨システムタスクを分解すると、確認を挿入できる位置が 6 つ現れる。それらは互いに排他的ではないが、どの組み合わせにするかは business 次第であり、各位置で見せる情報は異なる。

  • 計画後の実行計画の確認。Agent が計画を出し、まだデータに触る前に確認する。見せるべきは「何が起きるか」のリストである。どのオブジェクトが、どのフィールドが、どれくらいの金額が、どのステップが不可逆か。この層で止めるのは「理解のズレ」だ。Agent がやりたいことと、ユーザーがやりたいことが一致していないケースを止められる。すべての副作用が起きる前なので、これが最も価値のある確認点である。
  • 不可逆な動作の直前の確認。第三者に対して不可逆な操作を呼び出す直前に確認する。見せる内容は相手に送信する完全なパラメータの原文でなければならない。要約ではない。要約は細部を落としてしまうが、細部こそがユーザーが判断の材料にできるものだ。たとえば通知の送信なら、要約は「通知メールを送信します」で、ユーザーが判断できる材料は何もない。原文が「【注文】お客様のお荷物は本日発送されました。追跡番号 SF1234 です」なら、ユーザーは一目で送ってよいかどうかがわかる。
  • 批量操作の段階的な確認。同じ種類の操作が N 個のオブジェクトに影響するとき、N 回ダイアログを並べるべきではない。段階の分け方は影響範囲に従う。1 個から 3 個のオブジェクトなら 1 件ずつ確認する。4 個から 50 個なら分類したサマリー(たとえば「32 人の顧客に変更通知を送信します。対象は 3 種類の注文です」)にサンプルの明細を添える。50 を超える場合は専用の批量承認フローに通し、権限のあるロールがリスト上で 1 件ずつチェックする。
  • 跨システムの状態不一致に対する人手による裁定。照合で「成功と言っているが実際は失敗」、あるいは「失敗と言っているが実際は成功」と判明したとき、この停止地点は確認ではなく裁定である。両側の状態の対比と推奨アクションを表示し、既定ではどちらの側も選択状態にしない。
  • 補償失敗後の人手による意思決定。Saga の補償が途中で失敗したとき、「取り消し続けるか、現状を受け入れるか」を人が決める必要がある。取り消せたものと取り消せないものを表示し、取り消せない部分については結果を明確に説明する。
  • 権限昇格のための一時的な認可。ある操作に現在のチケット範囲を超える権限が要るとき、「実行の確認」ではなく「一時的な権限昇格」である。「今回追加で何の権限を得るか、どのオブジェクトが対象か、有効期限は多久か」を表示する。

確認疲労と降格戦略

確認機構の最大の失敗は、人間とシステムとの間のゴム印への退行である。識別サインは 3 つある。確認ダイアログの滞在時間が短くなり続けている。確認の通過率が 100% に近づいている。ユーザーが「なぜいつもこれを押すのか」と文句を言い始める。このうち 1 つでも現れたら介入すべきであり、警告の文面を増やし続けてはならない。

効果的な手段は、確認と実行を分離することである。ユーザーには毎回ではなく一度に、「この種類の操作のポリシー」を確認させる。ポリシーは「Agent が 200 円未満の注文の送料を調整してよい」「チケットに内部メモを追加することはよいが状態変更は不可」でよい。実行時にはシステムがポリシーに基づいて自動的に通し、ポリシー外のものだけを 1 件ずつ確認する。これで人が見る対象は「数万回の操作」から「数本のルール」に下がり、ルールは監査でき、改訂でき、追跡できる対象になる。

もう 1 つの手段は、確認内容の情報の密度を過去の行動に応じて調整することである。ある Agent が 3 か月連続で同種の操作を提出し、手戻りが 1 件もないなら、確認を「一括ページ + ワンタッチ承認」まで降格してよい。ただし追跡可能な記録は残す。逆に、直近で取り消しが必要になった操作があれば、該当種類の確認を改めて 1 件ごとの表示へ引き上げる。降格も昇格も明確なルールで決めるべきで、その場限りの判断で決めてはいけない。

TOCTOU:確認と実行のあいだの時間幅

これは Agent シーン特有の問題で、しかも見落とされやすい。ユーザーが確認ダイアログを見たときのデータと、システムが実際に実行するときのデータは、すでに違う可能性がある。間隔は数秒かもしれない(ユーザーが考えている時間)。数時間かもしれない(ユーザーは承認したが、リクエストがキューの中で並んでいる)。

対策は確認をバージョン番号つきの証跡にすることである。確認時に主要オブジェクトのバージョン番号や内容ハッシュを記録し、実行前に読み直して比較し、不一致なら確認を破棄してやり直す。この方法は面倒に聞こえるが、「ユーザーが承認したのは A なのに、システムが B に変えてしまった」という種類の事故をまるごと回避できる。そしてこの種の事故は事後では極めて説明しにくい。

同時に、確認の証跡には有効期限がなければならない。10 分前に承認された操作を、2 時間後に実行すべきではない。business の条件は変わっている可能性があり、ユーザーが承認したのはそのときの文脈だったからだ。有効期限の設定は business 自体の時間敏感性を参考にできる。1 時間のあいだに金額が変わるなら、証跡はもっと短くすべきである。

確認は認可ではない

最後に起こしやすい誤りは、「ユーザーが確認を押した」ことをそのまま「ユーザーがこの操作を認可した」とみなすこと、そして監査に「ユーザーの認可により実行」と書くことだ。この 2 つは本質的に別である。確認とは一度の具体的な実行への知情同意であり、認可とは一種類の操作範囲への許可である。混同すると責任追及で問題が起きる。ユーザーはこう言い返せるのだ。「私はあのダイアログを見ただけだ。以後ずっと自由に変えていいとは言っていない」と。

したがってシステムには 2 つの独立したものがあってよい。確認証跡(今回の実行に対応し、パラメータ原文、データスナップショット、バージョン番号、有効期限を含む)を実行記録に記録し、認可ポリシー(一種類の操作に対応し、範囲、上限、有効期限、付与者を含む)をポリシー庫に記録する。実行記録は自分が依拠したポリシーバージョンを参照し、事後 2 つの別の問いに答えられる。「今回なぜ許されたのか」(ポリシーを調べる)、「この人は知っていたのか」(確認証跡を調べる)。

  • 多くのチームは 2 つのうち片方しか持っておらず、その結果、権限がどこから来たのかを追及できないか、当事者が知っていたかどうかを説明できない。監査においてこれは 2 つの独立した問題であり、2 つの独立した証拠が必要である。

十、監査記録:事後で責任を追うために何を残すか

ここまでの六つの節で話してきたのは「どう正しくやるか」だが、この節で扱うのは「間違えたときにどう説明できるか」だ。この二つは判断基準がまったく違う。ある処理は監査ログがゼロでも完全に正しいことがあるし、逆にどの行にもログが残っていても事後で責任を追えないこともある。残した物が違うからだ。

Agent のシーンではこの問題はもっと鋭くなる。従来のシステムなら、ひとつの書き込み操作のコンテキストは確定している。ユーザーが押したボタン、フォームに入力した値、コード上の分岐——それだけだ。ところが Agent の場合は、モデルのバージョン、プロンプト、その時点のコンテキストに何が含まれていたか、ツールの実装バージョン、モデルが推論した中間の判断まで含まれる。「なぜそのときそうをしたのか」を事後で再現するには、ひとつの環が欠けているだけで鎖が切れる。

責任を追うときに答えるべき4つの問い

監査フィールドを設計するときの出発点は「どのフィールドを記録すべきか」ではない。「事後でどの問いに答える必要があるか」だ。責任を追うのに使える記録なら、最低限次の問いに答えられる必要がある。

  1. 誰が発起したのか?人が能動的に始めたのか、スケジュールタスクなのか、上流システムからのコールバックなのか、Agent 自身が継続実行したのか(前のターンがタイムアウトしたので retry した)。このフィールドが責任帰属の第一層を決める。
  2. それは何に基づいてそうしたのか?当時の認可ポリシーが何だったか、確認証跡が何だったか、直前のステップの出力が何だったか。これが欠けると、「システムがそのように設計されている」と「誰かが臨時に設定を変えた」を区別できない。
  3. 当時のシステムは何の状態だったのか?変更対象のオブジェクトが変更される前の完全な内容、関連する業務パラメータ、当時の在庫や残枠、状態機械上の位置。これが欠けると事後で影響範囲を再構成できず、その操作が当時妥当だったかどうかも判断できない。
  4. 実行結果はどうだったのか?相手が何を返したか、どれだけ時間がかかったか、何回 retry したか、最終状態が成功・失敗・不明のどれだったか。

この4つのうち、3番目がもっとも抜けがちで、しかも事後で責任を追うにも事後で修復するにも必須の要素だ。多くのシステムは操作と結果だけを残し、変更前の状態を残さなかった。問題が起きたら記憶と推測でしか答えられない。書き込み前のスナップショットはこのようなシステムの中で費用対効果がもっとも高いフィールドのひとつで、代償はオブジェクトを1回シリアライズするだけで済む。

イベントモデルと必須フィールド

監査記録はログ行ではなくイベントストリームであるべきだ。ログ行はサンプリングされたり、ローテーションで上書きされたり、ログシステムにレベルで絞り込まれたりするが、監査イベントにはそのようなことがあってはならない。イベントごとに構造は違うものの、いくつかのフィールドグループがすべてのイベントに必須だ。

フィールドグループフィールド用途
身元event_id、run_id、step_id、actor(人またはシステムの識別子)、on_behalf_of(誰として実行したか)イベントを具体的な実行経路に紐づけ、自律実行と代理実行を区別する
因果parent_event_id、caused_by(ユーザー指示・上流イベント・再試行)意思決定の連鎖を再構成し、「このパラメータはどこから来たのか」を特定する
モデルmodel_id、prompt_hash、tool_version、context_manifest当時の判断根拠を再現する。prompt_hash のほうが全文を保存するより手堅い
意図action、target、requested_fields、rationaleやりたかったことを示す。「実際にやったこと」と区別する
事実before_snapshot、after_snapshot、external_request、external_response影響範囲の評価と修復の基礎になる
認可policy_id、policy_version、approval_ticket_id、granted_scope「なぜ許可されたのか」「誰かが知っていたか」に答える
結果state(succeeded/failed/unknown)、attempts、duration、error_class結果の既知性。unknown 状態は必ず明示的に存在させる

rationale というフィールドに注意してほしい。これはモデルが主張した理由を記録したものであって事実ではない。だから証拠として扱ってはならず、補助の手がかりにすぎない。本当の証拠になりうるのは external_request、実際に送信されたパラメータの原文だ。この区別は重要で、事後でモデルが「金額は100元だと確認した」と言っているのに、実際に送られていたのが1005元だった、という2つはまったく違う調査の起点になる。

改ざんできないこと:記録そのものに信頼性を与える

監査記録が書き換えも削除も可能なままなら、責任を追うシーンでは価値がない。対策には3つの階層があり、投入の小さい順に並べると次のようになる。

  • 最下層:追記のみ、改変しない。ストレージには append-only のテーブルかオブジェクトを使い、INSERT だけを許可する。データベースの権限設定でアプリケーションアカウントに UPDATE と DELETE を持たせない。このステップでアプリケーション層の操作ミスと、経路の一般的な攻撃の大部分は防げる。
  • 中間層:ハッシュチェーン。各レコードに直前のレコードのハッシュを含めて鎖にする。どれか1つが書き換えられたか削除されれば鎖が切れ、再計算で検知する。blockchain を入れる必要はなく、データベースにフィールドを1つ足すだけでいい。ただし書き込み時に同期して計算する必要がある。
  • 上位層:チェーンのルートハッシュを定期的に外部に anchor する。自分自身のデータベースの管理者権限すら信用しないなら、ルートハッシュを定期的に自分の管理の外にあるチャネル(別アカウントのストレージ、あるいは追記専用の外部サービス)へ送る。このステップの価値は技術にあるのではなく、心理的な閾値にある。「データを書き換える」という行為が「技術的なアクション」から「外部に痕跡を残すアクション」に変わるからだ。この変化が内部の行動に与える影響は、技術的な保証より大きくなることが多い。

実装:追記専用のイベントチェーン

# 只追加事件链:每条事件带前一条的哈希,任何篡改都会在重算时暴露。# 注意这里没有 UPDATE 路径 —— 表结构上就不提供修改入口。import timeimport jsonimport hmacimport hashlibfrom dataclasses import dataclass, asdict, field# 哈希链的锚定目标与普通记录是分开的存储,签名密钥也分开ANCHOR_INTERVAL = 300@dataclassclass AuditEvent:    event_id: str    run_id: str    step_id: str    actor: str                 # 人 / 定时任务 / 上游系统    on_behalf_of: str          # 代表哪个租户或哪个用户    action: str                # 想做的事    state: str                 # succeeded / failed / unknown    intent: dict = field(default_factory=dict)    # 意图与理由(辅助线索)    before_snapshot: dict = field(default_factory=dict)    after_snapshot: dict = field(default_factory=dict)    external_request: dict = field(default_factory=dict)    external_response: dict = field(default_factory=dict)    authorization: dict = field(default_factory=dict)  # policy + approval ticket    model_context: dict = field(default_factory=dict)  # model/prompt hash/tool version    attempts: int = 1    duration_ms: int = 0    prev_hash: str = ""    hash: str = ""    ts: float = 0.0class AppendOnlyAudit:    def __init__(self, store, chain_key: bytes, sign_key: bytes):        self.store = store        self.chain_key = chain_key        self.sign_key = sign_key        self.last_hash = store.tail_hash() or ("0" * 64)    def append(self, event: AuditEvent):        # 关键顺序:先算哈希再落盘,落盘后推进 last_hash。        # 如果进程在两步之间挂掉,这条事件丢失但链未断;        # 如果反过来先推进后落盘,链会指向一条不存在的记录。        event.prev_hash = self.last_hash        event.ts = time.time()        payload = json.dumps(asdict(event), sort_keys=True,                             ensure_ascii=False, default=str)        event.hash = hmac.new(self.chain_key, payload.encode(),                              hashlib.sha256).hexdigest()        self.store.insert(event)          # 只 INSERT        self.last_hash = event.hash        return event    def verify_chain(self, limit=100000):        """重算校验。返回断裂位置;返回 None 表示链完整。"""        prev = "0" * 64        for e in self.store.iter_from_head(limit):            if e.prev_hash != prev:                return {"broken_at": e.event_id, "reason": "prev_hash 不匹配"}            body = dict(asdict(e))            claimed = body.pop("hash")            body["hash"] = ""            payload = json.dumps(body, sort_keys=True,                                 ensure_ascii=False, default=str)            # 落盘时 hash 字段为空参与计算,复算时保持一致            recomputed = hmac.new(self.chain_key, payload.encode(),                                  hashlib.sha256).hexdigest()            if recomputed != claimed:                return {"broken_at": e.event_id, "reason": "内容与哈希不符"}            prev = e.hash        return None    def anchor(self, anchor_store):        """把链的当前根哈希 + 签名推到外部存储,外部拿不到内容只能验证。"""        body = json.dumps({            "head": self.last_hash,            "count": self.store.count(),            "ts": time.time(),        }, sort_keys=True).encode()        sig = hmac.new(self.sign_key, body, hashlib.sha256).hexdigest()        anchor_store.put({"head": self.last_hash, "count": self.store.count(),                          "sig": sig, "ts": time.time()})        return sig    def replay_for_incident(self, run_id):        """事故复盘的主查询:按 run 拉全链路,缺失的因果关系要显式标出。"""        events = [asdict(e) for e in self.store.by_run(run_id)]        by_id = {e["event_id"]: e for e in events}        chain = []        for e in sorted(events, key=lambda x: x["ts"]):            e["causal_parent_present"] = (                not e.get("parent_event_id")                or e["parent_event_id"] in by_id)            chain.append(e)        # unknown 状态必须单独挑出来,这些是需要人工跟进的对象        unknown = [e for e in chain if e["state"] == "unknown"]        return {"run_id": run_id, "events": chain, "needs_followup": unknown}

保存期間、マスキング、アクセス制御

監査データの保存方針は内容ごとに段階分けすべきで、すべてに同じ設定を当てはめてはいけない。不可逆な操作のイベント(支払い、外部送信、権限変更)は、関連する紛争の期間と規制要件を覆えるだけの長さで保存する必要がある。ふつう年単位だ。そして期限が来たときの削除は「消去」ではなく「アーカイブ」にすべきだ。アーカイブした部分も要約は検索できるが、原文はコンプライアンス上の要件に従って扱う。

ここには見落とされがちな緊張関係がある。監査は「送信した完全なパラメータ原文」を記録したいが、そのパラメータには個人情報を含む可能性がある。一方、保存期間は業務データよりもはるかに長い。解決策は機微なフィールドについて、記録時に構造化されたマスキングを行うこと——フィールド名とハッシュと一部の接頭辞・接尾辞を記録し、全文の平文は残さない。全文の平文は独立した権限と独立した暗号化された狭いストレージに置き、アクセスには二次承認を要し、さらにアクセス自体も監査に入れる(さもないと「誰がユーザーの支払い情報を見たのか」には永遠に答えられない)。

最後の1つ。監査データそのものは、有用な結論に到達できる検索できること。フィールド設計のときに「次に障害が起きたら何を探すか」を決めておく必要があり、通常はテナント別、期間別、操作対象のID別、操作種別別、最終状態別、この5つの次元でインデックスを張る。特に「最終状態で unknown を絞り込む」操作は、pending 状態の操作を点検するときの主要なクエリ経路だ。インデックスがなければ点検ジョブはフルテーブルスキャンになり、誰も動かさなくなる。

十一、エンドツーエンドで一度たどる:システムをまたぐ「まとめ発送」のタスク

ここまでの十節では、各メカニズムをばらばらに見てきた。この節ではそれらを繋ぎ合わせて、実際に一度走らせてみる。設計上のトレードオフの代償は、はじめから最後まで通した流れのなかで動かしてはじめて見えてくるものだからである。シナリオとしては、連携の作業をするチームがほぼ必ずぶつかるとされる典型的なサポートチケットをえらぶ。顧客が会話のなかで「先週入れた4件を1件にまとめて送ってほしい、物流通知は4通もほしくない」と言った——という要望だ。見れば一行の依頼内容だが、システムとして落とすと4つのシステムと6種類の状態を動かすことになる。しかもそのうち2種類は不可逆な外部アクションである。

一つの文から、制約つきの一組のアクションへ

Agent がこの一文を読んだとき、最初にすることはそれを業務の意味に変換することであり、そのままインターフェースの呼び出しに変換することではない。ここにはシステムが提供し、モデルが自分ででっち上げてはいけない知識がある。どの注文がまとめられるのか、まとめる際の制約は何か、まとめたあとの送料はどう計算するのか、請求書、注文をまたいでまとめられるのか。こういうルールは店側の業務取り決めのなかにあり、互いに矛盾する何枚かの運用ドキュメントに書かれていて、しかも3か月前に一度改定されている。

正しい分け方はこうだ。モデルには「自然な言語のなかに意図と引数を見つける」仕事を任せ、ルールエンジンには「その引数が合法な操作になるかどうかを判定する」仕事を任せる。前者はモデルの得意とするところであり、後者はモデルが信頼できない部分だ。合法性判定までモデルに任せると、たいていこういう結論になる——この4件はまとめられる。見方の比較では、彼らが同じバッチのように映るからだ。しかし実際には、うち1件はクーポンを使い、1件はすでに部分発送済みで、もう1件は予約販売分だった。モデルはこのことを知らない。ユーザーのその一文のなかにない情報であり、モデルのコンテキストにもない情報だからだ。

しかして、最初の一歩は計画ではなくデータの取得である。システムは4件の注文の現在の状態、住所、支払い方法、利用可能なクーポン、すでに確保されている在庫、請求書の状態をまとめて読み出し、このタスクの入力スナップショットとして扱う。ここから先のすべての判断は、このスナップショットに基づいて行われる。モデルがいつでも取り直すと変化するクエリ結果ではなく、このスナップショットに基づく。この一点が、後述のすべての確認点で表示される内容の自己整合性を決めている——ユーザーに見せているもの、実行時に検証しているもの、監査に記録しているものが、すべて同じデータを使うことになるのだ。

最初の確認点:実行結果ではなく実行計画を表示する

ルールエンジンが走ったあとに生じるのは、実行可能なスクリプトではなく、構造化されたアクションのリストである。ここでようやくモデルの出番が来る。このリストを人間読める言葉につなぎ、どのステップに使用者への追加リスクの説明が必要かを指摘させる。「4件のうちの1件はクーポンを使っているため、まとめたあともクーポンは保持されず、割引額の払い戻しはしない”——このように。モデルがこの段階で行うのは表現であって、意思決定ではない。

利用者に表示される確認ページには、次の内容が含まれるべきである。まとめられる予定の注文一覧と、それぞれの重要な情報、起こる予定の状態変化の種類ごとに何が起きるか、どれが不可逆か、途中で失敗したらどうなるか、利用者の判断が要る選択肢はあるか。ページに現れてはならないのは、この操作に対するモデルの「自信度」を示す記述だ。それは利用者が判断の根拠にできない数値にすぎない。

この確認点の価値は「理解のズレ」を止めるところにある。そして理解のズレは、事故のなかで最も事後の修復が難しい類のものである。注文がすでにまとめられ、通知もすでに送られている状態で、ユーザーに「実は確認したかっただけだった」と伝えても、もう意味がない。逆に、副作用がまだ一つも発生していない段階で理解を訂正すれば、コストはほぼゼロになる。したがって、タスクが不可逆なアクションを含むなら、最初の確認点はあらゆる副作用より前に置くべきだ。シナリオの一部しか覆えていなくても、そうあるべきである。

途中まで進んで止まる:請求書発行のインターフェースがタイムアウトする

計画の承認のあとに実行が始まる。最初の二つは順調だった。まとめ要求の送信に成功し、運送伝票番号の生成に成功し、物流システム側の伝票状態が「引き取り待ち」に変わった。第三歩、電子請求書サービスに請求書の発行を要求したところ、相手は処理のなかで接続をリセットし、クライアントは応答をまったく受け取れなかった。

これは全文で繰り返している、もっとも扱いが難しい状態だ。相手が請求書を出したのか、出していないのかが分からない。ここに注意しておきたいのはリトライの落とし穴である。ふつうの書き方のままタイムアウト例外を捕まえてリトライすると、二つの悪い結果のどちらかが起きる。相手が実際にはすでに請求書を発行済みで、リトライの結果、同じ一件の業務についての請求書が二枚に増える。あるいは、相手は発行していないのに最初のリクエストが相手のキューに積まれたままで、リトライによってもう一枚が発行される。前者は照合をするまでまったく見つからない。後者は数秒後に自ら「二重発行」として露出するので、まだ運のよい部類である。

補償の途中で止まる

4件のうち1件が返金不可のクーポンを使っていた場合、ルールエンジンの結論は「この1件はまとめられない、単独で発送する」になる。この制約に気がついたのはタスクが実行の途中まで進んでからで、つまり部分的な成功である。システムは直前の2ステップを取り消すのか、それとも先に進むのかを決めなければならない。

判断の基準は補償の強さである。運送伝票番号は生成済みだがまだ引き取りされていないので、その取り消しは弱い補償に当たる。伝票番号は生成された時点で物流システムのキューに入っていて、まだ誰も引き取りに行っていなくても、相手の業務システムにはその記録が残っている。注文のまとめ取り消し自体は強い補償に当たる。本質的にデータ構造の変換なので、分解しても目に見える痕跡が残らない。顧客への通知はまだ実行されていないので、補償計画には含まれない。

したがって補償計画は、まず注文のまとめを取り消し、次に運単を取り消すという順序になる。まとめの取り消しは決定的で冪等なので、そのまま呼べばよい。運単の取り消しには物流のキャンセルインターフェースを呼び出すが、このインターフェースには業務ルールがある。生成済みかつ未引き取りの運単は通常ならキャンセルできるが、物流会社すでに拠点を割り当てて追跡情報を送信していた場合、キャンセルは失敗する。失敗したときに、全体の補償が失敗したと単純に考えてフロー全体をリトライするのは誤りである。補償の粒度ごとにこのステップだけを補償失敗として記録し、人手のキューに入れるべきである。

最終的にユーザーに見せる画面は、この状態を正直に反映していなければならない。まとめは取り消されたが、運単のキャンセルは失敗した(理由は物流が拠点を割り当て済みのため)。対処としては人手サポートに連絡して元の運単をキャンセルするか、あるいは利用者が受け取った後に返金申請をする、と伝える。これを操作失敗リトライしてくださいと表示するのは情報が失われるだけで、ユーザーはリトライして、重複した運単を大量に受け取ることになる。

事後に取り出せるもの

このようなタスクが実行されたあと、監査システムには次のような一連の記録が残っているべきである。ユーザーの元の要求の全文と、そこから読み取った意図のパラメータ、タスク開始時点の4件の注文の完全なスナップショット、ルールエンジンの判定結論と適用されたルールのバージョン、計画の承認証拠(利用者に表示したページの内容のハッシュとタイムスタンプを含む)、各ステップの外部リクエストの原文とレスポンス、保留になったステップの三状態を示すマーカーと観測結果、補償計画の全ステップとその結果、最後に利用者に渡した説明文。

この一連の記録が答えられる実際の問いは次のようなものである。なぜこの1件がまとめられないと判定されたのか。利用者はクーポンを回収されることを知っていたのか。この件は重複処理されていないか。そして、もし後から業務側がその時点では画面にその注意書きが表示されていなかったと主張しても、ページのスナップショットとそのときにレンダリングされたテンプレートのバージョンを突き合わせれば答えが出る。

できなかったことも正直に記しておく必要がある。当時のルールバージョンが残っていなければ、なぜその判定になったのかを説明できない。書き込み前のスナップショットが残っていなければ、その4件の注文の後続状態がどれだけ影響を受けたのかを評価できない。監査の価値は最も完全な一環ではなく、最も弱い一環で決まる。

十二、接続層の地味だが成否を分ける工学的細部

ここまでの十二節で話してきたのは制御の流れである。いつ止めるか、いつ取り消すか、いつ人に頼むか。しかし実際のプロジェクトで Agent チームがやられるのは、制御の流れがすべて正しくてもデータがまったく間違っている場合である。この種の問題に共通するのは、エラーが出ることではない。エラーを出さずに誤った結果だけが残り、しかもその結果はまったく妥当に見えるので、はっきりとしたクラッシュよりも発見が難しい。

同じフィールド名、二つの意味

システム横断のインテグレーションで最も多い間違いの出どころは、フィールド名が同じなのに意味が違うことだ。「金額」という名前のフィールドについて、EC プラットフォームは分単位の整数を返し、決済ゲートウェイは元単位の浮動小数点を返し、财务システムはどのインターフェースを叩くかによって税込か税抜かが決まる。Agent がユーザーのひとことから金額を抽出し、モデル・ツール・ゲートウェイ・外部システムという4つの層を巡回って渡っていくとき、各層はそれぞれ「自分は変換していないのが正しい」と思っている。

ここでの判断のポイントはこうだ。単位換算は境界で起きるべきで、業務コードの中では起きない。つまり外部システムから読み込んだ数値は、内部のデータ構造に入る前に必ず内部の統一単位へ変換しておかなければならない。そしてこの変換はツール層で強制して、業務コードに「受け取ったのは分である」という選択権を残さない。あるインターフェースが変換を忘れると、誤りは100倍という形で最終結果に現れるが、100倍する誤りと100で割る誤りは、帳票の上ではたいてい「それなりにありそうな数字」に見えてしまう。

タイムゾーンの問題は単位の問題より厄介だ。数値の換算ではなく意味の判断が絡むからだ。サードパーティのシステムが返すタイムスタンプは、タイムゾーンの指定がないローカル時刻かもしれないし、秒単位の Unix 時間かもしれないし、ミリ秒かもしれない。秒とミリ秒を取り違えると、すべての時間差が1000倍はずれた値になり、この種のずれはログ上では「データの誤り」ではなく「異常に時間がかかっている」という姿で出るから、原因究明のコストが極端に高くなる。夏時間も厄介で、現地時刻が1年に2回だけ重複するタイムゾーンがあり、素朴な時刻比較では前後関係が逆転していると判断してしまう。

現実的な対処は、内部ではタイムゾーンつきタイムスタンプに統一し、ツール層で外部の時刻を一度だけ明示的にパースしてアサートすることだ。パースに失敗した、あるいは結果が妥当な範囲を逸脱している(たとえば注文の作成時刻が未来の72時間以内になっているなど)場合は、その場で拒否して問題を接続層で露呈させる。奥までビジネスロジックに流してから気づくのでは、手遅れすぎる。

金額の精度と丸め方向

金額は個数より厄介だ。精度の要件と丸めのルールがあり、そのルールを明示的に書いておかないと、2回の計算で結果が1分ずれる。1分でも照合の段階では不一致レコードがまるごと1件生じる。複数システムにまたがる按分の場面はもっと複雑だ。ひとつの送料を3件の注文の金額比率に応じて按分すると、比率の合計は厳密には1にならず、最後には必ずどれか1件が丸め誤差を背負うことになる。

ここで規則を1本決めておくべきだ。按分のときは先頭の項目をすべて切り捨て、端数はすべて最後の項目に寄せる。この規則の価値は数学的に優れていることではなく、決定的で、再計算でき、順序に依存しないところにある。「1分まで丸めてから合計が合うかどうかを見てみる」ような実装は、対帳の問題を確率の問題に変えているにすぎない。

もう1つ、先に決めておかなければならないのが通貨だ。通貨をまたぐ場面で「100元」を外部システムに書き込むとき、どの時点のどの為替レートで換算するのか、途中に生じる為替差損益は誰が負担するのか。これらを詰めておかないと、Agent は「金額をドルに変換して」という指示を受けたときに、いまのレートを適当に当ててしまう。正しいやり方は、注文作成時にロックした為替レートを使うことだ。為替レートのような外部データソースは、業務ルールと同じように「モデルに勝手に作らせない」リストに入れるべきだ。

フィールドの欠落、null、そして「値がない」の三重の意味

多くのインターフェースでは「フィールドが存在しない」「フィールドの値が空」「フィールドがゼロ」は別の事柄だが、モデルにとってはその3つが同じ形に見えることがある。あるツールが「お届け先住所が存在しない」を空文字列で表すと、モデルは「ユーザーが住所を消してほしい」と解釈し、住所を設定するインターフェースを空の値付きで呼んでしまい、結果として顧客の住所が消える。

防御は、ツールの契約の中で各フィールドの3つの状態を明示的にモデル化して、状態ごとのデフォルト動作を規定することだ。もっとも実用的な規則はひとつ、外部から返ってきた欠落は一律に「知らない」と解釈し、「消してくれ」とは解釈させない、というものである。消すのはユーザーが明示的に指示した場合に限り、別の経路を通す。この規則は小さなことのようだが、実際に受け止めているのは、起きやすく、しかも取り消せないデータ破壊の一類だ。

SaaS の列挙値と状態機械のずれ

サードパーティの SaaS が返す列挙値は変わる、しかもそれを事前に通知してくれることはない。注文ステータスが5段階から6段階になったとか、発送ステータスの文字列の大文字小文字が変わったとか、あるステータスが廃止されたのに過去のデータにはまだ出ている。こうした変化はコードにエラーを出さない。ただ、ある分岐に永遠辿り着かなくなるか、ある判定が間違った分岐に落ちるだけだ。

対策は3つある。ひとつめは外部の列挙値を接続層で内部の列挙値にマッピングすることで、認識できない値を黙って捨ててはいけない。「不明」という区分に寄せてメトリクスを取っておいてアラートを出せば、相手が新しいステータスを足したときにすぐ気づける。ふたつめは定期的な能動的なプローブで、サンドボックス環境かテスト用テナントを定期的にひと回りさせて、出てきた列挙値を既知の一覧と差分取る。みっつめは状態機械の検証を書込み前に済ませることだ。「未払いから 곧바로 受領済みへ飛ばす」ような不正な遷移は、相手のインターフェースでは成功を返すかもしれないが、自分の業務では絶対に許されないからだ。

テナント分離はサーバー側で計算する。引数で渡すのではない

マルチテナントなシステムで最も起きやすい事故は、テナントをまたいだ読み書きだ。技術的にはたいてい、一見もっともらしい誤りが原因になっている。テナント識別子をただの通常の引数としてクエリメソッドへ渡し、呼び出し側が自分で埋める、という実装だ。Agent の場面ではこの誤りが特に起きやすい。モデルがツールの説明にそのフィールドを見ると、自分で埋めてしまう。その値はコンテキストから来る――ユーザーのひとことかもしれないし、文書検索の結果かもしれないし、ただの幻かもしれない。

正しいやり方は、テナント識別子を資格情報のコンテキストから解決し、データアクセス層でまとめて注入することだ。モデルにも業務コードにも触らせない。あわせて認可の層で明示的なアサーションをひとつ入れる。今回の操作が扱うすべてのリソース識別子が、現在のテナントに属していなければ、その場で拒否する。アサーションのコストはきわめて低いが、受け止められるのはもっとも重い種類の事故だ。しかもこの種の事故は、あとからかなり時間が経ってしか気づけない。顧客同士が互いに密告することはないからだ。

レート制限は信頼性の問題ではなく、クォータの問題だ

サードパーティの SaaS のレート制限は、Agent の場面ではもっとひどく出る。Agent は自然にバーストを発生させるからだ。ひとつのタスクが十数個のツール呼び出しを組み立てれば、数秒のうちにその全部が同じインターフェースに向かっていく。従来のインテグレーションならトークンバケットでピークを削ればよかったが、Agent の場面でピークを削るとタスクが長時間ハングし、ユーザーに見えるのは「ずっと回っている」だけの画面になる。

ここでやるべきことは、モデル自身にスケジューリングの判断へ参加させることだ。現実的な仕組みとしては、各ツールの説明に「このインターフェースは現在混雑している」と書いておき、モデルに代替経路を選ぶか分割実行させる柔軟さがある。ゲートウェイ層で機械的にキューへ並べるよりも柔軟なのは、モデルには意味を理解する力があり、どの呼び出しをまとめられ、どの呼び出しを後ろへ送れるかを判断できるからだ。代償として、この仕組みはモデルが実際にその説明を読み込んだことに依存する。だからより確実な作り方は、ツール層の硬い制約にすることだ。呼び出し前にチェックし、混雑していれば構造化された「あとで再試行」という結果をそのまま返す。そうすればモデルが受け取るのはタイムアウトではなく、明確なシグナルになる。

「モデル自身に判断を任せる」あらゆる仕組みには、必ずフォールバックとなる硬い制約を添えなければならない。前者が柔軟さを与え、後者が確定性を与える。問題が起きたときにシステムが失守しないことを保証できるのは後者のほうだ。

Webhook と非同期コールバックがもたらす確認の問題

SaaS の一部では書込み操作が非同期だ。呼び出しはすぐに受付成功を返し、本当の結果はコールバックで通知される。これは前節で述べた「結果の既知性」の問題は、はるかに遠い場所まで押しやられる。同期呼び出しのタイムアウトだけでなく、来ないはずのコールバック、重複したコールバック、順序の入れ替わったコールバックにも対処しなければならなくなる。

この種のインターフェースに対して正しいモデリングは、操作をライフサイクルを持つオブジェクトとして表現することだ。受付済み、進行中、成功、失敗、タイムアウト。同期の返却で進められるのは「進行中」までで、そのあと終端まで状態を進められるのはコールバックか、控えの問い合わせポーリングだけだ。コールバック処理は冪等でなければならない。相手が1回だけ送ることを保証しないからである。署名の検証も必須だ。コールバックは外部からの入力だからである。順序の入れ替わりにも耐えなければならない。先に届いた失敗のコールバックが、あとから届いた成功のコールバックを上書きしうるからだ。状態の進め方は機械的な上書きではなく、業務上の意味に従って判断しなければならない。

あるインターフェースが非同期の性質を持つなら、Agent 自身がループで問い合わせて待つべきではない。システムがタスクオブジェクトを保持し、Agent の責務は提出した時点で終わ도록するのがよい。画面の状態は、システムのコールバックとポーリングで更新される。これによって、Agent がループの中で繰り返し問い合わせてクォータを使い切ることも、待っている間にコンテキストが失われて状態をなくすことも避けられる。

十三、導入の順序:どれを先にやるべきか

ここまでで十数個のメカニズムを話してきたが、実際にはそれらを同時に作る必要はない。順序について判断を1つ決めておくと、大量のやり直しを省ける。最も取り消せないアクションから始める、という判断だ。最も呼び出しの多いインターフェースから始めるのではない。理由はふたつある。ひとつは、取り消せないアクションは許容の余地が最も短く、いったん問題が起きたらやり直す機会がないため、1回目で正解を出すことを強制される。もうひとつは、取り消せないアクションは呼び出し頻度が低いので、その上に仕組みを載せても保守コストは抑えられる。高頻度のインターフェースに仕組みを積み重ねると、摩擦が大きすぎてチームが迂回してしまうことが多いからだ。

具体的に言えば、第1ステップは明確なリスク一覧を作ることだ。今回 Agent に公開するすべての書き込み操作を並べ、それぞれの取り消しやすさと影響範囲を注記していく。注記の根拠はインターフェースのパスではなく、業務上の意味まで具体的に書くこと。このステップにコードは要らないが、以降のすべての作業順序を決めるもので、しかも半年後に「当初なぜこう決めたのか」に答えられる唯一の文書でもある。

第2ステップは、その一覧のうち最も取り消しにくい2〜3個のアクションに対して能力チケットを発行することだ。以降のすべての仕組みの土台になる。確認ポイントも監査も冪等台帳も、すべてこのチケットに識別子をぶら下げることになる。先にチケットを作る利点は、「今回の操作でどのオブジェクト、どのフィールドに触れてよいのか」を頭の中で整理しておかざるを得なくなることだ。これが解けないと、以降のどの仕組みも、それぞれ独自のバリデーションを作り始める。

第3ステップは、この種のアクションに書き込み前スナップショットを用意することだ。スナップショットの効果は即座に現れる。エラーを止めるわけではないが、エラーのコストを「どうにもならない」から「復元できる」へと下げられる。この低下だけで、業務側が今の段階で書き込み権限を開くことに踏み切る。業務側がもっとも抱く懸念は「何かあったらどうするのか」だ。スナップショットに人手の照合を添えれば、その問いに対する正直な答えになる。

第4ステップは監査をつなぐことだ。監査は前のステップと同時に載せなければならない。遅れるということは、監査されていない操作がすでに一定量発生していることを意味する。その操作には書き込み前スナップショットも確認の証跡も存在しないので、このひとかたまりは以後ずっと追責の対象外になる。監査そのものが完璧である必要はない。ただしイベントの骨格だけは最初に正しいものにして置く必要がある。あとから補記する項目は、そのときに記録した項目とは並びも意味も合わないことが多く、历史データの価値は追記入りの段階で大きく失われるからだ。

第5ステップがようやく冪等台帳と補償の仕組みになる。この2つは少し遅らせても構わない。主に解こうとしているのは「重複」と「部分的な成功」だ。流量を狭い範囲に収めている間は、この2つの問題の発生頻度は、構築コストを正当化するほどには高くない。確認ポイントと監査が揃い、量をふやさ始めて、実際に重複実行を観測できるようになった時点で台帳を作ればいい。そのときなら、何を相手に作ればよいかがまだ分かっている。

第6ステップは二段階コミットに関わる仕組みだ。この系統は最後に回すべきだ。コストが最も高く、複雑さも最も大きい。加えて、他の手段がすべて機能しなかったときに初めて必要になるものだからである。多くのチームが先ほどの5ステップを飛び越えて二段階だけを作り、最後に本当の問題が「コミットが途中で止まったこと」ではなく、そもそもコミットしてよいのかを確認していなかったことだと気づく。

十四、本物のシステムがつながった Agent が正しいことをどう確かめるか

この種のシステムのテストがむずかしいことは有名だ。出力が決まらず、副作用を再生できず、環境が隔離されていない。ただし、いくつかの手法は費用対効果が明らかに良好なので、優先して構築する価値がある。

記録した本番トラフィックの再生

本番で実際に起きた会話と、それに対応するツール呼び出しを記録し、秘匿化してから回帰テストのケースにする。これが解ける問題はふたつある。ひとつは変更による回帰を防ぐこと、もうひとつは「そのタスクを当時どう処理していたか」を照合できる基準にすることだ。再生のときは外部システムを、挙動が等価なフェイクサービスに置き換える。ここで重要なのは、そのフェイクサービスが本物の失敗モードを出しうるようにしておくことだ。タイムアウト、レート制限、フィールドの欠落、重複コミット——成功を返すだけではなく、それらを出すようにしておく必要がある。フェイクサービスが成功だけを返すなら、このテストがカバーするのは正常系だけ。しかもその正常系こそ、問題が最も少ない経路なのだ。

シャドウトラフィックとデータの突き合わせ

新しいロジックを本番トラフィックの読み取り専用の経路に噛ませ、「実行したら何が起きるか」を計算させつつ、実際には実行しない。そしてその結果を、実際の結果と突き合わせる。この方法の価値は本番データの分布をカバーできることにあり、単体テストでは決して作れないものを扱える点上にある。空の値、極端な値、奇妙な書式、意味が矛盾したデータ——そのような過去のデータは、本番のデータベースにしか存在しない。

突き合わせる対象は「結果が同じこと」ではなく「主要な項目が一致すること」だ。システムには本質的に非決定的な要素が多数ある。タイムスタンプ、採番された識別子、モデルの言い回し——それらは最初から一致するはずがない。本当に突き合わせるべきなのは、金額、数量、状態、対象オブジェクトの識別子といった業務項目だ。この区別を引いておかないと、突き合わせレポートはノイズで埋まり、結局だれも見なくなる。

照合タスクをテストケースの出典にする

本番環境で回っている照合タスクは、不一致を発見するたび、固定した回帰テストのケースとして積み上げていくべきだ。本文が費用対効果が最も高いと考えているのは、この做法が運用のコストを品質の資産へ変換し、しかもテストの出典が実在し、検証済みで、人為的な想像の成分が混じっていないからだ。多くのチームのテストセットは人為的に考えた境界ケースで埋まっている。現実の世界から出てきた厄介なシナリオは、ひとつもカバーされていない。

契約テストは双方向でやる

サードパーティのインターフェースについての理解には、必ず誤りが混じる。重要なのは、その誤りが例外として表面化するのではなく、静かな誤った値として現れる点だ。だから能動的に検証しなければならない。具体的には、サンドボックスのテナントで読み取り系のインターフェースを定期的にひと回りさせ、返ってきたフィールドの集合、列挙値の取りうる値、型の分布を、手元のモデルと差分取る。増えたフィールドと値は、人手で意味を確認する。同時に逆向きの検証もする。理解している契約どおりにリクエストを組み立てて送り、相手が受け入れるかを見る。この双方向の検査なら、典型的な2種類の誤りを事前に検出できる。相手がこっそり追加した、手元のコードが扱っていないフィールドがあるか、あるいは必須パラメータに対する理解が間違っているか——この2つです。

障害注入は的を絞って打つ

前に、保留、タイムアウト、レート制限、重複コミットといった状態を紹介した。故障注入の目的は、システムの分類ロジックが実際にそれらを扱えることを検証することだ。ただし注入点は正確に選ぶ必要がある。もっとも効くのはランダムなタイムアウトではなく、レスポンスが届いたあと、状態を永続化する前のウィンドウだ。このウィンドウはちょうど「相手は実行済みで、自分たちは失敗と思っている」という最悪のケースに対応する。2つ目に効くのは、確認の証跡が生成されたあと、実行の前のポイントで、バージョン検証が本当に変化を止められるかを確かめることだ。

この2つの注入点に共通する特徴は、システムの「順序に敏感な」不変条件を検査している点だ。そして順序に敏感なロジックこそ、Agent のインテグレーションで最も書き間違いやすく、しかもコードを読んでも最も発見しにくい部分である。ランダムなタイムアウトの効果は逆に薄い。ほとんどの再試行ロジックは、ランダムなタイムアウトの扱いをもともと正しく処理しているからだ。本当の問題は、「失敗のように見えて実は失敗ではない」場面で起きる。

十五、自分自身を迷子にする8つのやり方

実務でよく批判されるやり方を並べてみる。これらは1つの共通点を持つ。どれか1つだけ見ればどれももっともな設計だが、組み合わせれば、责任の追及が不可能なシステムになる。

ひとつめ、確認を口頭の質問1つにして、証跡にしない。そうすると、あとから当事者が認識していたことを証明できず、データが変わった時点でも拒否できない。ふたつめ、補償が失敗したときに、流れ全体を自動で再試行し、失敗した1ステップだけを単独で再試行しない。みっつめ、不確かな結果をひとまとめに「失敗」扱いすることが、繰り返し問題の根源になっている。よっつめ、権限の判定をツールの説明文に書き、モデルが守ることを期待している。ごつめ、再試行で冪等性の代わりをしている。ろくつめ、冪等キーをリクエストボディの中の独自フィールドに押し込んで、相手がそれを検査してくれると仮定している。ななつめ、操作の結果だけを記録し、書き込み前の状態を記録しない。やっつめ、長いトランザクションのタイムアウトをグローバルな定数にしてしまい、待ちすぎか短すぎのどちらかになる。

この8本のうち、5本の根因は共通している。「知らない」を「失敗」か「成功」として扱うことである。システムの中で最も明示的にモデル化されるべき状態が「不明」であり、それがいちばんデフォルト値に飲まれる。残り3本の根因は順序の問題だ。先に実行してあとから記録する、先に信じてあとから検証する、先に再試行してあとから判断する。外部への副作用を伴うコードは、1行ずつ「この順序は入れ替えられるか、入れ替えたらどうなるか」と問う価値がある。多くの事故の答えはこの3行の中にある。

十六、ID と資格情報:誰がこの操作を実行しているのか

本物のシステムにつないだあと、避けられない問いがひとつある。この書き込み操作は、いったい誰が発起したのか。この問いは監査の問題に見えて、実態は権限設計の問題だ。サービス拒絶、使用枠の消費、追及の対象、コールバックの帰属——この4つを直接決めてしまうからである。インテグレーションの設計に紛れた曖昧なデフォルト値は、ここで4方向の問題を同時に引き起こす。

ユーザーを代表して実行するのか、システムを代表して実行するのか

意味がまったく違うモードが2つある。ユーザーを代表するモードでは、Agent のすべての呼び出しが最終ユーザーのIDを含み、外から見たときは「このユーザーが自分で変えた」ことになる。システムを代表するモードでは、Agent はシステムアカウントで呼び出し、外から見たときは「システムが自動的に変えた」ことになる。ユーザーのIDは内部ログにだけ残る。この2つのモードは業務側にとって意味がまったく違う。前者には「このユーザーに権限があるか」という問題を扱う必要があるが、後者にはその必要がない。ただし後者では、すべての操作が1つのシステムアカウントの名前で動くので、事件が起きたときには内部監査を通して初めて担当者を突き止められる。

選択を誤ったときの結果は連鎖する。カスタマーサポートの場面は通常、ユーザー代表が合う。業務側がすでに持つ権限モデルがユーザー単位で作られているので、そのまま流用するのがもっとも楽で、しかも監査が自然につながる。一括照合やレポート生成のようなタスクは、通常システム代表しかできない。発起者が人間ではなくスケジュールタスクだからだ。もっとも間違いやすいのは混在した場面だ。Agent がユーザー代わりに操作を実行し、その操作がシステム側のバックグラウンドタスクを起動し、そのタスクが別のサービスを呼ぶ段階でIDが落ちる。相手が記録した内容にはユーザーの情報が何一つ残らない。この種のIDの断裂は、あとから追及するうえで致命的だ。相手のシステムに残るその1件の記録には、特定の人を指し示す手がかりが何も存在しないからだ。

現実的なやり方は、ID がタスクに明示的についていくようにし、呼び出しスタックの暗黙の伝播に頼らないことだ。タスクのコンテキストを発起者のIDで埋め、すべての外部呼び出しはグローバルなセッションからではなくタスクのコンテキストから取り出す。こうすれば、フローがキューを経由し、別のサービスを経由し、モデルの複数回のツール呼び出しを経由しても、IDは失われない。代償は、各サービスが「スレッドから取る」ではなく「コンテキストから取る」を明示的に実装する必要があることだが、これは多少の規律と検査があれば足りるし、相手のシステムを書き換えるよりずっと安い。

トークンのライフサイクルはタスクから独立させる

タスクの実行時間がトークンの有効期限を超える可能性があるとき——人手による確認まで数時間並べられたキュー、レート制限の下で長く待たされた一括タスク——トークンの期限切れは途中で処理を失敗させる。しかもその失敗する場所は、しばしば取り消せないアクションの直前か直後にあるので、単なる失敗より厄介だ。

安定なやり方は、トークンの更新をゲートウェイの自動動作にさせ、ゲートウェイが一元的に処理することだ。同時に監査には「今回の呼び出しでどのトークンのどのバージョンが使われたか」と「更新が発生したか」を記録しておく。この記録は追及のときに実際に意味を持つ。権限変更を伴う事故なら、当時のユーザーの権限の状態を知る必要がある。しかし多くのシステムが答えられるのは「ユーザーの権限はある時点で変更された」までで、「この呼び出しの時点で彼の権限はどうだったか」には答えられない。

一時的な権限昇格は痕跡を残し、自動的に失効させる

先ほど「一時的な権限昇格」という確認ポイントに触れた。実装上、これは必ず期限つきの認可記録でなければならない。トークンのなかに高い権限を忍ばせて、自然に消えるのを期待してはいけない。前者では利用のたびに記録と事後検証ができる。後者には有効と失効という2つの時点があるだけで、その間のすべての呼び出しを追跡する手段がない。

一時的な権限昇格の期限は短く取る。ここでの原則は、権限昇格の有効期限を「今回のタスクを終わらせるのに必要な時間」に一定の余裕を掛けた値にし、合理的に見える整数日数にはしないことだ。一時的な権限の真のリスクは、それが長く存在することではない。長く存在するあいだに、本来は実行するつもりがなかった別の操作に使われてしまうこと——つまり権限昇格の範囲と、昇格後の実際の用途とのあいだにずれが生じることだ。権限昇格を具体的なリソース集合と具体的なアクションに結びつけておけば、このずれの空間はかなり狭くなる。

十七、書き込み操作のコストゲート

読み取り操作のリソースやコストの問題は、別のトピックでかなり十分に議論されてきた。書き込み操作には、しばしば見落とされる特有のコストがある。それは増幅させることができるという点だ。ユーザーの自然言語のリクエストが、モデルに「この一群の注文をすべて変更する」と解釈され、「一群」が1万件のコンテキストの中で条件を満たす2千個のオブジェクトを含むなら、1回の会話のコストも副作用の規模も制御不能になる。

この問題を制御するには3つの層がある。ひとつめは意図の明確化だ。マッチしたオブジェクトの数が閾値を超えたとき、正しい挙動は実行を続けるのではなくユーザーに戻って尋ねることである。ユーザーが、自分の発言の字義がそこまで広いことに気づいていない可能性が非常に高いからだ。閾値は、ユーザーに煩わしく感じられないほど小さく設定しなければならない。ふたつめは実行クォータだ。1つのタスクのなかで、同じ種類の書き込み操作が成功する回数には硬い上限を設け、超えたら止まって残りを人手に渡す。みっつめはレート制限だ。単位時間内に実行を許す書き込み操作の総数である。

この3層のなかでもっとも抜けやすいのは第1層だ。第2層と第3層は技術的手段で、システムが叩き潰されるのを防げる。しかし「システムが、ユーザーが望まなかったことを正しくやっていた」場合までは防げない。この区別は、あとから追及するうえで決定的に重要だ。ユーザーが「私はこれらを変更と言っていません」と苦情を言ってきたとき、技術的にはクォータもレート制限も正常に動いている。責任は意味の層にある。そして意味の層の防御線は、対話のなかでの明確化の質問しかありえない。

コストについてのもう1つの判断は、再試行のコストの重さの問題だ。書き込み操作が失敗したあとの再試行は、多くのシステムでは安価な再試行として扱われているが、業務の観点では非常に高くつくことがある。最初の1回が実は成功していたなら、再試行が作った重複レコードを人手で片づける必要があり、その片づけのコストは再試行自体のコストよりはるかに大きい。だから書き込み操作には、もっと保守的なバックオフ戦略を使うべきだ。不確かな結果の上で何度も試すより、失敗を早く出して人手に回すほうがよい。

十八、タスクをまたぐ重複の排除

ここまでで触れてきた冪等性はいずれも1つのタスク内のもので、鍵はその操作の業務識別子から作っていた。ただし、もう1種類目立たないような重複がある。ユーザーが別の2回の対話で同じ意図を伝え、2つの独立したタスクが生まれた場合だ。

典型的な場面は、ユーザーが「この注文を明日の発送に変えて」と言い、実行の途中で外部の問題により失敗し、画面に「操作が未完了です。再試行しますか」と出て、ユーザーが再試行を選んだ——ところがシステムは元のタスクを再開するのではなく、新しいタスクを作っていた、というものだ。2つのタスクはそれぞれの冪等キーを持っているので、2つ目のタスクの最初の呼び出しは自分の台帳に止められず、重複が生まれる。

この問題の解決策の鍵は、重複の軸を「ユーザー意図」におくことであり、「この1つの操作」ではないことだ。具体的には、すべてのタスクに意図フィンガープリントを付ける。タスク種別、対象オブジェクトの集合、そして重要なパラメータを正規化した表現を含む。同じフィンガープリントのタスクは、短時間のあいだ並存させてはならない。元のタスクを再開して使い回すか、直前のタスクが打ち切られたことを明示したうえで、その補償を完了させるかのどちらかにする。

この仕組みのコストは、指紋の衝突を処理することだ。本来は別のタスクを重複と誤判定してしまうという問題。判断の基準は、新しいタスクと誤判定するよりも、重複と誤判定したほうがよいというものである。前者は効率への影響にとどまるが、後者はデータの正しさに響くからだ。フィンガープリントには十分な数の区別用の次元を含めるべきで、このトレードオフを実のあるものにする。関連する細部として「再試行」ボタンの実装方法がある。もし本当に新しいタスクを作るのであれば、UIでは「再試行」とは呼ばずに「やり直す」と表現すべきだ。この言い方の違いは、ユーザーがシステムのふるまいを誤解するのを大きく減らす。

十九、境界と代償、そして「書き込み権限を与えない」という選択肢

ここまでの18節をまとめると、かなり重い工事になる。実際のコストはこうだ。能力チケットという層では、発行と検証を維持しなければならない。冪等台帳はテーブルを作り、3つの状態を扱い、誰かが照合タスクを書かなければならない。Saga は復旧も補償もできなければならない。確認ポイントは画面を設計し、バージョン検証を処理しなければならない。監査は追記専用で、ハッシュを計算し、定期的にアンカーしなければならない。接続層では単位・タイムゾーン・金額の正規化を行わなければならない。

これらを足したものは、小さなプロジェクトではない。そしてこれらはすべて長期的な保守の負担で、書き終えて終わりになるものではない。

だから、1つだけ正直に答えなければならない問いがある。この仕組みは採算が合うのか。判断の材料は「技術的にできるかどうか」ではない。「この業務シナリオで Agent が誤った書き込みを1回行ったときの最悪の損失」と「その業務シナリオの操作頻度の多寡」だ。両者が低いとき、最適解は上の仕組みを一式そろえることではなく、そもそも書き込み権限を与えないことにあることが多い。

書き込み権限の3つの代替案

案1は読み取り専用に提案を組み合わせる。Agent は必要なデータをすべて読み、構造化された実行提案を作り、ユーザーが独立した画面で確かめてから人が実行する。この方式の利点は、書き込み操作の不可逆性がすべて問題でなくなることだ。実行者が人間と決定論的なシステムだからで、モデルは「読むことと計算すること」だけを担当する。代償は自動化率が低いことで、長い流れの体験は全自動に劣る。適用範囲は、想像よりもずっと広い。実際に業務側が欲しがるのは「この手順を終わらせてほしい」であって「この作業全体を全自動にしてほしい」ではないという事情にある。

案2は操作種別ごとに権限を分ける。取り消せる書き込み操作だけを開き、本当に取り消せない部分は人が対応するように残す。具体的には、Agent に草稿の作成、内部メモの修正、重要ではない内部状態の調整、権限のある範囲でのチケット作成をさせ、しかし支払い、出荷、外部への送信はできないようにする。こうすれば、システムは依然として大量の事務を自動で処理し、残った少数の高リスク動作だけを確認に回す。多くの業務の実際の状況はこうだ。高リスクな動作は頻度の上では数パーセントにすぎず、低リスクな動作が大多数を占める。だからこの方式で価値の大部分をカバーできる。

案3は遅延実行にバッチ確認を重ねる。Agent が行いたい操作を溜め込み、バッチがそろった、あるいは時間の枠がきた時点で、その一式のリストをユーザーに提示して1度だけ確認してもらう。この方式の利点は、確認の粒度が「1操作ごと」から「1バッチごと」に変わることで、確認の疲れを大きく減らしつつ、人が取り消せない動作を制御する力を保てる点上にある。適用条件は、これらの操作が互いに独立していることだ。依存関係があると、バッチ化によって部分失敗の処理がずいぶん複雑になる。

この3つの方式は互いに排他ではない。もっとも一般的な最適組み合わせは、案1ですべての取り消せない操作を覆い、案3で低リスクな操作の一括実行を担い、中間の領域を全自動に残すことだ。この組み合わせの中で全自動に属するのは、エラーが起きても自動で検出でき、自動で修復できる操作だけになる。

この仕組みが割に合わないとき

「割に合わない」と言い切ってよいシナリオがいくつかある。ひとつめは単一テナントで、内部ツールで、操作頻度が低く、かつ取り消せるシナリオだ。チーム内で使う運用アシスタントのように、変更できる設定項目を間違えても5分あれば復旧するものなら、完全な仕組みの構築コストは利点をはるかに上回り、書き込み前スナップショットと監査だけがあれば十分だ。ふたつめは書き込み操作の価値が読み取りよりはるかに低いシナリオだ。Agent の価値の中心が、ユーザーが情報を探して理解することを手伝うことにあれば、書き込みのために投じた投資のすべては省いてよい。読み取りを突き詰めることに集中すればいい。

みっつめはもっと微妙だ。業務側が確認フローによる遅延を受け入れられないシナリオのことだ。ユーザーが即時完了を期待している場面で、確認を入れると数秒から数十秒待つことになるなら、その機能は却下される。このときの正しい対処は、確認をこっそり外すことではなく、前の3つの代替案のなかから、制御と速さを両立する道を探すことだ。答えはおおむね案3のバッチ化にある。

この仕組みの本当の代償

エンジニアリング上の代償はすでに述べた。低估されがちなのは、プロセスと認知の代償だ。ひとつめ、チームの日常は「この操作はなぜ確認が必要で、あれはなぜ不要なのか」といった質問で継続的に削られる。明確なルール文書がなければ、同じ議論を繰り返すことになる。ふたつめ、障害対応の作法はいっそう儀式的になる。当直には保留中の台帳や照合レポートを読む方法を学ぶ必要があり、それには研修が要る。みっつめ、書き込み操作を伴うすべてのコード変更は、この3つの問いを考えなければならない。この操作は取り消せるか、結果が不明なときどうするか、あとから説明できるか——だ。そしてこの考え方をチームに定着させるのは、具体的な仕組みを1つ書くよりもずっと時間がかかる。

これらの代償と引き換えに得られるのは、1種類の能力だ。システムが間違えたとき、それが何をしたか、何を根拠にそう判断したか、誰が承認したか、当時のデータがどうだったかを、正確に言えるということである。この能力の値は、システムの規模が小さいときにはすぐにわからない。システムが本物の業務量を背負い始めて、実際に事故が起きてしまったあとは、別の方法で代替するのが難しい。だから正しい判断は「この仕組みを入れるかどうか」ではなく「このシステムの想定される寿命と業務上の重要性に、追及可能性のためのコストを支払う価値があるか」だ。

二十、書き込み権限を与えるための最小十分条件

この記事を1項目ずつ確認できるチェックリストに圧縮するなら、以下が本文の考える、欠けられない項目になる。並び順も、そのまま構築の順序として提案する。

権限の境界について:

  • 書き込み操作と読み取り操作で別の資格情報を使い、モデルにも業務コードにも書き込み用の資格情報を渡さない。
  • テナント識別子は資格情報から解決してまとめて注入し、いかなる場所でもモデル由来のテナント引数を受け付けない。
  • 書き込み操作のたびに署名の付いた能力チケットを運び、そのチケットにはアクション、リソース一覧、フィールドの許可リスト、枠、有効期限が入っている。
  • ツール層が受け付けるのはチケットだけで、素の引数は受け付けない。引数がフィールドの許可リストを超えていれば、その場で拒否し、モデルが訂正できる種のエラーとして返さない。
  • 業務上の合法性はルールエンジンが判定し、モデルは判定しない。モデルの仕事は自然言語を意図パラメータに翻訳することだけだ。

冪等性と重複について:

  • すべての書き込み操作に冪等台帳の記録があり、状態は真偽値ではなく3状態で、unknown は独立した1つの状態として扱う。
  • 台帳は外部送信の前に先に永続化し、一意制約はデータベース層で担保する。アプリケーション層で先に検索してから挿入する方式には依存しない。
  • 冪等にできない操作は明示的に印をつけ、クォータ上で高々1回に制限し、残りは人手に回す。
  • タスクの再試行は新しいタスクを作らず元のタスクを再利用する。少なくとも、意図フィンガープリントによって重複タスクの並存は遮断する。

トランザクションと失敗について:

  • Saga に参加する各ステップは補償の手段と補償の強さをあらかじめ宣言し、宣言できないステップは人手が必要なものだと明示的に印をつける。
  • 保留状態には独立した点検タスクがあり、その探索には読み取り専用のインターフェースを使う。探索できなければ再送ではなく人手へ回す。
  • 補償の失敗はステップ単位に個別に印をつけ、フロー全体の再試行を起動しない。
  • Saga 全体には総予算があり、各ステップの呼び出し前に残りの予算を検査し、足りない場合はそのまま補償に入る。

人手介入について:

  • 取り消せない動作の確認ポイントは副作用より前に置かれ、確認の証跡には要約ではなくパラメータの原文が入っている。
  • 確認の証跡はオブジェクトのバージョン番号か内容ハッシュに結びつき、実行前に再検証し、一致しなければ無効化してやり直す。
  • 確認の証跡には有効期限があり、確認と認可は独立した2つの記録で、それぞれが独立に「なぜ許可したのか」と「把握していたか」に答えられる。
  • 補償できないステップが失敗したときは、黙って飛ばされるのではなく、明示的な人手用のチケットを生成する。

監査と追及について:

  • 監査は追記専用のイベントストリームで、イベントには直前のハッシュを含め、チェーンのルートハッシュは定期的に外部ストレージへアンカーする。
  • 書き込み操作のイベントには書き込み前の完全なスナップショットを含め、機微なフィールドは全文の平文ではなくハッシュと一部の平文だけを記録する。
  • イベントには実際に送信したパラメータの原文を記録し、モデルが主張した理由は補助の手がかりとしてのみ扱い、証拠のフィールドには入れない。
  • 最終状態で「不明」の操作をすべて絞り出せるようにし、インデックスがあり、点検タスクが毎日実際に動いている。

二十一、実行期に追うべきものは可用性ではない

本物のシステムにつないだあと、可用性の指標はほぼその警告としての価値を失う。インターフェース成功率が高く、遅延が低くても、継続的に誤った判断を下し続けることがある。安定した状態で間違っているだけだからだ。この種のシステムが本当に追うべきなのは、「書き込み操作はまだ信頼に値するか」を示す基準の集合である。

この判定基準のなかでいちばん重要なのは、どの絶対値でもなく確認の通過率の変化傾向だ。健全なシステムでは、この数字は比較的低い位置に安定している。長期的に百分之百に近ければ、確認はゴム印になり、人はもう見ていない。このときの確認が与える保護はほぼゼロになる。本当のシグナルは、短期間に急上昇することだ。60パーセントから95パーセントへ跳ねたなら、ほぼ確実に、何かの操作のセマンティクスが変わったか、システムが確認を迂回しようとしている。これをアラートが必要な指標にしておくほうが、事故からの事後検証で見つけるより格段に効く。

ふたつめは人手介入率だ。ただし層を分けて見る。全体の人手介入率が上がっても、それは単に業務量が増えただけかもしれないので、必ずしも問題とは限らない。有意義なのは介入理由ごとの構成の変化だ。「保留中で人手裁定待ち」の割合が上がっているなら、外部インターフェースの安定性やタイムアウト設定に問題がある。「補償失敗」の割合が上がっているなら、選んだ補償方針が相手の業務ルールと衝突している。「セマンティクスが合わなくて明確化が必要」の割合が上がっているなら、もっとも注目すべきシグナルだ。モデルがユーザーの意図を理解する力が衰えていることを意味し、たいてい事故より先に現れる。

みっつめは重複の遮断回数だ。冪等台帳が何回の重複実行を止めたか。ほとんど誰も見ていないが、非常に価値のある指標である。これが高いということは、システムの再試行ロジックや画面上の再試行の設計が頻繁に重複を誘発している。逆にこの数字がゼロに近づいているなら、システムが非常にきれいなのではなく、台帳がそもそも効いていないと疑うべきである。健全なシステムは接続初期にはこの値が高めで、フロントの操作やモデルのふるまいを調整するにつれてじりじり下がるが、ゼロにはならない。

よっつめは確認から実行までの変換時長の分布だ。この指標の価値は、隠れた退行を発見できる点にある。確認の証跡が大量に期限切れを始めたのだ。証跡の期限切れの割合が上がっているなら、ユーザーの意思決定の速さとシステムの状態変化の速さが合っていません。対処は確認画面の表示内容を短くするか、証跡の有効期間を調整することです。単純に有効期間を延ばすことではない。有効期間を延ばすのは、フローの問題を技術の問題で覆い隠すことになるからです。

二十二、権限のグレードは調整できる状態にあるべきだ

多くのシステムは本番化のときに権限のグレードを固定し、あとは勘で調整していく。ここでは、権限のグレード自体を動的に調整できる量として扱い、その調整ルールを自動実行できる水準まで明示的に定義するほうが現実的である。

基本原理は正のフィードバックによる締め付けだ。ある種類の操作の自動実行比率が続けば下落している、あるいは人手介入率が上がり続けている、あるいは確認の通過率がしきい値を下回り続けているとき、自動的に「1件ずつ確認が必要」へ降格させる。

逆方向の緩和はもっと保守的でなければならない。ある種類の操作が、連続する一定期間、人手介入ゼロ、補償失敗ゼロ、保留ゼロであった場合にだけ、「確認が必要」から「自動実行可」へ移すことを検討する。緩和の閾値は締付けの閾値よりはるかに高くすべきである。この2つの判断の誤りの代償がまったく非対称だからだ。締付けの代償は数秒ぶんの人の手間だが、緩和の代償は取り消せない事故1回になりうる。

この仕組みが暗黙に求めているのは、権限のグレードの変化そのものも監査に入れること、そして「今回はなぜ降格されたのか」と「復旧を誰が承認したのか」に答えられることである。現在のグレードだけを記録して変化の履歴を残さない設定では、あとから検証できない。事故が起きたときにその操作が自動実行されていたことは見えても、なぜ降格されてからまた戻ったのかは分からない。

おわりに

最初にも述べた判断に戻る。Agent が本物の業務システムにつながる際の難度は、その1回の呼び出しが通せるかどうかではない。間違えたあとに、巻き戻せるか、説明できるか、人を辿り着けるかである。この記事で述べた十数個の事項は、本質的にこの3つの目標のどれかにすべてつながっている。

この3つをもう一度細かく分けてみると、全文が繰り返しているのは実は1つの原則にすぎない。「知らない」を明示的にモデルとして保つことだ。冪等台帳が真偽値ではなく3状態を使うのはなぜか、保留が成功や失敗と独立していなければならないのはなぜか、二段階コミットで重複コミットが最も起きるのはなぜか、人手確認がバージョン番号と結びついているのはなぜか、監査が意図と事実を区別するのはなぜか——それらはすべて同じ原則の、異なる側面での表れである。「知らない」が起こりうる場所には、すべて名前、状態、処置の流れを与える。そして何かのデフォルト値に飲ませない。

もう1つ繰り返し出てくる原則は権限の分離だ。モデルは理解と表現を担当し、合法性の判断と実行するかどうかの決定は担当しない。システムは決定論的なルールを担当し、確率的な推論には依存しない。補償動作はルール化された対応表で決めるもので、モデルがその場で生成するものではない。この3本の分離線をはっきり引けば、多くのリスクは実行時になって防御コードで塞ぐのではなく、アーキテクチャの段階で消える。

最後に述べる価値のあることが1つある。この一連のものは、事故が起きる前の段階では価値が見えない。流れを少し遅くし、開発を少し複雑にし、デバッグを少し冗長にする。そしてそれが避けている事故は、まだ起きていないあいだは純粋な帳簿上のコストにすぎない。だから本当の問題は「投資する価値があるか」ではなく「このシステムの業務上の重要性に、説明のできるシステムに値するか」だ。答えが「値する」ものであるとき、この一連の複雑さは実は最も低コストの道筋をなしている。サードパーティのシステムを書き換える必要がなく、予測できないふるまいを業務側に受け入れさせる必要もない。ただ自分のシステムが十分に正直であることだけが要求されるからだ。

「正直」という言葉は、ここでは文字通りの意味だ。状態をありのまま記録し、知らない部分をありのまま印し、できない部分をありのままユーザーに伝える。この部分があいまいなシステムは、短い目ではなめらかに見える。そのぶん、障害が起きるたびに「何が起きたのか」を確かめるために人一がもっと力を費やすことになる。確かめるのに時間がかかればかかるほど、損失は大きくなる。

二十三、同じオブジェクトへの同時書き込み:Agent が持ち込む新しい競合

Agent が持ち込む並行性の圧力と、従来のインテグレーションには性質上の違いがある。従来のインテグレーションでは、同じオブジェクトを同時に変更するのは通常、限られた数本の固定経路で、しかもその順序は開発者が推測できる。Agent の場面では、同じオブジェクトを同時に変更しうるのが、1ユーザーの複数のタスク、複数のユーザーそれぞれのタスク、そして定時タスクとユーザータスクの衝突である。かつそれらの順序はモデルの決定の結果であり、開発者があらかじめ知ることはできない。

最も直接的な表れは更新の喪失だ。2つのタスクがともに注文の現在の状態を読み、いずれもその状態をもとに変更案を作り、いずれも送信し、あとから送ったものが先に送ったものを覆い、先に送ったタスクは自分の変更が反映されたと思い込んだまま何も知らない。この種の問題はあらゆる並行システムに存在するが、Agent はこれを起きやすくする。モデルの計画処理は時間がかかるうえ予測もできないので、衝突の確率が明確に上がるからだ。

解決策は楽観的ロックであり、まともな競合処理戦略をセットする必要がある。よく間違えるのは、バージョン号のチェックだけを追加して、衝突したらエラーを出して再試行する、という実装だ。この方針は従来のシステムならなんとか使えるが、Agent のシステムでは最悪の体験を生む。タスクが繰り返し失敗し、モデルが毎回パラメータを変えて再試行し、最後には業務的には誤っているがそれらしいやり方で抜けてしまう。正しいやり方は、競合をエラーとしてではなく再計画が必要な情報として扱うことだ。「このオブジェクトは他者に変更された」という事実を、相手の変更内容と一緒にモデルへ返し、新しい状態に基づいて続行するかどうか判断させ直す。この処理には回数の上限を設け、上限を超えたら人手に回す。

もう1つ、より根本的な判断がある。一部の競合は再試行で解くべきではなく、書き込み範囲を狭めることで解くべきだ。2つのタスクが同じオブジェクトの別のフィールドを変更しているなら、そもそも競合するはずがない。ただし、書き込みが「読んで書き換えて全体を書き戻す」ではなく「自分が変更したフィールドだけを書く」という形であることが前提になる。この2つの違いは実装上は微妙だが、影響は巨大だ。前者のセマンティクスは「このオブジェクトがその瞬間の状態」、後者は「この数個のフィールドの新しい値」だ。Agent の場面では後者を強制すべきである。

ロックの代償は通常、競合の代償より高い

悲観的ロックで解決しようと思う人もいる。単純で直接的なので。ただし悲観的ロックは Agent の場面では代償が高く、理由は2つある。ロックの保持時間が予測できないこと(モデルの推論にどれだけかかるか次第だ)と、サードパーティのシステムが通常はシステム横断的なロックを提供していないことだ。そこでよく採られる妥協は、1回の書き込み操作にとても短いロックを掛け、「送信」という動作自体だけを保護し、「読んで考えたあとで書く」という全体の過程は保護しないというものである。「読んで考えたあとで書く」はアトミックではないことを受け入れ、送信時にバージョン号で競合を検出するということである。

これは実際には正しい選択だ。その妥協が1つの事実を認めているからだ。モデルの推論と複数の外部システムをまたぐフローでは、有意味なロックを保持することはできない。原子性があるように振る舞うのではなく、競合を明示的に発生させて処理するほうがよい。

二十四、時間、順序、そして遅れて届く事実

システム横断の、もう1つの隠れた間違いの源は時間だ。複数システムのインテグレーションでは、イベントの到着順序と処理順序が一致しないのが常態である。Webhook は遅れて届くことがあるし、再試行キューのタスクは新しいタスクより先に完了することがあるし、2つのシステムの時計には数十ミリ秒のずれがある。もし「最後の書き込みが勝つ」で競合を解決しているなら、先に起きた操作が、あとから起きた操作に上書きされる。後者が物理的には先に書き込まれている場合でも。

この問題を扱うには2つの時間を区別する必要がある。イベント時刻(業務として実際に起きた時刻で、操作そのものが持つ。注文の実際の出荷時刻など)と処理時刻(自分のシステムが書き込んだ時刻)だ。競合の解決はイベント時刻を基準にして、処理時刻は監視と並び替えにだけ使う。処理時刻で競合を解決するのは単体システムの範囲では問題ないが、システム横断になると誤る。

Agent の場面まで絞ったとき、実際に遭遇する場面はこうだ。ユーザーが「この注文を明日の出荷に変えて」と言い、Agent は状態を変えた。数分後、ユーザーが「明後日に変える」と言い、Agent はもう一度変えた。この2回の対話は別のタスクが処理している可能性があり、2つ目のタスクが先に完了することもある。処理時刻で解決すれば正しいように見える。しかし2つ目のタスクが先的原因で先に完了し、1つ目のタスクが後から完了したなら、処理時刻だと状態が「明日の出荷」へ戻ってしまう。ユーザーの最後に明示された要求は「明後日」だったにもかかわらず。

対策は、書き込み操作ごとに単調増加のバージョン識別子を持たせることであり、この識別子の出どころは処理の順序ではなく業務の事実であることにある。業務の事実としては、対話のなかの指示番号(同じセッション内で単調増加する)でもよいし、ユーザー側の明示的なバージョンでもよい。識別子を持つ書き込み操作は送信時に検証し、送信されるバージョンが現在の記録より古い場合は、上書きせずに拒否して「より新しい指示がある」と返す。

コールバックの順序が入れ替わったときの原則

先ほど、非同期のコールバックは順序が入れ替わるとも述べた。処理の原則はこうだ。状態の推進は冪等かつ単調でなければならない。単調という意味は、状態は業務で定義された方向にしか進めないということだ。すでに終端にある操作を、あとから来たコールバックが書き換えてはならない。実装上は、コールバックに業務バージョンの単調な連番を持たせ、連番が現在の記録以上なら無視する。連番が現在の記録より小さいものの状態が「より後ろにある」場合は人手による判断が必要だ。ある事象が起きたのに、システムの記録がそれを追い越してしまったことを意味しているからである。

最後のケースは、不整合は検出したが、誰が正しいかを自動で判断する力がない、という類のものだ。正しい対処は、明示的に記録して人手に回すことである。「最後に届いたものを勝ち」と推測することではない。推測を誤れば誤った状態が定着し、定着した誤りは一時的な不整合よりはるかに扱いにくいからだ。

二十五、サードパーティが使えないとき、システムはどんな形になるべきか

ここまでのすべての仕組みは、最終的に1つの問題に突きあたる。依存している外部サービスそのものが落ちていたら、どうするか。よくあるが誤ったやり方は「成功するまで再試行する」ことだ。相手が長時間使えないあいだ、再試行は自分の枠と接続を消費し続けるが、ユーザーは待つあいだ何もできず、画面には回転するアイコンだけが残る。

より妥当な設計は「使えない」を明確な状態として扱い、黙って待つ時間にもしないことだ。具体的には、ある重要な依存が連続して失敗し続けて一定回数を越えたら、関係するタスクは「再試行待ち」のキューに移し、ユーザーに「相手のサービスは一時的に利用できません。お客様の操作はキューに入りました。復旧後に自動で再試行します。結果を通知しますか」と明示する。こうすることの価値は、技術的な状態をユーザーが理解できる約束に変換した点にある。約束は守ることも、追及することもできる。

もう1つあらかじめ用意すべき問題が縮退経路だ。書き込み操作が使えなくなったとき、読み取り操作はまだ使えるのか。答えは業務次第だが、より大事なのは事故のときにその場で決めずに用意をしておくことである。一般的で有効な縮退設計はこうだ。書き込み操作を「実行待ちの変更セットを生成して保存する」に縮退し、相手が復旧したらシステムか人手がまとめて適用する。この設計は平常時には価値を持たない。しかし決定的な時に「業務の完全停止」を「業務の遅延」に変えることができる。遅延なら許容できるが、停止は許容できない。

依存が利用できないときの設計目標は「すみやかに復旧すること」ではない。「停止を遅延に変えること」だ。遅延には終わりがあり、伝達できる。停止には終わりがなく、ユーザー自身が気づくしかない。

二十六、人手の裁定はダイアログではなくワークベンチだ

前のいくつかの節で「人手へ回す」ということを何度も触れてきた。しかし、人手に回したあとにシステムがその人に何を与えるべきかは、単独で話しておく価値がある。大量のシステムがこの点を極端に薄く扱っており、エラーメッセージをそのままユーザーに転送して、ユーザーに自分でサポートへ連絡させるという做法をしている。これは本来システムが担うべき判断を、もっとも情報を乏しい相手に外注していることになる。

合格した人手裁定のチケットには、少なくとも次の5種類の情報が含まれる必要があり、どれか1つでも欠ければ処理者は合理的な時間内で決定できない。ひとつめはシステムが何をしようとして、どこまで進んでいるかで、手順名を並べるのではなく人間にわかる言葉で目標を記述する。ふたつめはすでに何が起こったかだ。どのステップが成功し、どのような観察可能な外部結果が生じ、相手システム側の記録識別子が何であるか。みっつめはどこで詰まったかだ。具体的な失敗の特徴、すでに試した対処、そしてなぜその対処で解決しなかったのか。

よっつめは両側の状態の比較で、これが最も重要でありながら最も省略されがちの一項目だ。保留で止まっているなら、画面には「自分側の記録の状態」と「探索した相手の状態」を同時に表示し、分岐が一目でわかるようにする。ごつめは選べる対処のリストで、各対処にはその影響を書く。待ち続ける、もう1回試す、相手の実際の状態に合わせてローカルの記録を直す、補償を開始する、相手のサポートに連絡する。どの対処も1クリックで実行できるようにし、処理者がどのインターフェースを呼ぶべきか推測させてはいけない。

ここで強調しておきたい設計判断がある。処理者が下したすべての決定を、システムの学びとして残すべきだ。具体的には、処理者が「相手の探索結果は信頼できない、自分側の記録を採用する」と判断したなら、その判断自体を理由つきの1件の記録として監査に書く。そしてこの種の判断の分布は定期的に見直すべきである。ある種類の保留で「自分側を採用する」比率が異常に高いなら、その操作に対して探索インターフェースはまったく適していないということだ。自動探索の経路から外すべきである。このループは、この種のシステムで少数ながら継続的に改善できる部分のひとつである。

二十七、読み取り専用から書き込み可能へ、段階を踏む道筋

多くのチームの実際の道筋はこうである。読み取りも回答もできる Agent をまず作り、それが動いたことを確認したうえで、書き込み権限を一度に渡す。前者はリスクが低く、フィードバックが速く、後者はリスクが集中し、露出が遅い。より良い道筋は授権を段階的に広げることである。

第1段階は純粋な読み取り専用に提案出力を足す。Agent はすべてのデータを読んで構造化された提案を出すが実行しない。この段階ではモデルの意図理解力とルールの網羅性の問題が見え、コストはきわめて低い。すべての動作が可逆だからです。この段階で追うべきものは、提案がユーザーに受け入れられる比率と、修正される比率だ。これらの変化が次の授権への確信になる。

第2段階で取り消せる書き込み操作を開く。典型的な動作はチケットの作成、内部メモの修正、重要でない状態の更新。失敗や誤りの代償はここでなら制御可能なので、量を変えて走らせてよい。台帳、スナップショット、監査の3つを実際の流量のもとで検証するのが要点だ。この段階でもっとも出やすい問題は並行性の衝突と列挙値のずれだ。実データはテストデータよりはるかに汚いからである。

第3段階で、1件ずつ確認が必要な取り消せない書き込み操作を開く。つまり支払いや外部送信のことだ。この段階の重点は「できるか」ではなく、確認点の情報の密度と確認疲れの扱いである。同時に、操作種別ごとの権限グレードの動的調整にも着手し始める。この種類の誤操作率は操作種別ごとに大きく異なるからである。

第4段階で、ようやく低リスクの種類に全自動を開く。第3段階で十分に長い期間走り、人手介入ゼロ、補償失敗ゼロだった種類だけが、この段に上がってくる。多くのチームは第4段階まで到達しない。しかし第3段階までに業務価値の大部分を獲得できるなら、それでよい。この道筋を設計する意味は、「最後の段階まで到達しないこと」を受け入れられる終状態にすることにある。

最後に、よく見落とされがちな進め方の障害を1つだけ挙げておく。各段階で業務側に受け入れられるリスクを再確認する必要があり、この過程は技術構築より時間がかかることが多い。だから最初の日から「どの段まで行くのか、各段のリスクは何か」を文書に書き、業務側と共同で保守する対象として扱うべきである。この文書は、事故が起きたあともっとも役に立つ資料のひとつでもある。

二十八、つい読み飛ばされがちな2つの仕上げの技術

冪等キーの作り方は、冪等の仕組みそのものより難しい

多くの実装では、冪等キーはその場で生成した乱数になっている。たとえばタスク開始時に乱数生成器を1回呼ぶだけだ。このやり方が解けるのは「同じタスクのなかでの再試行」の重複だけで、第2節で触れたタスクをまたぐ重複は解けない。しかも代償がある。タスクが一度再実行されると、同じユーザー意図に対してもまったく新しい鍵が生まれるため、台帳では止められない。

より役に立つ作り方は業務の事実から安定した鍵を導出することだ。手順は3つある。まず今回の操作が対象とするオブジェクトを確定し、それらの安定した識別子を並べて連結する。次に、今回の操作の重要なパラメータ、たとえばアクション種別、目標状態、扱う個数、を確定する。最後に両者をまとめて文字列にし、そのハッシュを冪等キーとして使う。この鍵には「同じユーザー意図を繰り返し実行すれば同じ鍵になり、異なる意図なら異なる鍵になる」という性質がある。

この作り方の代償は、「どのパラメータが意図に属し、どこからが実装の詳細か」をはっきり考えなければならないことにある。実装の詳細に該当するものを絶対に入れなければ、同じ意図でもシステムのアップグレード前後で鍵が変わってしまい、重複排除が機能しなくなる。意図に属するものはすべて入れなければ、本来は異なる2つの操作を重複と誤判定してしまう。この種の判断には業務の知識が必要で、汎用的な規則からは導けない。したがってこの層の実装は、具体的な操作種別と一緒に書くべきで、汎用関数にしておくべきではない。

もう1点、鍵の安定性は、連結に使うオブジェクトの識別子自体が安定していることに依存する点にも注目しておこう。識別子が毎回クエリで返る、タイムスタンプやバージョン番号を含むもののであれば、鍵は2回目の実行で変わってしまう。オブジェクトから識別子を取り出すときは、どのフィールドを使うかを明示して指定し、時間とともに変化するフィールドは使ってはいけない。

リハーサルを最後の防衛線として使う

前に2段階の確認とシャドー表を説明したが、そのどちらにも暗黙に1つの能力が含まれている。外部の副作用を発生させない条件下で、今回の実行で何が起きるかを計算する能力だ。この能力は単独で取り出す価値がある。トランザクションの仕組みから独立して使えるし、すべての取り消せない動作の前に置く共通の防衛線として使えるからだ。

リハーサルの做法は、実行の経路をひと通り走らせるが、最後の外部への書き込み呼び出しをローカルでの空振り呼び出しに置き換える。パラメータの組み立ても検証も権限チェックも通常どおりに行い、最後の1歩でだけ止めて「これから相手へ送るパラメータ」を記録する。リハーサルを通るかどうかの判定基準は完全なものでなければならない。すべての検証を通過し、権限チェックを通り、ルール判定が合法で、パラメータの組み立てが成功している。どの1歩でも失敗すれば、リハーサルの段階で露出し、代償はゼロだ。

リハーサルの限界も明示しておくべきだ。実際の実行時に相手の状態がリハーサル時と一致している保証はなく、相手自身の業務上の検証に失敗する場合もカバーできない。したがってこれは大量の問題を前段で止めてくれる検査であって、確認を置き換えられる仕組みではない。

妥当な使い方は、リハーサルを確認の前に置くことである。リハーサルの結果から確認画面の内容を作る。そうすればユーザーが見る「何が起きるか」は、モデルが言葉で書いたものではなく、実際に計算されたものになる。前に、確認画面で最も価値があるのはパラメータの原文だと述べた。リハーサルはまさに、その信頼できる原文を提供してくれる。

実装上の細部で忘れられがちなのが1つある。リハーサルは実際の実行とまったく同じコード経路を通아야で、違うのは最後の1歩だけである。リハーサルに独立した検証ロジックがあると、そこと実際の実行との食い違いがまったく発見しにくい新たな誤りの源になる。システムがリハーサルでは完璧に見えて実際は失敗する、という事態になり、原因を探る矛先が「リハーサルが古くなっていないか」に向いてしまう。

二十九、これらをどの層に置き、誰が維持するのか

前に述べたすべての仕組みは、実装のうえで同じ組織上の問題に直面する。業務ロジックに属するわけでもなく、インフラストラクチャにも属さない。置き場所を間違えると誰も維持しなくなる。業務に近く置きすぎると、それぞれの業務チームが自分で実装することになり、その品質が「たまたまそのチームに誰がいるか」に左右される。インフラに近い置き方だと、業務側は塞がれていると感じ、最後にはそれを迂回して自分でインターフェースを呼ぶ。

比較可行性の高い分け方は3段構成だ。第1段は接続層で、単位・タイムゾーン・金額の正規化、列挙値のマッピング、テナントの注入、トークンの更新、そして「フィールドの欠落はクリアではない」といった意味の防御を担当する。この層は外部システム1つにつき1つのアダプタとすべきで、業務コードは統一された内部インターフェースだけを相手にする。到達しているかを判定する基準が1つある。業務コードのなかに外部システムのフィールド名もインターフェースのパスも1つも現れないべきであり、1つも現れなければ境界は明快だ。

第2段は実行層で、能力チケットの発行と検証、冪等台帳、Saga のオーケストレーション、確認の証跡、呼び出し結果の3状態分類、そして監査イベントの永続化を担当する。この層は汎用のもので、すべての書き込み操作がここを通る。したがって安定性への要求がもっとも高く、各チームが個別に書くのに最も向かない層である。到達しているかを判定する基準が1つある。コードのなかに、今回の呼び出しはどの冪等キーを使い、どのような認可を経て、最終的にどんな状態になったのかに答えられる場所が1つ見つかるか。答えが複数の場所に散らばっているなら、この層はまだ十分に収束していない。

第3段は業務ルール層で、業務側しか知らないものを担当する。どの注文をまとめられるか、どのときに人手を介す必要があるか、補償はどの業務アクションを通すべきか。この層は業務側が保有して変更するもので、エンジニアリング側が実行の仕組みを提供する。到達しているかを判定する基準が1つある。業務ルールを、コードを一切書かずに変更できるか、たとえばルールエンジンに置いてあるか、設定画面があるか。という次第である。業務上の制約を1つ変えるたびにリリースが必要なら、ルールとコードの結合が濃すぎるということだ。業務側はシステムを迂回して自分でデータを書き換えるようになる。

この分け方のなかでもっとも飛ばされやすいのは第3段だ。チームの慣性として、業務ルールをエンジニアリングのコードに書く。書くのが速いし、エンジニアのほうが前提として熟悉しているからだ。結果は、業務側がルールを掌握している感覚を失う。たびに開発チームへ要望を出すか、あるいはシステムを迂回して自分でデータベースを直接書き換える。後者のほうが、一貫性の問題のうちもっとも追いかけにくい発生源になる。

三十、業務側と共同で維持する契約

最後に、以前にも何度も触れたリスク一覧とグレードの文書についてだけ触れておきたい。これが重要なのは、これがチームをまたぐ唯一の存在だからである。エンジニアリングのチームが実装を導くために使い、業務側が開放範囲を決めるために使い、双方が期待をそろえるために使う。維持は共同参加でなければならず、エンジニアリングチームが書いて配るだけにしてはならない。

この契約に書いておく価値があるのは、次の項目だ。どの操作が Agent に開放済みか、どれだけ開放されているか、種別ごとの不可逆性と影響範囲、種別ごとの確認経路、種別ごとの現在の人手介入率、そして最新の障害ふりかえりの結論。最後の項目はとくに有用だ。障害の根本原因を対応する行に書き戻していくと、その行はいつのまにかもっとも価値の高い運用知識になる。チームに新しく入った人が一度読むだけで、各操作がなぜ今の状態にあるのかが分かる。

この契約には期待すべき副作用がもう1つある。権限グレードの調整案が自然に生み出されることだ。一覧のどれかの行で補償失敗率が3か月連続でゼロ、人手介入率もゼロなら、そこは次の段の自動化の候補になる。逆に、ある行の確認通過率が百分之百なら、確認の内容を設計し直す必要がある。契約はしたがって、1回限りのレビュー資料ではなく、生きた文書になる。