Engineering ai-driven-dev 25 min read

owned media 運用エンジン mcluhan の構造設計 — 4 状態機械・Gate・SSOT・retro 累積

AI 量産時代に機械的に品質を止める owned media エンジン mcluhan の設計。Phase 0 で失敗 11 本を出した経験から組み立てた、人手レビュー前に NG を弾く engine 構造を全開示する。

公開 2026-05-21 森本 拓見

本記事に登場する HUMAN_INPUT マーカーとは、AI 執筆 skill が記事本文に残す「ここは人間が後で確定値を埋めるべき」を示すプレースホルダー(形式例: <!-- HUMAN_INPUT: 数値を記入 -->)。

pre-publish gate を自前で組む senior engineer / コンテンツ運用設計者向け。4 状態機械・Gate G1-G13・SSOT・retro 累積による self-improving pipeline の Yakumo 実装を全開示する。owned media 運用の経営判断・品質管理投資・透明性戦略といった business 観点は、経営判断側の記事 「AI 量産時代の owned media 運用 — mcluhan の経営判断と品質管理投資」 を参照してほしい。本記事では engine の構造そのものを扱う。

mcluhan は八雲が運用する汎用 owned media 運用エンジンだ。記事の状態管理(執筆中 / 監査待ち / 公開予約 / 公開済)、公開前の機械チェック 13 項目、公開時刻の自動 drip 化、運用中の学びを記事素材として累積する仕組み — owned media を回す手前で必要になる仕掛けを全部引き受ける。

なぜこれを作ったか。2026 年 5 月、八雲は AI 支援で書いた 48 本の記事を一斉公開して 5 日で全撤退する経験をした(詳細は 「AI 量産ブログを 5 日で全撤退した話」、本文では「Phase 0」と呼ぶ)。本文に残った HUMAN_INPUT マーカー、/blog/ への死リンク、表記揺れだらけのタグ、品質審査に全部落ちる featured 選定 — 全部が事後 audit で発見できた「機械が止められたはずのもの」だった。

そこで作ったのが mcluhan だ。命名は Marshall McLuhan の「The medium is the message」から取っている。表面の記事ではなく、それを運用する engine こそがメディアの message を伝える、という設計意図だ。

そして読者が今読んでいるこの記事自身が、mcluhan の dogfooding 出力の 1 つだ。後述する 4 状態機械と 13 項目の Gate を実際に通過して公開されている。このドキュメントはその構造の全貌を開示する。


なぜ engine を自前で作るのか — 事業との接点

tech の設計判断に入る前に、なぜ SaaS を使わず自前で書いたかを一言で整理する。

八雲が目指したのは記事を書くツールではなく、SSOT + context-aware Gate + reviewer scope の機械検証といった owned media 全体の構造規律を持つ AI エージェントシステムだった。その運用 layer を提供する SaaS は見つからなかった。加えて、Astro / Vercel / Claude Code と AI 支援を組み合わせると、Phase 1 の SSOT + Gate + scheduler + retro 累積は数日で組み上がり、構築コストが SaaS の制約をねじ曲げるコストより安かった。運用要件の複雑さが高く、構築コストが低かった — これが自前で書いた理由だ。

事業面の詳細(受託転用・外販計画・dogfooding の意義)は business spoke 「AI コンテンツ品質ゲートの ROI」 に委ねる。ここからは engine の構造そのものを見ていく。


なぜ owned media に専用エンジンが必要だったか

Phase 0 の失敗から学んだことは、failure detection(失敗の検出)と failure prevention(次回の防止)は別物だということだ。

監査を走らせると、HUMAN_INPUT の本文残存・死リンク・タグ表記揺れ・featured 選定ミス・1 日に 37 本公開した frontmatter — すべてが機械可読な情報として見えた。grep 1 行で見つかる程度の単純なミスが、publish ボタンの手前で誰にも止められなかった。

次回も同じ失敗を防ぐにはどうするか。素直に考えると、最初に出てくる答えは「人間が頑張れば止められる」だった。これは間違った答えだ。人間が頑張らないと止まらない pipeline は、いずれ必ず破綻する。疲れた日、急ぐ日、頭が切れない日が確実にやってくる。

正しい答えは「機械が機械的に検出できる失敗は、機械が止める」だった。

engine の責任範囲

