見出し画像

設計判断として残す 6 つの実装記録

中小企業の AI 時代 Web 戦略 #5(完結回) ── kidsplates.jp 実装ノート

連載 #3 の audit-state SoT + 連載 #4 の 5 エピソードを、設計判断として残す技術記録
具体実装は変わる。設計判断は残る。LLM 時代の運用は「ハウツー本」より「なぜそう決めたか」が長持ちする。


前置き ── なぜ実装記録か

連載 #3 と #4 で書いた 6 つのエピソード(audit-state SoT / crawler-audit / vps-bridge skill / contact form spam hardening / avatar build flag / partial promote 機構)について、本記事では 設計判断にフォーカスして整理します。

ハウツー本にしたいわけではありません。具体実装は半年から 1 年で変わります。`scripts/audit-state.mjs` の関数構成も、`gas/form-notify-with-gemini.js` の Turnstile 検証ロジックも、フレームワーク更新やベンダー仕様変更で変わる前提のものです。

しかし 設計判断 ── なぜ手書きでなく自動測定にしたか、なぜ runtime でなく build-time にしたか、なぜ Pattern B でなく vitest 経由にしたか ── は長持ちします。同じ問題に遭遇した他の中小企業が、別の実装スタックでも応用できる原則がここにあると考えています。

中核 3(audit-state SoT / contact form / partial promote)を厚めに、補完 3(crawler-audit / avatar build flag / vps-bridge)を簡潔に書きます。


§1 audit-state SoT ── 数字は手書きしない

audit-state SoT

起点

連載 #3 で書いた通り、起点は Codex 配布素材整合監査の 5 ラウンド連続ループ でした。配布素材ドキュメント(handoff / action-plan / 計画書)に書いていた数値メトリック(HEAD ハッシュ / commit 件数 / テスト件数 / JSON-LD 件数等)が、現実とズレ続ける現象。修正のたびに別の数字がズレる。

5 回目の Codex Second Opinion で根本指摘:

現在 HEAD を手書き doc に閉じ込めると同じズレが再発します。手書き doc は履歴・判断・方針に寄せ、現在値は scripts/audit-state.mjs などで生成するのが安全です。

設計

`scripts/audit-state.mjs` (commit `e436d0d`、2026-05-04 22:36) で 6 種類のメトリックを一括測定:

  • `repo` ── HEAD / origin/main / ahead-behind / Phase 2 base からの commit count / untracked

  • `tests` ── ファイル別件数 + 合計 (vitest `--reporter=json` から取得)

  • `dist` ── HTML ページ数 / JSON-LD 注入数 / sitemap URL 数 / image 数

  • `liveTestDomain` ── `vps-test.kidsplates.jp` の HTTP status / Last-Modified / `build-info.json` 内 commit / 配信 commit と repo HEAD の一致判定

  • `productionDomain` ── `kidsplates.jp` の status / WordPress markers (`X-Pingback`)

  • `measuredAt` ── 計測時刻 (ISO 8601)

結果は `audit-state.json` として書出。配布素材ドキュメントはこの JSON を参照する形に書換、本文に数値を手書きしない。

`tests/audit-state.test.ts` が `npm test` 実行時に `audit-state.json` と現実(`git rev-parse HEAD` / `git rev-list --count` / dist 内 HTML 件数等)の整合性を検証。ズレたら CI で fail。配布素材を Codex 監査に出す前に必ず気付く構造。

設計判断その 1 ── 循環依存の回避(`--exclude`)

実装で意外と効いた工夫が、`tests/audit-state.test.ts` を `audit-state.mjs` 自身の vitest 計測対象から除外 することでした(`scripts/audit-state.mjs` 内で vitest を起動する際 `--exclude tests/audit-state.test.ts` を指定)。

理由: `audit-state.mjs` が `audit-state.json` を更新するために vitest を起動するとき、もし `audit-state.test.ts` が含まれていると、それは前回の `audit-state.json` と現在の HEAD の整合性を検証しようとして fail する。前回値はまだ古いので、当然合わない。これでスクリプトが exit non-zero になり、`audit-state.json` の書出しに到達しない。循環依存です。

解: `audit-state.mjs` 内の vitest 起動では `audit-state.test.ts` を除外、`npm test` のフルスイートでは含める。測定の vitest整合性検証の vitest を分離する。

