Abstract(要旨)
AIに仕事を頼んで席を離れ、戻ったら最初の承認で止まっていた。逆に自動承認にすると、見ていないところで変更される。この二択を変えようとしているのが、Cloudflare OSの承認前シミュレーションです。
対応するGatekeeperは、変更をまだ外部サービスへ送っていなくても、「変更後ならこう見える」という結果を返します。AIはその結果を使い、次の作業へ進めます。
面白いのは承認ボタンそのものではありません。AIの作業時間と、人がレビューする時間を分離できることです。ただし、先に進めるほど未確定の前提も積み上がります。
そこで今回は、同梱のConfluence実装を変更せずに使い、承認順序・却下・待ち時間中の他者編集などを11ケース検証しました。分かったのは、「最新versionで更新すれば安心」でも「却下すれば元どおり」でもない、という具体的な境界でした。
これはモデルの賢さを測る記事ではありません。LLMを呼ばず、正しい形のAPI操作を固定しても起きる現象を調べています。前編のローカルモデル評価とは、切り離して読めます。
2026年9月1日更新: 前編と同じ実行環境を **Qwen3.8-27B Q4_K_M(16K)**へ切り替えました。Cloudflare OS上でもモデル選択と応答完了を確認しています。ただし、本稿の中心である11ケースはLLMを呼ばない決定論的な結合試験です。モデル変更後に再実行し、中心11ケースは11/11、最小再現2ケースも2/2でPASSしました。これは「Qwen3.8だから安全」という意味ではなく、モデル更新で変わる提案品質と、変わらない実行契約を混ぜないための再確認です。
目次
- 何が普通の承認待ちと違うのか
- 何を本物のまま使い何を模擬したか
- 発見1 最新versionでも待ち時間中の編集は守れない
- 発見2 却下できる依存と却下しきれない依存
- 発見3 変更の一覧はトランザクションではない
- 使うならどんな仕事から始めるか
- 再現方法と検証の限界
- まとめ
何が普通の承認待ちと違うのか
まず、Cloudflare OSは一般的なデスクトップOSではありません。AIとの会話、小さなアプリであるGadget、外部サービスとの接続役であるGatekeeperを組み合わせる作業環境です。
Gatekeeperは接続先のAPIや認証を引き受けます。そのうち、今回見るのは「変更を保留し、変更後の状態を読み取りに反映する」という機能です。固定コミットの公式READMEにも、この考え方が説明されています。
たとえば、リリース用の手順ページを作る仕事です。
親ページを作る → その下にロールバック手順を作る → 手順に追記する
最初の「親ページ作成」が承認されるまで止める方式では、子ページの作成も待つ必要があります。
一方、ConfluenceのGatekeeperは、まだ実在しない親ページに ~1、子ページに ~2 という仮のIDを割り当てます。本文の読み取りにも保留中の追記を重ねるので、外部には何も書いていないのに、後続作業が使える状態になります。
実装を呼び出した結果は、次のとおりでした。
| 観測した状態 | 承認前 | 順番に適用した後 |
|---|---|---|
| 親・子のID |
~1、~2
|
模擬APIが発行した 200、201
|
| 子ページの本文 | 「停止」+「旧版へ戻す」 | 同じ本文 |
| 外部書き込み | 0件 | 3件 |
| 保留中の操作 | 3件 | 0件 |
図1:検証ログから生成したレポートのスクリーンショット。以下の画像もCloudflare OS標準UIではなく、今回の測定結果を並べたものです。合成データのみを使い、認証情報や実際の業務情報は含めていません。
これは単なる「書き込みをキューに入れる」仕組みと一段違います。読み取りまで変えて、後続作業が進められるようにしています。
この仕組みがあると、人は「AIが止まるたびに呼び戻される」代わりに、「ある程度まとまった変更案を、都合のよい時に見る」使い方ができます。減らせる可能性があるのは、承認の件数より、人の作業が中断される回数です。今回は人のレビュー時間や生産性までは測っていません。
なお、すべての接続先で同じことができるわけではありません。結果をシミュレーションしない操作には awaitDecision という待機の仕組みもあります。別枠で既存のworkerd統合テストを実行し、その場合は承認までAgentが停止し、承認後に再開することを確認しました。「どの操作でも止まらない」という製品評価にはしていません。
何を本物のまま使い何を模擬したか
今回の中心は、独自に作った安全対策のデモではなく、OSSに入っているConfluence実装の振る舞いです。
| 本物のまま使った部分 | 模擬した部分 |
|---|---|
| 保留中の変更を保存・適用・却下・revertする処理 | Durable ObjectのKVに相当するメモリ内ストレージ |
| 本文・タイトルに保留中の変更を重ねる処理 | 承認キューの受信側 |
ConfluenceApi のHTTPリクエスト生成 |
ConfluenceのHTTP応答 |
対象はコミット af56a9d79d8a。実サービスへは接続していません。未定義のHTTP要求は例外にし、模擬APIにもversionの競合検査を入れました。
ローカルのAgent設定はQwen3.8-27B Q4_K_Mへ更新していますが、以下の操作列はすべてテストコードから直接呼び出しました。つまり、Qwen3.8の判断や生成文を、合否の入力には使っていません。この分離により、モデルを今後差し替えても「承認待ちの間に何が起きるか」を同じ物差しで比較できます。
したがって、これから示すのは実装コンポーネントの結合試験です。Confluenceの本番環境、OAuth、ブラウザからの操作経路すべてを実証した、という意味ではありません。
この境界を先に置いたうえで、まず意外だった結果から見ていきます。
発見1 最新versionでも待ち時間中の編集は守れない
次の順序を試しました。
- 元の本文を読み、変更案を保留する。
- 人が承認する前に、別の担当者が本文を編集する。
- 保留していた変更を適用する。
比較する操作は、本文を丸ごと置き換える setContent と、末尾へ加える appendContent です。
| 操作 | 適用後の本文 |
|---|---|
| 全置換 | AIの提案だけになり、待ち時間中の担当者の編集は残らない |
| 追記 | 担当者が編集した最新の本文に、AIの提案が追加される |
図2:同じ初期状態・同じ他者編集で、操作だけを変えた対照。単なる「更新が成功したか」では、両方とも成功になります。
「全置換だから当然」でもあります。けれども、今回引っかかったのは、どちらも適用時に最新のversionを読み直していたことです。
全置換の処理を短くすると、こうなります。
const current = await getLatestPage();
await updatePage({
version: current.version + 1,
body: previouslyPreparedBody, // 本文は以前に作った案のまま
});
新しいversionを使っているので、API上は正当な次の更新です。しかし本文は、他者が編集する前に用意した案です。
つまり、ここで確認しているのは「今から行う更新が、直前の取得と衝突しないか」。「この案を作ったときの前提が、今も同じか」ではありません。 対象の適用処理
これを区別するため、追加の対照も入れました。適用処理が最新versionを取得した直後、書き込む直前に、他者の編集を挟みます。すると模擬APIは409で拒否し、変更は保留のまま残りました。
図3:競合検査が壊れていたのではなく、守っている時間帯が違った。模擬APIは「現在version+1」の更新だけを受け付けます。
ここから持ち帰れるのは、「versionを付けよう」より一段具体的な設計判断です。
承認待ちを長くできる製品ほど、提案を作った時点と、適用する時点の差を扱う必要がある。
共同編集される既存本文の置換なら、提案時の版や本文ハッシュとの比較、最新状態との差分の再提示などが候補になります。対して追記なら、今回の実装では他者の本文を残せました。ただし、文脈が変われば追記内容が不適切になることはあります。追記を無条件に安全とは言えません。
これはローカルモデルを強くしても消えない問題です。正しい案を作った後に、外側の世界が変わるからです。
発見2 却下できる依存と却下しきれない依存
承認前に先へ進めるなら、途中を却下したときはどうなるでしょうか。
まず「親ページ→子ページ→孫ページ→孫への追記」という4件を保留しました。親ページの作成を却下すると、保留中の4件すべてがConfluence側の保存領域から除去され、外部書き込みは0件でした。
存在しなくなる親の下に子を作れない。この関係は、IDと親子関係から分かります。
では、次のような関係はどうでしょう。
① ページのタイトルを「公開可」に変える案を保留
② 読み取りで、シミュレーションされたタイトル「公開可」を取得
③ 読んだ値を埋め込み、「『公開可』を確認しました」というコメントを保留
④ ①だけ却下
この場合、コメントは保留のまま残りました。それを適用すると、実際のタイトルは「作業手順」のままなのに、コメントには「公開可」が入ります。
ここでLLMは使っていません。返ってきた文字列を、そのまま次の操作へ渡すだけで作れる依存関係です。
図4:どちらの却下でも、Gatekeeperは再起動を求める restart ヒントを返しました。ここではGatekeeper側の記録と適用結果を確認しており、Agent全体の自動再計画までは検証していません。
「依存関係を管理する」という言葉には、二つの違う仕事が入っています。
- 構造の依存:子ページの親IDなど。機械がたどれる。
- 意味の依存:そのタイトルを見たから、この説明を書いた。IDだけでは分からない。
今回の実装は、前者を連鎖して除去する一方、後者を自動で取り消すものではありませんでした。対象の却下処理
ここで、レビューの単位が変わります。
操作を1件ずつ見て「このタイトル変更は嫌だから却下、コメントは問題なさそうだから承認」と判断するだけでは、前提のつながりを見落とします。同じ前提から作られた変更を、一つの仕事として見る必要があるのです。
実務上は、根拠になる変更を却下したら、その後に用意された通知文・要約・別ページへの転記も見直す。この扱いが自然だと感じました。「全部を自動で却下すればよい」わけでもなく、独立した作業まで捨てない単位設計が必要です。
発見3 変更の一覧はトランザクションではない
もう二つ、短い対照実験をしました。
一つ目は、本文の全置換と追記を、異なる順序で適用することです。
| 適用順序 | 最終的な本文 |
|---|---|
| 「新版手順」へ全置換 → 「安全上の注意」を追記 | 両方が残る |
| 追記 → 全置換 | 「新版手順」だけになる |
保留中に見えていた完成形は同じでも、適用順序を変えると結果が違いました。
親より先に子ページの作成を適用する試験では、未作成の親を参照してエラーになり、勝手に親が作成されることはありませんでした。その後、親→子の順で適用すると回復しました。
これは適用処理へ直接、逆順に要求した試験です。通常のUIが勝手に逆順に処理する、という話ではありません。確認したレビューUIは古い順に表示する設計です。
二つ目は、適用済みの追記をrevertすること。単独なら元の本文に戻りました。しかし、追記の後に担当者が別の編集をしていても、同じように追記を提案する前の本文へ戻りました。
図5:今回のrevertは、AIの追記部分だけを差し引く操作ではなく、保存していた以前の本文を書き戻す処理でした。後から入った担当者の編集も残りませんでした。
したがって、この版のConfluence連携で保留中の変更が並んでいることを、データベースのトランザクションのような「全部まとめて確定、なければ全体を元どおり」と解釈するのは適切ではありません。
revertが用意されていることも、「他の変更に影響せず、いつでも戻せる」こととは違います。戻す操作も、今の状態に対する新しい変更としてレビューする必要があります。
使うならどんな仕事から始めるか
ここまで読むと、落とし穴ばかりに見えるかもしれません。ただ、承認前の作業を進められる価値と、この実装の境界は分けて考えたいところです。
私なら、最初に選ぶのは新しい下書きのまとまりを作る仕事です。
たとえば、まだ誰も編集していない場所に、リリース手順、ロールバック手順、確認項目をまとめて作る。順序どおりに実体化できることは今回の小さな試験で確認でき、既存本文を全置換するより他者の変更とも衝突しにくい使い方です。
一方、複数人が更新中の運用手順を丸ごと直し、その完了通知まで用意する仕事には、今回見た「時差」と「意味の依存」の両方が出てきます。
| 始めやすい候補 | 最初から慎重に扱いたい候補 |
|---|---|
| 独立した新規ページ群の下書き | 共同編集中の本文の全置換 |
| レビュー可能な追記案の準備 | 変更が確定した前提での通知・転記 |
| まとめて見比べたい複数案 | 後から一括revertすればよいとする運用 |
これは用途ごとのend-to-end実証ではなく、今回の結果からの適用案です。
導入判断では、モデルのベンチマークとは別に、接続先ごとに次を確認するとよさそうです。
- 先へ進めるか:保留中の変更は読み取りへ反映されるのか、それともAgentを止めるのか。
- 前提を保てるか:承認するまでに他者が編集したら、上書き・積み直し・拒否のどれになるのか。
- 却下を伝播できるか:存在の依存だけか、後続の仕事も再検討する仕組みがあるか。
- どう戻すか:差分だけを取り消すのか、過去の状態を復元するのか。
Cloudflare OSという箱が同じでも、これらはGatekeeperと操作によって違います。「このモデルが使えるか」の次は、「この接続先の、どの操作まで任せるか」を評価する。ここが今回、前編から一歩進められた判断軸です。
再現方法と検証の限界
今回の追加検証は、11の決定的なケースです。同じ操作列を何度もLLMへ投げて成功率を測ったものではありません。
図6:予想した状態とassertionが一致することを確認した一覧。テスト通過は、製品全体の安全性や本番運用の保証ではありません。
実装と照合したい場合は、次の3箇所が入口になります。
既存のConfluenceテスト85件と、上記の統合テスト1件も実行して通過を確認しました。追加11ケースとは別枠です。
以下は「全置換と追記」の差だけを確かめる最小テストです。本稿掲載用に短くしたコードも、2ケースとも実行確認しました。実アカウントやAPIキーは不要です。
コピーして試す:承認待ちの間に他者が編集したらどうなるか
対象リポジトリを上記コミットで用意して pnpm install した後、packages/gatekeeper-confluence/__tests__/approval-gap.test.ts に保存します。これは実装の適用処理へ直接、保留済み記録を渡す最小再現で、UIや承認キュー全体を通す試験ではありません。
import { afterEach, expect, it, vi } from "vitest";
import { ConfluenceApi } from "../src/confluence-api";
import { ConfluenceStore, applyStoredAction, storageToMarkdown } from "../src/confluence-actions";
afterEach(() => vi.unstubAllGlobals());
it.each(["setContent", "appendContent"] as const)("approval gap: %s", async type => {
const kv = new Map<string, unknown>();
const memory = {
get: (k: string) => structuredClone(kv.get(k)),
put: (k: string, v: unknown) => { kv.set(k, structuredClone(v)); },
delete: (k: string) => kv.delete(k),
list: ({ prefix }: { prefix: string }) =>
new Map([...kv].filter(([k]) => k.startsWith(prefix))),
} as ConstructorParameters<typeof ConfluenceStore>[0];
// Simulated Confluence, not a real account. Any unexpected request throws.
let current = {
id: "100", type: "page", title: "Manual", status: "current",
version: { number: 1 }, body: { storage: { value: "<p>Original</p>" } },
};
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
if (new URL(url).pathname !== "/ex/confluence/lab/wiki/api/v2/pages/100") {
throw new Error("Unexpected URL: " + url);
}
if (init.method === "GET") return Response.json(current);
if (init.method !== "PUT") throw new Error("Unexpected method");
const body = JSON.parse(String(init.body));
if (body.version.number !== current.version.number + 1) {
return Response.json({ message: "version conflict" }, { status: 409 });
}
current = { ...current, version: body.version,
body: { storage: { value: body.body.value } } };
return Response.json(current);
});
const api = new ConfluenceApi({ cloudId: "lab", webBase: "https://lab.invalid",
getToken: async () => "fake-token" });
const store = new ConfluenceStore(memory, api);
const before = await store.getContentResponse("100");
// The record that is held while a person reviews the proposed change.
store.putAction({ id: 1, state: "pending", submittedAt: 0, action: {
type, contentId: "100", markdown: "Agent proposal",
previousMarkdown: storageToMarkdown(before.body!.storage!.value),
} });
current.version.number = 2;
current.body.storage.value = "<p>Human edit during review</p>";
await applyStoredAction(store, 1);
const text = storageToMarkdown(current.body.storage.value);
expect(current.version.number).toBe(3);
expect(text).toBe(type === "setContent"
? "Agent proposal" : "Human edit during review\n\nAgent proposal");
});
リポジトリのルートで実行します。
pnpm --filter @gadgets/confluence-gatekeeper test:run __tests__/approval-gap.test.ts
両テストが通るのは「両操作とも他者の変更を守る」からではなく、全置換では残らず、追記では残るという観測を、それぞれ確認しているからです。
限界も明示しておきます。
- 実Confluenceの競合・権限・障害応答を網羅したものではありません。模擬APIの振る舞いと、対象実装が送る更新内容を検証しました。
- 承認キュー全体とGatekeeper内部の記録は別物です。内部で子孫が除去された結果から、UIの全カードも同時に消えるとは主張していません。
- Agentの再起動・再計画、実際のレビュー時間、複数サービスをまたぐ変更は未測定です。
- 固定したearly-access版の結果です。他のGatekeeperや今後の版へ、そのまま一般化できません。
まとめ
Cloudflare OSの承認前シミュレーションは、AIが止まるたびに人を呼ぶ方式から、AIに変更案のまとまりを用意させ、人が後からレビューする方式へ寄せる仕組みでした。
ただし、そこでレビューするものは、もう操作1件の可否だけではありません。
その変更は今も同じ前提で成り立つか。どの変更に依存しているか。順番を変えたり戻したりすると、何が残るか。
今回の面白さは「LLMを信用しない」という教訓ではなく、AIを先へ進めるために、レビューの単位も変える必要があると具体的に見えたことです。
モデルが強くなれば、よりよい案を作れるかもしれません。しかし、提案後に他者が編集した事実や、却下した案を前提にした別の変更は、それだけでは消えません。だからこそ、モデル評価と接続先の動作契約を分けて検証する意味があります。