mcluhan が引き受けるのは以下の領域:

  • 公開前検査(HUMAN_INPUT 残存・死リンク・タグ揺れ・著者整合・時刻分散)
  • 状態管理(執筆中 / 監査待ち / 公開予約 / 公開済)
  • 公開スケジューラ(指定時刻に build を trigger)
  • 透明性表示(AI-assisted フッター)
  • 改善ループ(運用中の学びを retro として累積)

人間が判断する領域は以下のまま:

  • voice / 文体・トーン
  • 一次情報の質と独自性
  • 公開すべきか否かの最終判断
  • editorial vision(何を書くか、何を書かないか)

「機械化できるもの」と「人間にしかできないもの」の境界線を意識して設計した。


4 状態機械 — 執筆と公開を decouple する

mcluhan の中核は frontmatter で表現する 4 状態の machine。

[執筆中]    →  [監査待ち]   →  [公開予約]      →  [公開済]
draft:true     draft:true      draft:false        draft:false
reviewed:f     reviewed:f      reviewed:true      reviewed:true
scheduled:-    scheduled:-     scheduled:future   scheduled:past

状態は記事の frontmatter で直接表現する。専用の DB やキューは持たない。記事のメタデータがそのまま状態を表す。

---
title: "..."
publishedAt: 2026-05-19
draft: true             # 状態フラグ
reviewed: false         # 状態フラグ
reviewedBy: ""          # 監査者の slug
scheduledAt: null       # 公開予定時刻
authorSlug: takumi-morimoto
aiAssisted: true
---

build フィルタで「公開済」だけが magazine に出る:

// src/lib/magazine.ts
export async function getAllPosts(): Promise<MagazineEntry[]> {
  const now = new Date();
  const posts = await getCollection('magazine', ({ data }) => {
    if (data.draft) return false;                                  // 執筆中・監査待ち
    if (!data.reviewed) return false;                              // 監査未完了
    if (data.scheduledAt && data.scheduledAt > now) return false;  // 公開予定が未来
    return true;
  });
  return posts.sort(/* ... */);
}

この設計の利点は 執筆と公開のリズムを decouple できること。

土日に AI 支援で記事を batch 執筆 → drafts に蓄積。平日に編集者が daily に review → reviewed:true に切り替え + scheduledAt を未来日時に割り当て。Vercel cron が daily に build を trigger → scheduledAt が来た記事だけが新たに公開される。

書く時間と公開する時間を分けた瞬間、量産モードと品質モードを両立できる。


Gate — 公開前検査の 13+7 ルール

scripts/blog-gate.tssrc/content/magazine/**/*.md を走査し、必須チェック G1-G13 と警告チェック W1-W7 をかける。npm run prebuild で毎ビルド前に走るので、失敗を含む記事は build から物理的に出せない。

必須チェック(fail → build 停止)

#チェック失敗条件
G1HUMAN_INPUT marker<!-- HUMAN_INPUT --> / HUMAN_INPUT[TAG] / 行頭コロン形式の残存
G2死リンクmarkdown link 形式の ]\(/blog/...\) 残存
G3tag SSOT 整合frontmatter tagstagCatalog 外の値
G4track-category 一致frontmatter category と物理パスの不整合
G5reviewer 存在draft: falsereviewedBy が members.ts に存在しない
G6reviewer scopereviewedByreviewScope が記事 track をカバーしていない
G7scheduledAt 必須draft: falsescheduledAt 未設定
G8time-slot 整合scheduledAt の時刻が publishPolicy.timeSlots
G9authorSlug 存在authorSlug 未設定 / members.ts に存在しない
G10self-reference 検出Yakumo 自身を 会社 / 企業 / firm / company と表記
G11pillar brief 整合pillar 記事の brief に cluster / audience.primary / seo セクションが揃っているか
G12英語版存在英語版(magazine-en)が存在するか(WARN のみ、build は止めない)
G13locale prefixMagazine 内部リンクが locale prefix(/magazine/ / /en/magazine/)を持っているか

警告チェック(build は止めない)

#チェック警告条件
W1rate-limit (daily)scheduledAt が同一日に 6 本超
W2rate-limit (weekly)同週に 50 本超
W3rate-limit (monthly)同月に 200 本超
W4description 長60 字未満 or 130 字超
W5title 長18 字未満 or 60 字超
W6pillar inbound linkspoke 記事から所属 pillar への内部リンクなし(実装予定)
W7series 整合series 指定が seriesCatalog に存在しない