設計判断の言い方をすると: 「状態を更新する処理」と「状態を検証する処理」を循環させない。検証が更新を blocking する構造は不安定。

設計判断その 2 ── 「数値は手書きしない」の強制

`runbook.md` には禁止行為が明示されています。

  • `audit-state.json` を更新せずに distribution doc を変更

  • `tests/audit-state.test.ts` を skip / 削除して fail を回避

これは LLM の自己申告に依存しないための明示ルール です。LLM が「数値ズレてるから skip しよう」「audit-state は別途更新するから本文 OK」と判断する経路を、ルールレベルで物理排除している。

運用結果

96 commit 分のドリフト 0 件(2026-05-09 audit-state.json 実測値: `commitCountFromPhase2Base: 96`)。Codex 5 ラウンドのループは 6 ラウンド目が起きない構造で物理的に終わった。


§2 contact form spam hardening ── 入口で物理的に防ぐ

contact form spam hardening 設計

起点

2026-05-07 朝、お問い合わせフォームに同一スパムが 2-3 回ずつ届く現象。連載 #4 第 3 幕で書いた経緯です。

Form 回答シートの row 数(Drive 上の `19Dq34n4...` ID のスプレッドシート)を実読 → 同じスパマが 5-11 秒間隔で 3 行ずつ独立記録されている事実を確認 → GAS バグでなく bot 多重 POST と確定

設計判断その 1 ── output sanitization は対症療法、input 側 anti-bot こそ本丸

最初に出た本能的な対応は GAS 側の hardening(`a19d3a5` / `4ec150f` / `d717f04`、2026-05-07 朝の 3 段)でした。`slackEscape` の特殊文字エスケープ、email validation、件名 sanitize、Slack 全 8 text field を escape 後 cap、Gmail 全失敗時 Slack エスカレ。

これは output の堅牢化です。重複は 1 件も減らない(bot が依然 3 回 POST してくる)。

本格対処は別物でした。入口を変える

`<form action="https://docs.google.com/.../formResponse">` という Astro 側の直 POST 設計を廃止し、commit `7c2c12e`(2026-05-07 09:29) で:

  • フォームの action を GAS Web App `doPost` 経由 に切替(`gas/form-notify-with-gemini.js:237` の `doPost(e)` 関数)

  • Cloudflare Turnstile で human verify(`verifyTurnstile()` 関数、`L497`、server-side Siteverify 必須)

  • honeypot field(`website` という人間には見えない field、bot が埋めると即弾く、`L244-245`)

  • 短期 dedup + obvious-random-spam 検知(CacheService の `contact_dedupe` キー、`L639`)

  • server-side Google Forms forwarding(承認された送信のみ GAS が Google Forms に転送)

これで `dist/contact/index.html` から `/formResponse` URL も `entry.*` IDs も完全に消える(build 後の HTML を grep して確認可能)。bot が scrape する面が物理的に存在しない。

設計判断その 2 ── fail-closed

`src/pages/contact/index.astro` で、`PUBLIC_CONTACT_FORM_ENDPOINT` と `PUBLIC_TURNSTILE_SITE_KEY` の env が無ければ form が disabled(fail closed)。env が欠けたまま deploy してしまっても、フォームは「送信ボタン無し」状態で公開される。bot に隙を与えない

// 概念図
if (!CONTACT_ENDPOINT || !TURNSTILE_SITE_KEY) {
  // form は描画しない、または disabled
}

LLM が env を見落としても、form が動かないだけで bot に POST 面を晒すことはない。

設計判断その 3 ── input layer と output layer は独立に threat model

最初の本能的な対応(output hardening のみ)は、threat model の取り違いでした。output sanitization は 「内部に届いたデータの処理がどれだけ安全か」 の話。「外部から内部にどれだけデータが届くか」 はそれと独立に設計する必要がある。

ルール化すると: `<form action="外部 service の private endpoint">` 直 POST は anti-pattern として reject。外部 service の入力エンドポイントを HTML に書き出す構造は、URL が即 scrape されることを前提に threat model する。

運用結果

prod tag `prod-20260507-01` 昇格後、観察 1 日で 旧 `/formResponse` 直叩き多重 POST パターンはゼロ件。新 form 経由の通常送信は機能。Turnstile widget の human verify、honeypot、dedup が入口で物理的に bot を弾く構造が成立。


§3 partial promote 機構 ── conflict 時 abort + branch 削除


partial promote

起点

2026-05-09、「news 記事 1 件だけ本番に出したい、他の staging 上 commit はまだ出したくない」要望。Astro の静的全量ビルド整合(`audit-state.json` / `build-info.json` の SoT)と衝突。

設計

3 ファイルが新規:

  • `scripts/build-release-branch.mjs` ── path-glob で `main` の特定 commits だけを cherry-pick した release branch を生成 + push

  • `scripts/promote-to-production.mjs --source-ref <release-branch>` ── 既存の promote スクリプトに `--source-ref` flag を追加

  • `tests/audit-state.test.ts` を branch-aware に(release branch 上では origin/main との ahead/behind ≠ 0 でも fail させない)

設計判断その 1 ── conflict 時 abort + branch 削除

`scripts/build-release-branch.mjs` 中で cherry-pick conflict が出たとき、自動的に `git cherry-pick --abort` し、生成中の release branch を削除する設計。half-applied state の release branch を残さない。

理由: LLM(あるいは人間)が cherry-pick 途中で conflict を見たとき、「ここは雑に resolve しておこう」「とりあえず branch を残しておいて後で resolve」と判断するインセンティブが働く。これらは事故の温床。

「中途半端な状態の release branch は存在しない」 という不変条件を、スクリプトレベルで物理保証する。conflict が出たら、abort → branch 削除 → 最初からやり直す経路しかない。

関数構成(`scripts/build-release-branch.mjs`):

ensureCleanTree()       ← working tree が clean か検証
latestProdTag()         ← base prod tag を取得
listCommits(base, src)  ← cherry-pick 対象 commits を列挙
changedFilesFor(commit) ← 各 commit の変更ファイルを取得
commitMatchesPaths(...) ← path-glob と照合(全ファイル match で include)
deriveBranchName(...)   ← release branch 名を決定的に生成

`tests/build-release-branch.test.ts` には `globToRegex` / `pathMatchesAny` / `commitMatchesPaths` の 10 個の unit test が並んでいて、glob 解釈 / 複数 glob の union / mixed commit(一部 match)の reject 等のエッジケースをカバー。

設計判断その 2 ── audit-state を branch-aware に

release branch では `audit-state.json` の inSyncWithOrigin チェック(`origin/main` との ahead/behind == 0)が当然 fail する。release branch は意図的に main と異なる(cherry-pick で再構成された branch)から。

`a1b1e09 test(audit-state): skip in-sync check on release/* branches` で、test を branch 名 prefix で分岐。`release/*` branch では in-sync 検証を skip、それ以外では従来通り検証。

意図的な不一致と意図せざる不一致を区別する設計。`release/*` 命名規約自体が「これは意図的に main と異なる branch」という signal として機能する。

設計判断その 3 ── partial の単位は commit、dist file ではない

`docs/operations/release-flow.md` に明記:

dist のファイルを 1 つだけ差し替えたい → 不可 ── 整合が壊れる。partial 昇格に変換するか、commit を作り直す

Astro 全量ビルドの整合性を保つため、partial 性は commit 単位でしか表現できない。「特定ファイルだけ古い版に戻す」は技術的に可能でも、設計レベルで禁止。

運用結果

初回 partial promote `release/2026-05-09-news-bootstrap` を modecchi が staging で cherry-pick verify → `prod-20260509-01` 発行 → production `kidsplates.jp/build-info.json` で commit ハッシュ確認、`/news/.../` HTTP 200 を実観測 → 機構が実運用で機能することを verify 完了。


§4 crawler-audit 4 validators(補完)

crawler-audit

`scripts/lib/crawler-audit.mjs` (commit `cefdfb4`、2026-05-06 00:45) の 4 つの export 関数:

  • `validateLlmsTxt(filePath)` ── 対象: `public/llms.txt`。H1 + blockquote + H2 link-list の構造を llmstxt.org 仕様で検証

  • `validateSitemap(filePath)` ── 対象: `dist/sitemap-*.xml`。urlset / loc / changefreq enum / priority 0.0-1.0 / W3C lastmod を検証

  • `validateRobotsTxt(filePath)` ── 対象: `public/robots.txt`。構文 parse (`robots-parser` npm) + AI bot 10 種 allow を確認

  • `validateJsonLd(distDirPath)` ── 対象: dist 内全 HTML。各 HTML の `<script type="application/ld+json">` を抽出して `@context schema.org` + `@type` を検証