context-aware — 機械的検出の精度

最初は単純 grep だった。だがこの記事自身のように 失敗事例を本文で説明する記事を書こうとすると、説明文に含まれる HUMAN_INPUT/blog/... まで誤検出する。失敗開示型 owned media を運用するには、Gate がメタな記述にも対応する必要があった。

解決策は markdown の code context を pre-stripping すること。

function stripCodeContexts(body: string): string {
  // fenced code block (```...```) を除去
  let result = body.replace(/```[\s\S]*?```/g, '');
  // inline code (`...`) を除去
  result = result.replace(/`[^`\n]+`/g, '');
  return result;
}

stripping 後の本文に対して、検出は特定フォーマットに限定する:

  • HTML コメント: <!--\s*HUMAN_INPUT
  • 角括弧タグ: HUMAN_INPUT\[[A-Z_]+\]
  • 行頭コロン: ^HUMAN_INPUT:
  • markdown link 形式: \]\(/blog/[^)]+\)

これで「HUMAN_INPUT を本文中で説明している inline code」は除外され、「HTML コメントとして残った実 marker」だけが fail する。

G10 — 文脈判定の応用

八雲は未法人化のため 「八雲は ... 会社」 「Yakumo is a firm」 のような自己言及を禁止している。だが 「株式会社マーケットエンタープライズ」(他社の正式名)や 「自称 AI 開発会社」(他社カテゴリ批判)は OK。同じ「会社」という単語でも、文脈で OK/NG が分かれる。

G10 は allow-list と pattern-list で判定する:

// src/config/brand.ts
externalOrgAllowlist: [
  '株式会社マーケットエンタープライズ',
  'Market Enterprise',
],
externalReferencePatterns: [
  /他の[^。]{0,30}?(会社|企業)/g,
  /自称[^。]{0,30}?(会社)/g,
  /other\s+(AI\s+)?(development\s+)?(firms|companies)/gi,
  // ...
],

検出フロー:

  1. 本文から 会社 / 企業 / firm / company を抽出
  2. 各マッチが allowlist 内文字列に含まれていれば除外
  3. external pattern にマッチする部分文字列内なら除外
  4. 残ったマッチについて前後 50 文字以内に Yakumo / 八雲 / 私たち があれば fail
  5. それ以外は warning(要人間確認)

context-aware を「stripping 系」と「allow-list + pattern 系」の 2 アプローチで実装した。今後同じパターンを他のルール(「弊社」「当社」「内輪用語」など)にも適用可能。


SSOT 群 — 表記揺れと禁則の機械可読化

frontmatter で参照する値はすべて SSOT に集約してある。

ファイル責務
src/config/tags.tsTagKey 一覧(執筆当時 約 25 種、現在は src/config/tags.ts を参照)。frontmatter tags は SSOT 外の値を許さない
src/config/pillars.tspillar 記事の slug 宣言。spoke は所属 pillar への inbound link を W6 で警告される
src/config/series.tsMagazineSeriesId の SSOT。frontmatter series は SSOT 外を許さない
src/config/members.ts執筆者・レビュアー定義。canReview / reviewScope で G5/G6 を検証
src/config/publish-policy.tstimeSlots / rateLimits / aiAssistedFooterTemplate / editorNoteCadence

Phase 0 で発見した失敗の 1 つは 77 種類のユニークタグ、うち 55 が孤立タグだった。agents / agent-design / エージェント設計 のような表記揺れが Topical authority を破壊していた。SSOT 化と Gate G3 の組み合わせで、新規記事が表記揺れを混入させる経路を物理的に閉じた。

frontmatter からの参照は単純な文字列キー:

tags: [content-ops, quality-control, blog-audit]
series: mcluhan-engine
authorSlug: takumi-morimoto
reviewedBy: takumi-morimoto

content-ops という文字列が tagCatalog['content-ops'] に存在しなければ Gate が fail する。mcluhan-engine が seriesCatalog に存在しなければ frontmatter validation で zod が剥がす。

SSOT を変更すれば全記事の表記が変わる。SSOT を変更しなければ全記事が同じ表記で揃う。表記揺れは構造的に発生しない。


Vercel Cron スケジューラ — 自動 drip 公開

scheduledAt が未来日時の記事は build に含まれない。だが Vercel Cron が定期的に rebuild すれば、scheduledAt が現在時刻を過ぎた記事は次の build から自動的に公開対象になる。

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/rebuild-magazine",
      "schedule": "0 0,9 * * *"
    }
  ]
}

cron 起動時刻は UTC 00 / 02 / 05 / 07 / 09 — JST 09:00 / 11:00 / 14:00 / 16:00 / 18:00 の 5 slot 構成。spoke 量産フェーズ用に 2 slot から拡張済(詳細は src/config/publish-policy.ts を SSOT として参照)。publishPolicy.timeSlots と完全に一致する。

// src/config/publish-policy.ts(※ 最新値は publish-policy.ts を参照)
timeSlots: ['09:00', '11:00', '14:00', '16:00', '18:00'] as const,

scheduledAt: 2026-05-20T11:00:00+09:00 の記事は、UTC 02:00 より後の最初の cron(UTC 09:00 = JST 18:00)で rebuild されて初めて公開される。publishedAt(display 用)と scheduledAt(machine 用)を分けることで、display では「2026-05-20 公開」、Google の discovery date は実際の build trigger 時刻、両方を整合させる。

/api/cron/rebuild-magazine の中身は Vercel Deploy Hook を fetch する単純な proxy:

// src/pages/api/cron/rebuild-magazine.ts
export async function GET() {
  const hookUrl = process.env.VERCEL_REBUILD_HOOK_URL;
  if (!hookUrl) return new Response('OK (no hook)', { status: 200 });
  await fetch(hookUrl, { method: 'POST' });
  return new Response('rebuilt', { status: 200 });
}

scheduledAt をいくら未来日時に設定しても、その時刻が来るまでは記事は公開されない。逆に scheduledAt が過去日時なら、次の cron 起動で必ず公開される。「いつ公開するか」を frontmatter の 1 フィールドだけで完全制御できる設計。


AI-assisted フッター — 透明性の設計

全記事の最下部に AI 支援の事実と reviewer 名義を表示する。

# publishPolicy.aiAssistedFooterTemplate
ja: 'この記事は AI 支援でドラフトされ、{reviewer} が {reviewedAt} にレビューしました。'
en: 'This article was drafted with AI assistance and reviewed by {reviewer} on {reviewedAt}.'

src/components/magazine/article/ArticleFooterMeta.astro が frontmatter の aiAssisted / reviewedBy / reviewedAt を読み取り、members.ts から reviewer 名を引いて locale ごとに文字列展開する。

なぜ表示するかというと、AI 量産時代の Google ranking signal は「AI 生成を隠していない」「accountability が明示されている」ことを評価する方向に動いている。隠す方が deceptive 判定されるリスクが高い。

AI で書いた事実を消そうとする owned media は多い。mcluhan の設計は逆方向 — AI 支援を publicly committed な事実として表示し、reviewer 名義で accountability を渡す。これが Google Scaled Content Abuse 判定の境界線(unique value × volume × oversight)の oversight 部分を満たす設計。


retro 累積 — pipeline 自身が改善対象

mcluhan の運用中に発見した issue / fix / learning は blog-ops/retros/{YYYY-MM-DD}-{slug}.md に記録する。blog-retro skill が format に従って書き出す。

---
date: 2026-05-18
short_slug: gate-false-positive
title: "Gate の grep が失敗開示記事に false positive を出した"
trigger: "..."
related_pillar: "2026-05-magazine-reset-timeline"
spoke_potential:
  - "Pre-publish gate の設計 — false positive と false negative のバランス"
  - "失敗事例を記事化するときの Gate 対応"
status: captured
---

## 経緯
## 根本原因
## 修正
## 学び
## 派生 spoke 候補

執筆当時(2026-05-18)時点で 3 件の retro が累積していた(現時点で 9 件: blog-ops/retros/ を参照):

  • 2026-05-18-gate-false-positive: G1/G2 の plain grep が記事中の HUMAN_INPUT 説明に false positive を出した → context-aware stripping で解決
  • 2026-05-18-naming-rule-english-parity: ja の「会社」ルールを定義したが en の company/firm に並走漏れ → BRAND.md のルール定義を locale 並走で記述する必要
  • 2026-05-18-context-aware-company-gate: 「会社/企業」ルールを機械チェック化する際の文脈判別仕様 → G10 として実装

現在、blog-ops/config/pipelines/ 配下に 5 本の pipeline が定義されている(audit / new-article-pillar / new-article-spoke / rewrite-tech / rewrite-case)。

各 retro は 将来の spoke 記事の素材になる。「pre-publish gate の設計」「multi-locale brand rule の SSOT 化」「context-aware 静的解析」など、抽象化した spoke を書ける材料が retro として既に揃っている。

つまり mcluhan は 使うたびに次の記事を生む engine でもある。失敗の検出 → 仕組み化 → 記事化 → さらなる検出 のループが、pipeline 自身を改善対象として組み込んでいる。engine が自分自身の記録を素材として消費し続ける構造だ。


命名 — なぜ mcluhan か

Marshall McLuhan は 1960 年代のメディア論者。代表的なフレーズが「the medium is the message」— メディアそのものが伝えるメッセージは、表面のコンテンツではなく、それを運ぶ媒体の構造にこそ宿る、という主張だ。

owned media に当てはめると、訪問者が読み取るのは記事の文字列だけではない。「この組織はどう記事を運用しているか」「どう品質を担保しているか」「失敗をどう開示するか」— pipeline の設計自体が、編集の姿勢を伝える。

だから engine の名前として mcluhan を選んだ。記事は流れていく。engine の設計は残る。

ちなみに src/lib/bateson/ も同じ流儀で命名している。Gregory Bateson(人類学者・サイバネティクス論者)から取った。Yakumo の src/lib/ 配下は「人間と情報のシステム論」の思想家・批評家の人名空間として一貫させている。bateson が booking engine(サイトでの sales agent)、mcluhan が owned media engine(サイトでの marketing agent)— 訪問者が体験する 2 つの面が、それぞれ思想家の名前を持つ独立した engine として動いている。


汎用化への道 — 他組織でも動くか

mcluhan は現在、八雲コーポレートサイトで dogfooding 中。将来は他組織が自分たちの owned media に install して動かせる切り出し版として独立予定だ。bateson と同じ設計原則を継承する:

  1. corporate-site に依存しない(逆向き依存のみ)
  2. すべての公開 API は tenantId を引数として持つ
  3. Adapter パターンで外部システムを差し替え可能(storage / ai / publish / analytics)
  4. テンプレ・ブランド要素は注入(pillar 定義・著者・カテゴリは tenant config)
  5. UI は headless 寄り(agent からも CLI からも UI からも同じ core を呼ぶ)

切り出し後の構造は以下を想定:

src/lib/mcluhan/
├── core/
│   ├── types.ts           # Article / Author / Tag / Series / Pillar / Reviewer 型
│   ├── content.ts         # createContentCore(adapters) — adapter DI
│   ├── editor-policies.ts # track ごとの policy 検証
│   └── gate.ts            # pre-publish gate logic
├── adapters/
│   ├── storage/           # GitHub / Notion / filesystem
│   ├── ai/                # Claude / OpenAI
│   ├── publish/           # Vercel / Cloudflare
│   └── analytics/         # Search Console / GA4
└── ui/

現在の Phase 1 では src/config/*.tsscripts/blog-gate.ts が core の役割を担う。汎用化時にこれらを src/lib/mcluhan/core/ に移植する設計。Phase 4 でエージェント化、Phase 5 で切り出し版として独立配布に進む予定だ。


まとめ — engine が分けるもの

AI 量産時代の owned media は「engine をどう作るか」で差がつく。記事を AI が書ける時代に、差別化要因として残るのは:

  • 機械的に検出できる失敗を機械が止める仕組み(Gate)
  • 状態管理と公開タイミングを decouple する設計(4 状態機械)
  • 表記揺れと禁則を SSOT で物理的に閉じる構造(tags / pillars / series / members)
  • 公開を自動 drip 化する scheduler(Vercel cron + scheduledAt)
  • AI 利用の透明性を担保する footer(accountability)
  • pipeline 自身を改善対象として組み込む retro 累積(self-referential ループ)

人間が時間を使う領域は voice 設計と編集判断と一次情報の生成に限定される。それ以外は engine が引き受ける。

そしてこの記事自身が、mcluhan の最初の本番出力の 1 つとして、5/21 18:00 JST の slot に予約され、Vercel cron が build を trigger し、Gate G1-G13 を通過して、reviewer takumi-morimoto が承認して、今読者が読んでいる。

記事は流れていく。engine の設計は残り、次の記事を生み続ける。

SHARE X でシェア B! はてブ