集約関数 `readCrawlerAudit({ publicDir, distDir })` が 4 つを束ねて、`audit-state.json` に統合される。

設計判断 ── fail-open with deadline

連載 #4 推奨は「通過しないと公開できない構造」(fail-closed)です。なぜ fail-open(warn-level)で導入したか。

実装時点での false positive 率が未知だったから。いきなり fail-closed にすると、true positive(本物のバグ)で開発が止まる以外に false positive(linter の癖や仕様の境界例) でも開発が止まる。false positive で何度も止まると、開発者は linter を skip するか offline で動かす習慣がつく ── これは fail-closed の意図に反する。

そこで段階移行:

  1. fail-open(warn-level)で導入、`tests/audit-state.test.ts` には構造存在確認のみのアサーション、件数 0 強制なし

  2. 7 日連続 false-positive 0 を観察、これが満たされた項目から個別 fail-closed に移行

  3. 全項目 fail-closed に到達した時点で「通過しないと公開できない構造」完成

「いきなり完成形でなく、運用と並走して段階移行する」の典型例です。


§5 avatar build flag(補完)

連載 #4 第 4 幕で書いた `PUBLIC_ENABLE_AVATAR_LAYER` フラグ(commit `0a09e00`、2026-05-07 12:26)。`BaseLayout.astro` で:

{import.meta.env.PUBLIC_ENABLE_AVATAR_LAYER === 'true' && (
  <AvatarLayer ... />
)}

設計判断 ── runtime でなく build-time

  • runtime チェック ── env が無いとき: コードは含まれて実行時に分岐。LLM が flag check 行を消して「綺麗に」してしまう可能性がある

  • build-time チェック (採用) ── env が無いとき: コードが tree-shaking で 物理的に absent。物理的に absent な機能は実装変更で本番には出ない

build-time なら、staging `.env.local` に `PUBLIC_ENABLE_AVATAR_LAYER=true` を立てる → Avatar Layer 関連コードが含まれる。production には flag を立てない → ビルド時に tree-shaking で 物理的に消える。LLM が AvatarLayer のソースコードをいじっても、production の dist には AvatarLayer の文字列が一切存在しない構造。

機械的縛りは LLM の注意力に依存しない ことが本質。runtime チェックは注意力に依存、build-time は依存しない。


§6 vps-bridge skill(補完)

`~/.claude/skills/vps-bridge.md` (commit `0a0a2f2`、2026-05-06 02:51 着地)。5 つの PowerShell wrapper + Claude Code Skill yaml frontmatter。

  • `send-instruction.ps1` ── ローカル → VPS Claude に一発指示送信 (課金発生)

  • `connect-vps.ps1` ── modecchi の対話 attach 用

  • `upload-asset.ps1` ── ファイル転送 (画像 / PDF 等)

  • `sync-transcripts.ps1` ── VPS の transcript をローカルに pull

  • `install-shortcut.ps1` ── デスクトップショートカット作成

設計判断 ── skill 自体が機械的縛り

Skill の YAML frontmatter と本文に、LLM が動ける範囲が明示的に書かれている:

  • 課金安全則: `-MaxTurns 20` 上限、`-DryRun` で事前確認

  • 権限境界: `-AllowedTools "Bash(git *) Edit Read Write"` で許可ツールを明示

  • アンチパターン: 「`connect-vps.ps1` で attach するのは modecchi の役割、LLM は使わない」「課金確認なしの本番タスクは禁止」

これらは Skill ファイルにテキストとして書き込まれていて、LLM が Skill を invoke した時に system prompt として注入される。LLM が「自由に判断」できる余地が、Skill 設計の時点で物理的に狭められている

機械的縛りの形態は多様で、設計ドキュメントに書かれた制約も、その制約が確実に LLM の判断に流し込まれる経路(=Skill の自動読み込み)があれば、機械的縛りとして機能します。


6 実装の共通設計原則

6 実装の共通原則
  • audit-state SoT ── (a) LLM の判断を介さない / (d) 再現可能 (script で残す)

  • crawler-audit ── (a) LLM の判断を介さない / (b) fail-open with deadline

  • vps-bridge skill ── (a) LLM の判断を介さない / (b) fail-closed (課金上限・権限境界)

  • contact form ── (a) LLM の判断を介さない / (b) fail-closed (env 欠如時 form disable)

  • avatar build flag ── (a) LLM の判断を介さない / (b) build-time tree-shaking で物理 absent

  • partial promote ── (a) LLM の判断を介さない / (b) conflict 時 abort + branch 削除

抽出すると 4 原則:

  • (a) LLM の判断を介さない ── 機械的に判定可能なものは機械に任せる、LLM の自己申告に依存しない

  • (b) fail-closed か fail-open with deadline ── 「いつ fail-closed になるか」を明示。永遠の warn は warn ではなく無視されるノイズ

  • (c) verify before claim ── 主張する前に一次情報で確認(`audit-state.json` の値 / build-info.json の commit / Form 回答シートの row 数)。要約 doc を根拠にしない

  • (d) 再現可能 ── 設計判断は script として残す。次の人が読める形で


中小企業がここまでやる必要があるのか

本連載で扱った 6 実装は、中小企業のコーポレートサイトとしては オーバースペックに見える かもしれません。Codex 監査 5 ラウンド、partial promote 機構、Turnstile + honeypot + dedup の 3 段防御。「うちのサイトは月 100 PV の名刺サイトだから不要」と感じるのも自然です。

ですが、実装コストの大部分は初期投資でした。一度 audit-state SoT が動き出せば、以降の commit で数値ドリフトが起きません。一度 contact form が GAS Web App + Turnstile 経由になれば、以降スパム多重通知が来ません。一度 partial promote 機構が入れば、以降「本番に何を出すか」の judgement を機械に渡せます。

つまり、LLM 駆動運用を 1 年回す前提なら投資回収する。逆に LLM 駆動を使わないなら不要(従来通り「制作会社に発注 → 数日待つ」の経路で十分)。

連載 #2 で書いた「サイト更新を 1-2 週間から数分に短縮する」効果と、本記事で書いた「機械的縛りで安全性を上げる」コストは、セットでしか成立しない。安全性無しに自走させると事故るし、自走させずに安全性だけ整備しても意味がない。

中小企業がこのレベルの設計を採るかは事業判断ですが、規模を問わず効く構造である、というのが連載を通して確認したことです。


参考一次情報

設計原則の出典

各実装の出典(commit / 関数 / 設計 doc)

  • §1 audit-state SoT: commit `e436d0d` + `ea6d501` + `e6b8f0b`、`scripts/audit-state.mjs`、`tests/audit-state.test.ts`、`docs/operations/runbook.md` §「audit-state.json を唯一の数値メトリック源とする」

  • §2 contact form: commits `a19d3a5` / `4ec150f` / `d717f04` / `7c2c12e` / `30fb5ac`、`gas/form-notify-with-gemini.js` L237 `doPost` / L497 `verifyTurnstile`、`docs/handoff/2026-05-07-contact-form-spam-hardening.md`、`docs/operations/contact-form-flow.md`

  • §3 partial promote: commits `f23884a` / `a1b1e09` / `6036248`、`scripts/build-release-branch.mjs`、`scripts/promote-to-production.mjs --source-ref`、`tests/build-release-branch.test.ts` 10 unit test、`docs/operations/release-flow.md`

  • §4 crawler-audit: commit `cefdfb4`、`scripts/lib/crawler-audit.mjs` 4 export 関数 + `readCrawlerAudit` 集約

  • §5 avatar build flag: commit `0a09e00`、`src/layouts/BaseLayout.astro` の `import.meta.env.PUBLIC_ENABLE_AVATAR_LAYER`

  • §6 vps-bridge skill: commit `0a0a2f2`、`~/.claude/skills/vps-bridge.md`、`~/.claude/scripts/local-vps-bridge/*.ps1`(5 wrapper)

Cloudflare Turnstile


#実装記録 #設計判断 #auditstate #Turnstile #partialpromote #buildflag #ClaudeCode #中小企業のAI戦略 #GEO対策 #生成AI運用 #Astro #AI時代のWeb戦略 #キッズプレート #茂出木謙太郎


本記事の内容は 2026-05-11 時点のもので、各 LLM ベンダーやツールチェーンの仕様変更により挙動は変わる可能性があります。
この記事は連載「中小企業の AI 時代 Web 戦略」の第 5 回(完結回)です。


いいなと思ったら応援しよう!

茂出木謙太郎 よろしければ応援お願いします! いただいたチップはクリエイターとしての活動費に使わせていただきます!