<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Review on Tarragon</title><link>https://tarrragon.github.io/blog/tags/review/</link><description>Recent content in Review on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 29 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/review/index.xml" rel="self" type="application/rss+xml"/><item><title>infra 走 PR 流程與自動化護欄</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/</guid><description>&lt;p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。&lt;/p>
&lt;h2 id="infra-變更走-code-流程">infra 變更走 code 流程&lt;/h2>
&lt;p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。&lt;/p>
&lt;h3 id="plan-是整條鏈最關鍵的一環">plan 是整條鏈最關鍵的一環&lt;/h3>
&lt;p>&lt;code>terraform plan&lt;/code> 把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。&lt;/p>
&lt;p>plan 輸出裡最關鍵的判讀訊號是操作類型。&lt;code>+&lt;/code> 是新增，&lt;code>~&lt;/code> 是就地更新，&lt;code>-&lt;/code> 是銷毀，&lt;code>-/+&lt;/code> 是先銷毀再重建。前兩者多數情境是安全的，後兩者需要逐行細看。改一個看似無害的欄位可能觸發整個資源重建（&lt;code>-/+&lt;/code>），例如某些雲資源的 &lt;code>name&lt;/code> 或 &lt;code>identifier&lt;/code> 是 immutable 屬性，改它的唯一方式就是銷毀再建。對有狀態的服務（RDS、帶資料的 EBS volume），&lt;code>-/+&lt;/code> 代表資料遺失或停機。Review 階段抓到這個 &lt;code>-/+&lt;/code>，比 apply 到一半才發現便宜太多。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl"># plan 輸出中要特別警惕的標記
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"># forces replacement — 某個 immutable 屬性被修改，將觸發銷毀重建
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"># must be replaced — 跟上面同義，Terraform 新版的表達方式
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"># will be destroyed — 資源將被刪除
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> # aws_db_instance.primary must be replaced
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> -/+ resource &amp;#34;aws_db_instance&amp;#34; &amp;#34;primary&amp;#34; {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ~ identifier = &amp;#34;app-prod&amp;#34; -&amp;gt; &amp;#34;app-production&amp;#34; # forces replacement
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> }&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="把-plan-結果貼回-pr">把 plan 結果貼回 PR&lt;/h3>
&lt;p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。&lt;/p>
&lt;p>這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。應對方式分保守與務實兩種。保守做法是 apply 前重跑一次 plan 並比對結果 — 一致才繼續，不一致就中斷。務實做法是在合併觸發 apply 時自動跑 plan 並只在無 destroy / replace 時自動執行，有 destroy / replace 就停下來要人確認。多數團隊從務實做法開始，到遇過一次 plan-apply 不一致的事故後才升級到保守做法。&lt;/p>
&lt;h3 id="apply-失敗的回退邊界">apply 失敗的回退邊界&lt;/h3>
&lt;p>infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。例如 apply 建了一個新 subnet 但在建 route table 時 timeout，此時 subnet 存在於雲端和 state 裡，route table 只在雲端不在 state 裡（或反過來），下一次 plan 的計算基礎就不精準。&lt;/p></description><content:encoded><![CDATA[<p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。</p>
<h2 id="infra-變更走-code-流程">infra 變更走 code 流程</h2>
<p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。</p>
<h3 id="plan-是整條鏈最關鍵的一環">plan 是整條鏈最關鍵的一環</h3>
<p><code>terraform plan</code> 把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。</p>
<p>plan 輸出裡最關鍵的判讀訊號是操作類型。<code>+</code> 是新增，<code>~</code> 是就地更新，<code>-</code> 是銷毀，<code>-/+</code> 是先銷毀再重建。前兩者多數情境是安全的，後兩者需要逐行細看。改一個看似無害的欄位可能觸發整個資源重建（<code>-/+</code>），例如某些雲資源的 <code>name</code> 或 <code>identifier</code> 是 immutable 屬性，改它的唯一方式就是銷毀再建。對有狀態的服務（RDS、帶資料的 EBS volume），<code>-/+</code> 代表資料遺失或停機。Review 階段抓到這個 <code>-/+</code>，比 apply 到一半才發現便宜太多。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl"># plan 輸出中要特別警惕的標記
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"># forces replacement  — 某個 immutable 屬性被修改，將觸發銷毀重建
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"># must be replaced    — 跟上面同義，Terraform 新版的表達方式
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"># will be destroyed   — 資源將被刪除
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  # aws_db_instance.primary must be replaced
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  -/+ resource &#34;aws_db_instance&#34; &#34;primary&#34; {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      ~ identifier = &#34;app-prod&#34; -&gt; &#34;app-production&#34;  # forces replacement
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ...
</span></span><span class="line"><span class="ln">10</span><span class="cl">    }</span></span></code></pre></div><h3 id="把-plan-結果貼回-pr">把 plan 結果貼回 PR</h3>
<p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。</p>
<p>這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。應對方式分保守與務實兩種。保守做法是 apply 前重跑一次 plan 並比對結果 — 一致才繼續，不一致就中斷。務實做法是在合併觸發 apply 時自動跑 plan 並只在無 destroy / replace 時自動執行，有 destroy / replace 就停下來要人確認。多數團隊從務實做法開始，到遇過一次 plan-apply 不一致的事故後才升級到保守做法。</p>
<h3 id="apply-失敗的回退邊界">apply 失敗的回退邊界</h3>
<p>infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。例如 apply 建了一個新 subnet 但在建 route table 時 timeout，此時 subnet 存在於雲端和 state 裡，route table 只在雲端不在 state 裡（或反過來），下一次 plan 的計算基礎就不精準。</p>
<p>應對的紀律是：apply 失敗後，先跑一次 <code>terraform plan</code> 確認 state 與現實的差距，再決定是修正 code 重新 apply 還是手動清理殘留資源後 <code>terraform state rm</code>。在清理之前不要再改 code、不要連發第二次 apply — 第二次 apply 在不確定的 state 上跑，可能把問題擴大。</p>
<p>PR 流程的價值在這裡不只是事前審查，也是事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。</p>
<h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查</h2>
<p><code>fmt</code> 與 <code>validate</code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快（通常不到五秒）、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。</p>
<p><code>terraform fmt -check</code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。在本地開發階段配合 editor plugin 或 pre-commit hook 在存檔時自動 fmt，讓 CI 的 fmt check 幾乎不會再 fail — 它存在的意義是攔住那些沒裝 plugin 的人。</p>
<p><code>validate</code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏、module 呼叫的 source 解析不了，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。validate 需要先跑 <code>terraform init</code>，但可以用 <code>-backend=false</code> 跳過連線 state backend，這樣在 CI 裡不需要雲端憑證就能跑完。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform fmt -check -recursive</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -backend=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform validate</span></span></span></code></pre></div><p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。</p>
<h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞</h2>
<p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 <code>0.0.0.0/0</code>，規則不會。</p>
<h3 id="三者的側重">三者的側重</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>側重領域</th>
          <th>典型命中</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>tflint</td>
          <td>provider 層正確性與慣例</td>
          <td>棄用參數、region 不存在的 instance type、命名違規</td>
      </tr>
      <tr>
          <td>checkov</td>
          <td>安全與合規（CIS benchmark 導向）</td>
          <td>S3 公開、未加密、缺少 log、IAM 過寬</td>
      </tr>
      <tr>
          <td>tfsec</td>
          <td>安全反模式（HCL 結構導向）</td>
          <td>敏感埠全開、未加密、hardcode secret</td>
      </tr>
  </tbody>
</table>
<p>checkov 與 tfsec 的覆蓋範圍有重疊（都會掃 S3 公開與 SG 全開），差別在規則來源與報告格式。checkov 的規則對標 CIS benchmark 和多雲合規框架（AWS、Azure、GCP、Kubernetes），tfsec 更專注在 Terraform HCL 結構。兩者跑在一起時，重複的命中可以用其中一個的 skip 標記豁免。</p>
<h3 id="兩個最常攔下的反模式">兩個最常攔下的反模式</h3>
<p><strong>S3 bucket 對外公開</strong>。一個漏設 <code>block_public_access</code> 或 ACL 寫成 <code>public-read</code> 的 bucket，會讓裡面的物件對整個網際網路可讀。這類設定在 HCL 裡只是一兩行，肉眼 review 時很容易因為「看起來像樣板」而放過，但後果是資料外洩。checkov 規則 <code>CKV_AWS_19</code>（S3 bucket 未啟用 server-side encryption）和 <code>CKV_AWS_53</code>（block public access 未全開）會標記這類漏洞：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># checkov 會攔下的寫法 — 缺少 block_public_access
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;data&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;acme-customer-data&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 正確寫法 — 顯式關閉公開存取
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_public_access_block&#34; &#34;data&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  bucket</span>                  <span class="o">=</span> <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">data</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  block_public_acls</span>       <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  block_public_policy</span>     <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  ignore_public_acls</span>      <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  restrict_public_buckets</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">}</span></span></code></pre></div><p><strong>Security group 對全世界開放</strong>。一條 ingress 寫成 <code>cidr_blocks = [&quot;0.0.0.0/0&quot;]</code> 加上 port 22 或 3306，等於把 SSH 或資料庫埠暴露給全網掃描器。tfsec 與 checkov 都會標記這種「敏感埠 + 全開 CIDR」的組合。這條規則跟<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>講的 security group 收斂原則是同一件事的兩端 — 模組三教怎麼把規則寫對，本章用靜態掃描確保寫錯時擋得下來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 三道掃描串在一起，任一 fail 就中斷</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tflint --recursive
</span></span><span class="line"><span class="ln">3</span><span class="cl">checkov -d . --quiet --compact
</span></span><span class="line"><span class="ln">4</span><span class="cl">tfsec . --soft-fail<span class="o">=</span>false</span></span></code></pre></div><h3 id="命中是候選不是判決">命中是候選不是判決</h3>
<p>判讀這些工具的命中時，要區分「真漏洞」與「情境合理的例外」。並非每個 <code>0.0.0.0/0</code> 都是錯 — 一個對外的 HTTPS load balancer 在 port 443 開全網是設計本意。所以掃描的命中是候選不是判決。</p>
<p>多數工具支援用行內註解標記豁免。checkov 用 <code>#checkov:skip=CKV_AWS_260:ALB 443 對外是設計本意</code>，tfsec 用 <code>#tfsec:ignore:aws-elb-alb-not-public</code>。豁免的紀律是：每個 skip 都要寫理由、要在 PR 裡可見。沒有理由的 skip 跟關掉整條規則沒有差別 — review 時看到無理由的 skip 應該當成跟看到裸 <code>0.0.0.0/0</code> 一樣的警報。</p>
<p>把例外顯式化、留下為什麼豁免的紀錄，比關掉整條規則安全。隨時間累積的 skip 也要定期盤點：某個當初合理的例外，在架構演進後可能已經不再合理。</p>
<h2 id="atlantis-與-github-actions自動化-plan-與-apply">Atlantis 與 GitHub Actions：自動化 plan 與 apply</h2>
<p>把上述流程自動化，需要一個能監聽 PR 事件、在對的時機跑 plan 與 apply 的執行層。兩種常見做法是直接用 CI 平台（如 GitHub Actions）寫 workflow，或用 Atlantis 這類專為 Terraform PR 流程設計的工具。</p>
<h3 id="atlantis">Atlantis</h3>
<p>Atlantis 是一個常駐服務，掛在 git 平台的 webhook 上。PR 開啟時它自動跑 <code>plan</code> 並把結果貼回 PR comment，reviewer approve 後在 PR 留言 <code>atlantis apply</code>，它才執行 apply 並回報結果。它的價值在於把「誰能 apply、apply 前要不要 approve、plan 結果在哪看」這些規則收斂成一致的、可設定的流程。</p>
<p>Atlantis 內建的 state lock 語意在多 PR 並行時特別有用：當兩個 PR 都改到同一個 Terraform project，第二個 PR 的 plan 會被 lock 擋住，直到第一個 apply 完成或 PR 關閉。這避免了兩個 PR 各自拿到的 plan 基於不同的 state 快照、apply 時互相覆蓋的問題。用 GitHub Actions 要自己實作這個 lock 邏輯（通常靠 Terraform 自己的 state lock + workflow concurrency group），複雜度高得多。</p>
<p>Atlantis 的代價是它本身是一個要部署、要升級、要保護的常駐服務 — 它持有對雲端的寫入權限，所以它的部署環境必須嚴格控制存取。</p>
<h3 id="github-actions">GitHub Actions</h3>
<p>GitHub Actions workflow 的優點是不必額外維運服務、跟既有 CI 共用同一套 runner。缺點是 apply 的 gating 邏輯要自己用 workflow 條件拼出來。一個完整的 workflow 通常分成兩個 job：PR 觸發 plan job（跑 fmt / validate / scan / plan、把結果貼回 PR），合併到 main 才觸發 apply job。</p>
<p>無論哪種執行層，自動化的 apply 都需要對雲端的寫入權限，而這個權限怎麼來是整條管線的安全根基。這裡正是<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>鋪設的 OIDC 兌現的地方 — 管線不該存放長期的 access key，而是在 runner 執行時用 OIDC 向雲端換取短期 token。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 合併到主幹後，用 OIDC 換短期憑證再 apply（呼應模組二）</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write  </span><span class="w"> </span><span class="c"># 允許 runner 取得 OIDC token</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><h3 id="選型判準">選型判準</h3>
<table>
  <thead>
      <tr>
          <th>考量</th>
          <th>GitHub Actions</th>
          <th>Atlantis</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>維運成本</td>
          <td>無額外服務</td>
          <td>需部署 + 升級常駐服務</td>
      </tr>
      <tr>
          <td>state lock</td>
          <td>靠 Terraform 自身 + concurrency</td>
          <td>內建 project lock、跨 PR 互斥</td>
      </tr>
      <tr>
          <td>apply gating</td>
          <td>自己用 environment rule 拼</td>
          <td>內建 approve + <code>atlantis apply</code> 語意</td>
      </tr>
      <tr>
          <td>跨 repo 一致</td>
          <td>每 repo 各自寫 workflow</td>
          <td>一套 server config 管所有 repo</td>
      </tr>
      <tr>
          <td>適合規模</td>
          <td>少量 repo、簡單流程</td>
          <td>多 repo、需統一 apply 治理</td>
      </tr>
  </tbody>
</table>
<p>判讀自動 apply 的邊界：對會觸發資源重建或刪除的高風險 plan，多數團隊會保留人工 apply 的關卡（Atlantis 的手動 <code>atlantis apply</code>、或 workflow 加 environment protection rule 要人按確認），不讓這類變更在合併瞬間無人看管地執行。自動化的目的是消除重複勞動與人為遺漏，不是把判斷也一起省掉。</p>
<h2 id="知識留在-code而不是留在個人腦中">知識留在 code，而不是留在個人腦中</h2>
<p>走完整套 PR 流程後，infra 的真正收穫是知識從個人的記憶移到了 repo 裡。每一次「為什麼這個 security group 開這個埠」「為什麼這台機器選這個 instance type」的決策，都以 code + PR 描述 + review 討論的形式留下，新人讀 repo 就能還原當初的判斷，不必去問那個「只有他懂 infra」的人。基礎設施可被閱讀，等於它可被交接。PR 流程上線後，管理層可以從 repo 的 PR merge 歷史與 plan comment 確認所有 infra 變更都經過提案與審查——這本身就是稽核要求的變更紀錄證據，不需要額外產出。</p>
<h3 id="git-revert-的能力與邊界">git revert 的能力與邊界</h3>
<p>可 revert 是 PR 流程最直接的兌現。當某次變更引發問題，回退手段是 <code>git revert</code> 那個 commit 再走一次 PR 流程，讓基礎設施回到變更前的設定 — 跟回退一段壞掉的程式碼是同一個動作。對照手動操作的舊狀態：回退靠的是當事人記得自己改了什麼、手動在 Console 改回去，記錯或人不在就無從回退。把變更歷史留在 git，回退就從「依賴某人的記憶」變成「依賴版本紀錄」。</p>
<p>這份 revert 能力的邊界要講清楚。revert code 救得回的是「設定」，救不回已經被銷毀的狀態與資料：</p>
<ul>
<li>revert 掉一個刪除 RDS 的 commit，只是讓設定回到「該資源應該存在」。apply 時 Terraform 會試圖建一個新的空資料庫 — 但被刪掉的資料庫裡的資料不會跟著回來。</li>
<li>rename 或 replace 類的變更 revert 後，可能再觸發一次資源重建 — 因為 <code>identifier</code> 又改回去了，而 identifier 是 immutable 屬性。</li>
<li>apply 到一半失敗的 state 不能直接 revert code 修復，得先處理 state 與雲端現實的不一致。</li>
</ul>
<p>stateful 變更的真正回退仍然靠備份與快照，這正是<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a> stateful 處理與<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a> secret / state 保護要顧的事。把 git revert 當「設定層回退」就誠實，把它當「資料層回退」就會在事故裡踩空。</p>
<h3 id="知識共享的判讀訊號">知識共享的判讀訊號</h3>
<p>判讀一個團隊是否確實把知識留在 code 的訊號：當主要負責 infra 的人請假，其他人能不能只靠讀 repo 就理解現狀並安全地改一個小設定。如果答案是「得等他回來」，那不論工具鏈多完整，知識還在個人腦中，PR 流程只是形式。這個訊號比任何工具設定都更能反映 infra 的成熟度。</p>
<p>讓知識真正從個人腦中搬進 repo 的方式，除了 PR 流程本身，還需要組織層的配合 — 刻意的 review 輪替、on-call 輪值、配對操作。這條路線在<a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>展開到組織層。本章解決的是技術機制 — code 留得住知識；模組九解決的是怎麼讓團隊實際願意走這套流程、把知識交出來。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD 教學</a>：infra 管線用的就是這套驗證 / 發布 gate，plan / apply 對應 build / deploy 階段</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：管線用 OIDC 取得 apply 權限，本章是該章 OIDC 設計的回報兌現處</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：security group 收斂原則，本章用 tfsec / checkov 在 CI 攔下寫錯的全開規則</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：stateful 資源的保護策略，git revert 救不回資料層</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：secret / state 保護</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：本章把知識留在 code 的技術機制，在該章展開成組織層的採用與知識共享</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：S3 公開、敏感埠全開這類掃描攔截的反模式，對應的資料保護原則</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級</a>：權限變更走 PR 流程，讓 policy 調整有審查紀錄</li>
<li>→ <a href="/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接設計</a>：PR 歷史是交接時的知識載體</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/" data-link-title="Terraform CI Pipeline 設定指南" data-link-desc="用 GitHub Actions 建立完整的 Terraform CI pipeline：fmt → validate → tflint → plan → PR comment → apply，含 OIDC credential 與環境保護規則">Terraform CI Pipeline 設定指南</a>：GitHub Actions 完整 workflow</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置</a>：規則選擇、豁免管理、CI 整合</li>
</ul>
]]></content:encoded></item><item><title>模組七：infra 走 PR 流程與自動化護欄</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/</guid><description>&lt;p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。&lt;/p>
&lt;h2 id="infra-變更走-code-流程">infra 變更走 code 流程&lt;/h2>
&lt;p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。&lt;/p>
&lt;p>&lt;code>terraform plan&lt;/code> 是這條鏈裡最關鍵的一環。它把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。一個容易被低估的判讀訊號是 plan 裡的 &lt;code>destroy&lt;/code> 與 &lt;code>replace&lt;/code>（顯示為 &lt;code>-/+&lt;/code>）— 改一個看似無害的欄位（例如某些雲資源的 &lt;code>name&lt;/code>、或資料庫的 &lt;code>identifier&lt;/code>）可能觸發整個資源重建，對有狀態的服務代表資料遺失或停機。Review 階段抓到這個 &lt;code>-/+&lt;/code>，比 apply 到一半才發現便宜太多。&lt;/p>
&lt;p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。多數團隊在 apply 階段會重跑一次 plan 並要求它與 review 時一致，代價是流程多一道、但換到「review 看到的就是實際執行的」這個保證。&lt;/p>
&lt;p>風險邊界落在 apply 失敗的回退上。infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。所以 PR 流程的價值不只在事前審查，也在事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。&lt;/p>
&lt;h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查&lt;/h2>
&lt;p>&lt;code>fmt&lt;/code> 與 &lt;code>validate&lt;/code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。&lt;/p>
&lt;p>&lt;code>terraform fmt -check&lt;/code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。&lt;code>validate&lt;/code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。&lt;/p>
&lt;p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">jobs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">validate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runs-on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ubuntu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/checkout@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hashicorp/setup-terraform@v3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform fmt -check -recursive&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform init -backend=false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform validate&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞&lt;/h2>
&lt;p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 &lt;code>0.0.0.0/0&lt;/code>，規則不會。&lt;/p></description><content:encoded><![CDATA[<p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。</p>
<h2 id="infra-變更走-code-流程">infra 變更走 code 流程</h2>
<p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。</p>
<p><code>terraform plan</code> 是這條鏈裡最關鍵的一環。它把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。一個容易被低估的判讀訊號是 plan 裡的 <code>destroy</code> 與 <code>replace</code>（顯示為 <code>-/+</code>）— 改一個看似無害的欄位（例如某些雲資源的 <code>name</code>、或資料庫的 <code>identifier</code>）可能觸發整個資源重建，對有狀態的服務代表資料遺失或停機。Review 階段抓到這個 <code>-/+</code>，比 apply 到一半才發現便宜太多。</p>
<p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。多數團隊在 apply 階段會重跑一次 plan 並要求它與 review 時一致，代價是流程多一道、但換到「review 看到的就是實際執行的」這個保證。</p>
<p>風險邊界落在 apply 失敗的回退上。infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。所以 PR 流程的價值不只在事前審查，也在事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。</p>
<h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查</h2>
<p><code>fmt</code> 與 <code>validate</code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。</p>
<p><code>terraform fmt -check</code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。<code>validate</code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。</p>
<p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform fmt -check -recursive</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -backend=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform validate</span></span></span></code></pre></div><h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞</h2>
<p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 <code>0.0.0.0/0</code>，規則不會。</p>
<p>這三者的側重不同，組合起來覆蓋面才完整。tflint 偏向 provider 層的正確性與慣例規範：用了已棄用的參數、instance type 在該 region 不存在、命名不符規範。checkov 與 tfsec 偏向安全與合規：掃的是會造成資料外洩或權限過大的設定。兩個最常被它們攔下、也最常釀成真實事故的模式，值得單獨說明。</p>
<p>第一個是 S3 bucket 對外公開。一個漏設 <code>block_public_access</code> 或 ACL 寫成 <code>public-read</code> 的 bucket，會讓裡面的物件對整個網際網路可讀。這類設定在 HCL 裡只是一兩行，肉眼 review 時很容易因為「看起來像樣板」而放過，但後果是資料外洩。checkov 有專門規則比對 bucket 的 public access 設定，命中就讓 CI fail，逼作者在合併前說明或修正。</p>
<p>第二個是 security group 對全世界開放。一條 ingress 寫成 <code>cidr_blocks = [&quot;0.0.0.0/0&quot;]</code> 加上 port 22 或 3306，等於把 SSH 或資料庫埠暴露給全網掃描器。tfsec 與 checkov 都會標記這種「敏感埠 + 全開 CIDR」的組合。這條規則跟模組三：網路地基講的 security group 收斂原則是同一件事的兩端 — 模組三教怎麼把規則寫對，本章用靜態掃描確保寫錯時擋得下來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 三道掃描串在一起，任一 fail 就中斷</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tflint --recursive
</span></span><span class="line"><span class="ln">3</span><span class="cl">checkov -d . --quiet --compact
</span></span><span class="line"><span class="ln">4</span><span class="cl">tfsec . --soft-fail<span class="o">=</span>false</span></span></code></pre></div><p>判讀這些工具的命中時，要區分「真漏洞」與「情境合理的例外」。並非每個 <code>0.0.0.0/0</code> 都是錯 — 一個對外的 HTTPS load balancer 在 port 443 開全網是設計本意。所以這些掃描的命中是候選不是判決：多數工具支援用行內註解標記豁免（例如 checkov 的 <code>#checkov:skip</code>），代價是豁免要寫理由、要被 review，避免變成無聲略過。把例外顯式化、留下為什麼豁免的紀錄，比關掉整條規則安全。</p>
<h2 id="atlantis-與-github-actions自動化-plan-與-apply">Atlantis 與 GitHub Actions：自動化 plan 與 apply</h2>
<p>把上述流程自動化，需要一個能監聽 PR 事件、在對的時機跑 plan 與 apply 的執行層。兩種常見做法是直接用 CI 平台（如 GitHub Actions）寫 workflow，或用 Atlantis 這類專為 Terraform PR 流程設計的工具。Atlantis 是一個常駐服務，掛在 git 平台的 webhook 上：PR 開啟時它自動跑 <code>plan</code> 並把結果貼回 PR comment，reviewer approve 後在 PR 留言 <code>atlantis apply</code>，它才執行 apply 並回報結果。它的價值在於把「誰能 apply、apply 前要不要 approve、plan 結果在哪看」這些規則收斂成一致的、可設定的流程，而不是散落在各 repo 各自的 workflow 腳本裡。</p>
<p>選哪一種是機會成本的取捨。GitHub Actions workflow 的優點是不必額外維運一個服務、跟既有 CI 共用同一套權限與 runner；缺點是 apply 的 gating 邏輯（approve 後才能 apply、apply lock 避免兩個 PR 同時改同一份 state）要自己用 workflow 條件拼出來。Atlantis 的優點是這些 gating 與 state lock 是內建語意、跨多 repo 一致；缺點是它本身是一個要部署、要升級、要保護的常駐服務。團隊 repo 少、流程簡單時 Actions 划算；管理大量 Terraform repo、需要統一 apply 治理時 Atlantis 划算。</p>
<p>無論哪種執行層，自動化的 apply 都需要對雲端的寫入權限，而這個權限怎麼來是整條管線的安全根基。這裡正是模組二：身分與憑證地基鋪設的 OIDC 兌現的地方 — 管線不該存放長期的 access key，而是在 runner 執行時用 OIDC 向雲端換取短期 token。模組二講的是怎麼建立這個信任關係，本章是它的回報處：因為有了 OIDC，自動 apply 才能在不持有靜態憑證的前提下安全執行，憑證外洩的攻擊面從「一把長期金鑰」縮到「單次執行的短期 token」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 合併到主幹後，用 OIDC 換短期憑證再 apply（呼應模組二）</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write  </span><span class="w"> </span><span class="c"># 允許 runner 取得 OIDC token</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><p>判讀自動 apply 的邊界時，要留意它不適合所有變更。對會觸發資源重建或刪除的高風險 plan，多數團隊會保留人工 apply 的關卡（Atlantis 的手動 <code>atlantis apply</code>、或 workflow 加 environment protection rule 要人按確認），不讓這類變更在合併瞬間無人看管地執行。自動化的目的是消除重複勞動與人為遺漏，不是把判斷也一起省掉。</p>
<h2 id="知識留在-code而不是留在個人腦中">知識留在 code，而不是留在個人腦中</h2>
<p>走完整套 PR 流程後，infra 的真正收穫是知識從個人的記憶移到了 repo 裡。每一次「為什麼這個 security group 開這個埠」「為什麼這台機器選這個 instance type」的決策，都以 code + PR 描述 + review 討論的形式留下，新人讀 repo 就能還原當初的判斷，不必去問那個「只有他懂 infra」的人。這是這個模組從第一章開始累積的目的地：基礎設施可被閱讀，等於它可被交接。</p>
<p>可 revert 是這套機制最直接的兌現。當某次變更引發問題，回退手段是 <code>git revert</code> 那個 commit 再走一次 PR 流程，讓基礎設施回到變更前的設定 — 跟回退一段壞掉的程式碼是同一個動作。對照「只有我懂 infra」的舊狀態：那時候回退靠的是當事人記得自己改了什麼、手動在 console 改回去，記錯或人不在就無從回退。把變更歷史留在 git，回退就從「依賴某人的記憶」變成「依賴版本紀錄」。</p>
<p>這份 revert 能力的邊界要講清楚，跟本章前面講的 apply 半完成 state 是同一個誠實。revert code 救得回的是「設定」，救不回已經被銷毀的狀態與資料：revert 掉一個刪除 stateful 資源的 commit，只是讓設定回到「該資源存在」，但被刪掉的資料庫內容不會跟著回來；rename 或 replace 類的變更 revert 後，可能再觸發一次資源重建。所以 stateful 變更的真正回退仍然靠備份與快照，這正是模組五 stateful 處理與模組八 secret / state 保護要顧的事。把 git revert 當「設定層回退」就誠實，把它當「資料層回退」就會在事故裡踩空。</p>
<p>這條知識共享的路線會在模組九：怎麼把 infra 推動起來展開到組織層。本章解決的是技術機制 — code 留得住知識；模組九解決的是怎麼讓一個習慣手動操作的團隊真的願意走這套流程、把知識交出來。技術上能審查、能回溯、能交接是前提，但讓團隊實際採用它是另一層問題。</p>
<p>判讀一個團隊是否真的把知識留在 code 的訊號很具體：當主要負責 infra 的人請假，其他人能不能只靠讀 repo 就理解現狀並安全地改一個小設定。如果答案是「得等他回來」，那不論工具鏈多完整，知識還在個人腦中，PR 流程只是形式。這個訊號比任何工具設定都更能反映 infra 的成熟度。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程與自動化護欄</a></td>
          <td>PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/" data-link-title="Terraform CI Pipeline 設定指南" data-link-desc="用 GitHub Actions 建立完整的 Terraform CI pipeline：fmt → validate → tflint → plan → PR comment → apply，含 OIDC credential 與環境保護規則">Terraform CI Pipeline 設定指南</a></td>
          <td>GitHub Actions 完整 workflow（fmt → validate → tflint → plan → PR comment → apply）、OIDC credential、環境保護規則</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置</a></td>
          <td>三階段漸進啟用、規則選擇策略、inline vs 集中式豁免管理、自訂規則、false positive 處理</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD 教學</a>：infra 管線用的就是這套驗證 / 發布 gate，plan / apply 對應 build / deploy 階段</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：管線用 OIDC 取得 apply 權限，本章是該章 OIDC 設計的回報兌現處</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：security group 收斂原則，本章用 tfsec / checkov 在 CI 攔下寫錯的全開規則</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：本章把知識留在 code 的技術機制，在該章展開成組織層的採用與知識共享</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：S3 公開、敏感埠全開這類掃描攔截的反模式，對應的資料保護原則</li>
</ul>
]]></content:encoded></item><item><title>Metadata surface 要納入寫作 review 範圍</title><link>https://tarrragon.github.io/blog/report/metadata-surface-in-writing-review/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/metadata-surface-in-writing-review/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>寫作 review 的 surface 包含正文與 metadata surface。&lt;/strong> Title、description、frontmatter、heading、link label、MOC 索引條都是讀者入口與 grep 入口；它們和正文共同建立讀者第一個概念錨點。正文通過 multi-pass review 只代表 body surface 收斂，metadata surface 仍要跑同一套意圖、語氣、grep-ability 與索引一致性檢查。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Surface&lt;/th>
 &lt;th>典型位置&lt;/th>
 &lt;th>Review 責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Body surface&lt;/td>
 &lt;td>段落、表格、範例、判讀徵兆&lt;/td>
 &lt;td>完整論證、段首核心、案例補足&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Metadata surface&lt;/td>
 &lt;td>&lt;code>title&lt;/code>、&lt;code>description&lt;/code>、&lt;code>tags&lt;/code>、&lt;code>weight&lt;/code>&lt;/td>
 &lt;td>讀者第一眼、搜尋摘要、排序與分類&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Navigation surface&lt;/td>
 &lt;td>&lt;code>_index.md&lt;/code> 索引條、MOC hook、link label&lt;/td>
 &lt;td>跨篇路由、下一步判斷、概念入口一致性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Identity surface&lt;/td>
 &lt;td>檔名、slug、canonical link&lt;/td>
 &lt;td>可回溯識別、跨工具定位、單次 grep 命中&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判別問題：「&lt;strong>讀者看到這篇文章之前，會先看到哪些文字？這些文字有沒有跟正文跑同一輪 review？&lt;/strong>」&lt;/p>
&lt;hr>
&lt;h2 id="warp-分析摘要">WARP 分析摘要&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>觀察&lt;/td>
 &lt;td>在建立資安章節大綱時，正文已採用「資安作為風險路由系統」的正向概念，但 frontmatter title 與 &lt;code>_index.md&lt;/code> 索引條保留 &lt;code>資安不是 Checklist：它是風險路由系統&lt;/code>。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>判讀&lt;/td>
 &lt;td>Review frame 套在 body surface，metadata surface 被當成包裝文字；因此「正向陳述優先」實際只覆蓋正文，讀者入口仍使用負向 hook。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>策略&lt;/td>
 &lt;td>把 metadata surface 明列成 review scope：title、description、tags、heading、link label、MOC hook、slug / filename 都要跟正文一起跑 positive wording、focus、grep-ability、cross-link pass。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結論&lt;/td>
 &lt;td>&lt;code>compositional-writing&lt;/code> 的 multi-pass 規則需要補一個 surface 軸：frame 決定看什麼品質，surface 決定掃哪些文字。Frame × surface 同時完整，review 才能覆蓋文章實際被讀到的位置。&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>反向驗證：有些標題可以保留對照句型，條件是正文需要讀者先排除常見誤解，且標題本身同時給出正向概念錨點。這次的正文已能用「資安作為風險路由系統」直接建立錨點，對照句型放在正文的論證段更穩定。&lt;/p>
&lt;hr>
&lt;h2 id="情境">情境&lt;/h2>
&lt;p>建立 &lt;code>content/backend/07-security-data-protection/security-as-risk-routing-system.md&lt;/code> 時，文章責任已經寫成「把資安從檢查項目轉成工程路由語言」。正文段落也使用了正向定義：資安路由系統先判斷風險落點，再選擇控制面。&lt;/p>
&lt;p>問題出現在讀者入口：&lt;/p>
&lt;ul>
&lt;li>Frontmatter &lt;code>title&lt;/code> 使用 &lt;code>資安不是 Checklist：它是風險路由系統&lt;/code>&lt;/li>
&lt;li>&lt;code>content/backend/07-security-data-protection/_index.md&lt;/code> 的索引條沿用同一個 link label&lt;/li>
&lt;li>Review 討論集中在正文與章節內容，title / MOC hook 沒被列為同一輪檢查對象&lt;/li>
&lt;/ul>
&lt;p>這個問題的主因是 review surface enumeration 漏列：執行者知道要跑正向陳述檢查，但心中 scope 等於「正文段落」，沒有把 metadata surface 視為同等重要的文字。&lt;/p>
&lt;hr>
&lt;h2 id="理想做法">理想做法&lt;/h2>
&lt;h3 id="第一步先列出本次產出的所有-surface">第一步：先列出本次產出的所有 surface&lt;/h3>
&lt;p>寫作前先列出本次會產生或修改的文字位置。例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">content/backend/07-security-data-protection/security-as-risk-routing-system.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">- frontmatter.title
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">- frontmatter.description
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">- body headings
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">- body paragraphs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">- link labels
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">content/backend/07-security-data-protection/_index.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">- table link label
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">- table topic
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">- table responsibility&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份清單是 review 的 surface enumeration。它補足 &lt;a href="../applicability-scope-must-be-enumerated/">#96 適用範圍要展開成 file enumeration&lt;/a> 的單檔內版本：#96 先列「哪些檔」，本卡再列「檔內哪些文字位置」。&lt;/p>
&lt;h3 id="第二步每一輪-frame-都掃所有-surface">第二步：每一輪 frame 都掃所有 surface&lt;/h3>
&lt;p>Multi-pass review 的每輪 frame 都要套到 surface 清單上：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Frame&lt;/th>
 &lt;th>Body surface&lt;/th>
 &lt;th>Metadata / navigation surface&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>對意圖&lt;/td>
 &lt;td>段落是否回到核心責任&lt;/td>
 &lt;td>Title / description 是否承接同一個核心責任&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>正向陳述 / 機會成本語氣&lt;/td>
 &lt;td>段落是否先建立概念，再補對照&lt;/td>
 &lt;td>Title / MOC hook 是否先給正向錨點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Grep-ability / 命名&lt;/td>
 &lt;td>段首關鍵字是否可搜尋&lt;/td>
 &lt;td>Title、slug、link label 是否能單次 grep 命中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cross-link 健康度&lt;/td>
 &lt;td>引用是否指向正確卡片&lt;/td>
 &lt;td>&lt;code>_index.md&lt;/code> 索引條是否導向同一個概念入口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>反例 / 邊界&lt;/td>
 &lt;td>對照段是否保留原因與適用範圍&lt;/td>
 &lt;td>標題若使用對照句型，是否有正文立即承接其原因&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Surface enumeration 讓「我有跑正向陳述 pass」變成可驗證動作，而不只是抽象自我宣告。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>寫作 review 的 surface 包含正文與 metadata surface。</strong> Title、description、frontmatter、heading、link label、MOC 索引條都是讀者入口與 grep 入口；它們和正文共同建立讀者第一個概念錨點。正文通過 multi-pass review 只代表 body surface 收斂，metadata surface 仍要跑同一套意圖、語氣、grep-ability 與索引一致性檢查。</p>
<table>
  <thead>
      <tr>
          <th>Surface</th>
          <th>典型位置</th>
          <th>Review 責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Body surface</td>
          <td>段落、表格、範例、判讀徵兆</td>
          <td>完整論證、段首核心、案例補足</td>
      </tr>
      <tr>
          <td>Metadata surface</td>
          <td><code>title</code>、<code>description</code>、<code>tags</code>、<code>weight</code></td>
          <td>讀者第一眼、搜尋摘要、排序與分類</td>
      </tr>
      <tr>
          <td>Navigation surface</td>
          <td><code>_index.md</code> 索引條、MOC hook、link label</td>
          <td>跨篇路由、下一步判斷、概念入口一致性</td>
      </tr>
      <tr>
          <td>Identity surface</td>
          <td>檔名、slug、canonical link</td>
          <td>可回溯識別、跨工具定位、單次 grep 命中</td>
      </tr>
  </tbody>
</table>
<p>判別問題：「<strong>讀者看到這篇文章之前，會先看到哪些文字？這些文字有沒有跟正文跑同一輪 review？</strong>」</p>
<hr>
<h2 id="warp-分析摘要">WARP 分析摘要</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>觀察</td>
          <td>在建立資安章節大綱時，正文已採用「資安作為風險路由系統」的正向概念，但 frontmatter title 與 <code>_index.md</code> 索引條保留 <code>資安不是 Checklist：它是風險路由系統</code>。</td>
      </tr>
      <tr>
          <td>判讀</td>
          <td>Review frame 套在 body surface，metadata surface 被當成包裝文字；因此「正向陳述優先」實際只覆蓋正文，讀者入口仍使用負向 hook。</td>
      </tr>
      <tr>
          <td>策略</td>
          <td>把 metadata surface 明列成 review scope：title、description、tags、heading、link label、MOC hook、slug / filename 都要跟正文一起跑 positive wording、focus、grep-ability、cross-link pass。</td>
      </tr>
      <tr>
          <td>結論</td>
          <td><code>compositional-writing</code> 的 multi-pass 規則需要補一個 surface 軸：frame 決定看什麼品質，surface 決定掃哪些文字。Frame × surface 同時完整，review 才能覆蓋文章實際被讀到的位置。</td>
      </tr>
  </tbody>
</table>
<p>反向驗證：有些標題可以保留對照句型，條件是正文需要讀者先排除常見誤解，且標題本身同時給出正向概念錨點。這次的正文已能用「資安作為風險路由系統」直接建立錨點，對照句型放在正文的論證段更穩定。</p>
<hr>
<h2 id="情境">情境</h2>
<p>建立 <code>content/backend/07-security-data-protection/security-as-risk-routing-system.md</code> 時，文章責任已經寫成「把資安從檢查項目轉成工程路由語言」。正文段落也使用了正向定義：資安路由系統先判斷風險落點，再選擇控制面。</p>
<p>問題出現在讀者入口：</p>
<ul>
<li>Frontmatter <code>title</code> 使用 <code>資安不是 Checklist：它是風險路由系統</code></li>
<li><code>content/backend/07-security-data-protection/_index.md</code> 的索引條沿用同一個 link label</li>
<li>Review 討論集中在正文與章節內容，title / MOC hook 沒被列為同一輪檢查對象</li>
</ul>
<p>這個問題的主因是 review surface enumeration 漏列：執行者知道要跑正向陳述檢查，但心中 scope 等於「正文段落」，沒有把 metadata surface 視為同等重要的文字。</p>
<hr>
<h2 id="理想做法">理想做法</h2>
<h3 id="第一步先列出本次產出的所有-surface">第一步：先列出本次產出的所有 surface</h3>
<p>寫作前先列出本次會產生或修改的文字位置。例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">content/backend/07-security-data-protection/security-as-risk-routing-system.md
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">- frontmatter.title
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">- frontmatter.description
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">- body headings
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">- body paragraphs
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">- link labels
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">content/backend/07-security-data-protection/_index.md
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">- table link label
</span></span><span class="line"><span class="ln">10</span><span class="cl">- table topic
</span></span><span class="line"><span class="ln">11</span><span class="cl">- table responsibility</span></span></code></pre></div><p>這份清單是 review 的 surface enumeration。它補足 <a href="../applicability-scope-must-be-enumerated/">#96 適用範圍要展開成 file enumeration</a> 的單檔內版本：#96 先列「哪些檔」，本卡再列「檔內哪些文字位置」。</p>
<h3 id="第二步每一輪-frame-都掃所有-surface">第二步：每一輪 frame 都掃所有 surface</h3>
<p>Multi-pass review 的每輪 frame 都要套到 surface 清單上：</p>
<table>
  <thead>
      <tr>
          <th>Frame</th>
          <th>Body surface</th>
          <th>Metadata / navigation surface</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對意圖</td>
          <td>段落是否回到核心責任</td>
          <td>Title / description 是否承接同一個核心責任</td>
      </tr>
      <tr>
          <td>正向陳述 / 機會成本語氣</td>
          <td>段落是否先建立概念，再補對照</td>
          <td>Title / MOC hook 是否先給正向錨點</td>
      </tr>
      <tr>
          <td>Grep-ability / 命名</td>
          <td>段首關鍵字是否可搜尋</td>
          <td>Title、slug、link label 是否能單次 grep 命中</td>
      </tr>
      <tr>
          <td>Cross-link 健康度</td>
          <td>引用是否指向正確卡片</td>
          <td><code>_index.md</code> 索引條是否導向同一個概念入口</td>
      </tr>
      <tr>
          <td>反例 / 邊界</td>
          <td>對照段是否保留原因與適用範圍</td>
          <td>標題若使用對照句型，是否有正文立即承接其原因</td>
      </tr>
  </tbody>
</table>
<p>Surface enumeration 讓「我有跑正向陳述 pass」變成可驗證動作，而不只是抽象自我宣告。</p>
<h3 id="第三步用-grep-補字面層掃描">第三步：用 grep 補字面層掃描</h3>
<p>正向陳述是語意判斷，但第一層候選可以用 grep 找出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">rg -n <span class="s2">&#34;不行|不可以|不是|不要|無法|不能&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  content/backend/07-security-data-protection/security-as-risk-routing-system.md <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  content/backend/07-security-data-protection/_index.md</span></span></code></pre></div><p>grep 命中代表「需要判讀」，不代表自動違規。合法的對照句型要回到 <a href="../positive-rewrite-preserves-contrast/">#94 正向改寫要保留對照論據</a> 的判準：有正向錨點、有對照原因、有適用情境。</p>
<hr>
<h2 id="沒這樣做的麻煩">沒這樣做的麻煩</h2>
<h3 id="讀者入口會先傳遞舊-frame">讀者入口會先傳遞舊 frame</h3>
<p>Title 與 MOC hook 是讀者先看到的文字。正文即使已經建立正向概念，入口若仍用負向 hook，讀者第一個 mental model 仍會被帶回「排除某種做法」而非「建立某個責任」。入口 frame 會影響後續閱讀方式。</p>
<h3 id="search-surface-會保留錯誤概念錨點">Search surface 會保留錯誤概念錨點</h3>
<p>Title、description、link label 是搜尋結果與 grep 最容易命中的位置。metadata surface 沒跑 grep-ability 與 positive wording，錯誤概念會比正文更容易被找到，長期變成知識庫中的主要入口。</p>
<h3 id="review-報告會產生-coverage-illusion">Review 報告會產生 coverage illusion</h3>
<p>只寫「已跑 positive wording pass」但沒有列 surface，review 報告會暗示整篇文章已覆蓋。實際上只掃 body surface，metadata surface 仍是未驗證區。這是 <a href="../multi-pass-scope-must-cover-risk-zone/">#95 Multi-pass scope 要蓋同類風險區</a> 在單檔內的同形問題。</p>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<ul>
<li><strong><a href="../writing-multi-pass-review/">#83 Writing 的 multi-pass review</a></strong>：#83 定義 frame 軸，本卡補 surface 軸。Frame 回答「用什麼眼睛看」，surface 回答「哪些文字都要被看」。</li>
<li><strong><a href="../multi-pass-scope-must-cover-risk-zone/">#95 Multi-pass review 的 scope 要蓋同類風險區</a></strong>：#95 處理跨檔 scope，本卡處理單檔內 surface scope。兩者組合成完整 coverage：file scope × surface scope。</li>
<li><strong><a href="../applicability-scope-must-be-enumerated/">#96 適用範圍要展開成 file enumeration</a></strong>：#96 要求可重現的 file list，本卡要求每個 file 內的 surface list。File enumeration 完成後，還要做 surface enumeration。</li>
<li><strong><a href="../positive-rewrite-preserves-contrast/">#94 正向改寫要保留對照論據</a></strong>：#94 保留合法對照的推理，本卡定義對照句型出現在 title / MOC hook 時的檢查位置與承接責任。</li>
<li><strong><a href="../naming-as-iterated-artifact/">#84 Naming 是 iterated artifact</a></strong>：Title、slug、link label 都是命名。它們需要多輪迭代，在生成後持續用 grep-ability 與讀者入口角度收斂。</li>
<li><strong><a href="../single-source-of-truth/">#44 Single Source of Truth</a></strong>：正文核心概念與 metadata surface 需要共享同一個概念 SSoT。入口文字與正文語意分裂時，讀者會看到兩個 competing source。</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>當你完成文章或卡片後，看到以下訊號就要補 surface enumeration：</p>
<table>
  <thead>
      <tr>
          <th>徵兆</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>正文改成正向概念，title 仍使用排除式 hook</td>
          <td>Metadata surface 漏跑語氣 pass</td>
      </tr>
      <tr>
          <td><code>_index.md</code> 索引條只是沿用第一版標題</td>
          <td>Navigation surface 漏跑對意圖</td>
      </tr>
      <tr>
          <td>Frontmatter description 比正文更像行銷標語</td>
          <td>Search surface 漏跑概念錨點</td>
      </tr>
      <tr>
          <td>Review 紀錄只寫「已檢查文章」但沒列 title / MOC</td>
          <td>Coverage 欠缺驗證依據</td>
      </tr>
      <tr>
          <td>Grep 掃正文通過，搜尋結果仍命中舊句型</td>
          <td>Grep scope 沒包含 metadata</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="適用範圍與邊界">適用範圍與邊界</h2>
<ul>
<li><strong>適用</strong>：技術文章、report 卡片、知識卡片、README、規格文件、skill reference、MOC / <code>_index.md</code></li>
<li><strong>特別適用</strong>：有 frontmatter、sidebar title、SEO description、index table、link label 的內容系統</li>
<li><strong>邊界</strong>：Metadata surface review 是寫作 pass；它需要語意判讀，grep 只負責提出候選</li>
<li><strong>例外</strong>：短訊息、一次性草稿、私人 scratch note 可以只保留 title / body 的最小 surface；production 內容與公開知識庫需要全 surface review</li>
</ul>
<hr>
<h2 id="可操作檢查">可操作檢查</h2>
<p>Production 內容交付前，至少跑這三步：</p>
<ol>
<li>列出這次新增 / 修改檔案的 surface：<code>title</code>、<code>description</code>、heading、body、link label、MOC row。</li>
<li>跑負向詞候選 grep，逐一判讀是否有正向錨點與對照原因。</li>
<li>對照 <code>_index.md</code> 或 MOC，確認索引條、文章標題與正文第一段都指向同一個核心責任。</li>
</ol>
]]></content:encoded></item><item><title>Cross-Reviewer Convergence：多 Reviewer 收斂的 finding 比單 Reviewer flag 信號強</title><link>https://tarrragon.github.io/blog/report/cross-reviewer-convergence-priority-weighting/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/cross-reviewer-convergence-priority-weighting/</guid><description>&lt;h2 id="核心跨-reviewer-收斂的-finding-信號強">核心：跨 reviewer 收斂的 finding 信號強&lt;/h2>
&lt;p>當跑 multi-reviewer parallel audit（4-reviewer / N-reviewer）、最 high-priority 不是 &lt;em>單一 reviewer flag 的 most severe finding&lt;/em>、是 &lt;em>多個 reviewer 從不同軸獨立 flag 的同一 finding&lt;/em>。&lt;/p>
&lt;p>直覺：&lt;/p>
&lt;ul>
&lt;li>單 reviewer flag P0 finding 是 &lt;em>該軸的判斷&lt;/em>&lt;/li>
&lt;li>跨 reviewer convergence flag 是 &lt;em>多軸共同 hit 同一點&lt;/em>、信號收斂&lt;/li>
&lt;/ul>
&lt;p>機制：N 個獨立 axis 隨機 hit 同一 finding 的機率隨 N 指數下降 — 兩個 axis 偶然 hit 同點機率低、三個 axis hit 同點機率更低。所以 convergence 排除 &lt;em>單 reviewer 主觀 / 偏好 bias&lt;/em>、留 &lt;em>系統性 issue&lt;/em>。&lt;/p>
&lt;h2 id="casemysql-4-reviewer-audit">Case：MySQL 4-reviewer audit&lt;/h2>
&lt;p>跑 4-reviewer audit（A 寫作規範 / B 跨檔一致性 / C 技術準確性 / D 結構性質疑）對 MySQL 17 篇：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>Flagged by&lt;/th>
 &lt;th>Convergence&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>4 篇 migration playbook 缺 weight + banner&lt;/td>
 &lt;td>Reviewer A + Reviewer B&lt;/td>
 &lt;td>&lt;strong>2 軸&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Frame uniformity（5 個踩雷 100% 重複）&lt;/td>
 &lt;td>Reviewer A + Reviewer D&lt;/td>
 &lt;td>&lt;strong>2 軸&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PlanetScale FK 過時 claim&lt;/td>
 &lt;td>Reviewer C 單獨&lt;/td>
 &lt;td>1 軸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PG CTE 版本錯（6.4 vs 8.4）&lt;/td>
 &lt;td>Reviewer C 單獨&lt;/td>
 &lt;td>1 軸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Connection memory 衝突（3MB vs 8-10MB）&lt;/td>
 &lt;td>Reviewer B 單獨&lt;/td>
 &lt;td>1 軸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework bias（Type A/C/E 集中）&lt;/td>
 &lt;td>Reviewer D 單獨&lt;/td>
 &lt;td>1 軸&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>2 軸 convergence 的 finding（缺 weight + frame uniformity）信號特別強 — 兩個 reviewer 從不同 audit 維度（寫作規範軸 vs 跨檔一致性軸）獨立判斷出同一 issue。&lt;/p>
&lt;p>對比：PlanetScale FK 是 &lt;em>單 reviewer 找到的 highest-severity finding&lt;/em>（invalidates 整段 Phase 1 audit premise）、但是 &lt;em>單軸 flag&lt;/em>。&lt;/p>
&lt;p>兩種都 P0、但 &lt;em>priority weighting&lt;/em> 應該不同：&lt;/p>
&lt;ul>
&lt;li>2 軸 convergence finding：&lt;em>structurally important&lt;/em>、是 batch level pattern&lt;/li>
&lt;li>單軸 high-severity finding：&lt;em>technically critical&lt;/em>、specific issue&lt;/li>
&lt;/ul>
&lt;h2 id="機制為什麼-convergence-比-severity-重要">機制：為什麼 convergence 比 severity 重要&lt;/h2>
&lt;h3 id="1-單-reviewer-flag-有-axis-specific-bias">1. 單 reviewer flag 有 axis-specific bias&lt;/h3>
&lt;p>每個 reviewer 用特定 audit 軸（寫作規範 / 一致性 / 技術 / 結構）。單軸 flag 帶該軸的 &lt;em>judgment preference&lt;/em>：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心跨-reviewer-收斂的-finding-信號強">核心：跨 reviewer 收斂的 finding 信號強</h2>
<p>當跑 multi-reviewer parallel audit（4-reviewer / N-reviewer）、最 high-priority 不是 <em>單一 reviewer flag 的 most severe finding</em>、是 <em>多個 reviewer 從不同軸獨立 flag 的同一 finding</em>。</p>
<p>直覺：</p>
<ul>
<li>單 reviewer flag P0 finding 是 <em>該軸的判斷</em></li>
<li>跨 reviewer convergence flag 是 <em>多軸共同 hit 同一點</em>、信號收斂</li>
</ul>
<p>機制：N 個獨立 axis 隨機 hit 同一 finding 的機率隨 N 指數下降 — 兩個 axis 偶然 hit 同點機率低、三個 axis hit 同點機率更低。所以 convergence 排除 <em>單 reviewer 主觀 / 偏好 bias</em>、留 <em>系統性 issue</em>。</p>
<h2 id="casemysql-4-reviewer-audit">Case：MySQL 4-reviewer audit</h2>
<p>跑 4-reviewer audit（A 寫作規範 / B 跨檔一致性 / C 技術準確性 / D 結構性質疑）對 MySQL 17 篇：</p>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>Flagged by</th>
          <th>Convergence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>4 篇 migration playbook 缺 weight + banner</td>
          <td>Reviewer A + Reviewer B</td>
          <td><strong>2 軸</strong></td>
      </tr>
      <tr>
          <td>Frame uniformity（5 個踩雷 100% 重複）</td>
          <td>Reviewer A + Reviewer D</td>
          <td><strong>2 軸</strong></td>
      </tr>
      <tr>
          <td>PlanetScale FK 過時 claim</td>
          <td>Reviewer C 單獨</td>
          <td>1 軸</td>
      </tr>
      <tr>
          <td>PG CTE 版本錯（6.4 vs 8.4）</td>
          <td>Reviewer C 單獨</td>
          <td>1 軸</td>
      </tr>
      <tr>
          <td>Connection memory 衝突（3MB vs 8-10MB）</td>
          <td>Reviewer B 單獨</td>
          <td>1 軸</td>
      </tr>
      <tr>
          <td>Framework bias（Type A/C/E 集中）</td>
          <td>Reviewer D 單獨</td>
          <td>1 軸</td>
      </tr>
  </tbody>
</table>
<p>2 軸 convergence 的 finding（缺 weight + frame uniformity）信號特別強 — 兩個 reviewer 從不同 audit 維度（寫作規範軸 vs 跨檔一致性軸）獨立判斷出同一 issue。</p>
<p>對比：PlanetScale FK 是 <em>單 reviewer 找到的 highest-severity finding</em>（invalidates 整段 Phase 1 audit premise）、但是 <em>單軸 flag</em>。</p>
<p>兩種都 P0、但 <em>priority weighting</em> 應該不同：</p>
<ul>
<li>2 軸 convergence finding：<em>structurally important</em>、是 batch level pattern</li>
<li>單軸 high-severity finding：<em>technically critical</em>、specific issue</li>
</ul>
<h2 id="機制為什麼-convergence-比-severity-重要">機制：為什麼 convergence 比 severity 重要</h2>
<h3 id="1-單-reviewer-flag-有-axis-specific-bias">1. 單 reviewer flag 有 axis-specific bias</h3>
<p>每個 reviewer 用特定 audit 軸（寫作規範 / 一致性 / 技術 / 結構）。單軸 flag 帶該軸的 <em>judgment preference</em>：</p>
<ul>
<li>Reviewer A 偏好 <em>寫作風格規範</em>、可能 flag 過嚴</li>
<li>Reviewer C 偏好 <em>technical correctness</em>、可能 flag 一些 <em>正確但 niche</em> 議題</li>
</ul>
<p>單軸 flag finding 可能是 <em>該軸 perspective 的 P0、其他軸 perspective 不重要</em>。</p>
<h3 id="2-跨-axis-convergence-排除-axis-specific-bias">2. 跨 axis convergence 排除 axis-specific bias</h3>
<p>當兩個 reviewer 從 <em>不同 axis</em> 獨立 flag 同 finding、表示這個 issue 對 <em>多種 judgment perspective</em> 都 reachable — 是 <em>系統性 pattern</em>、不是單一 perspective 的偏好。</p>
<p>舉例：「4 篇 migration playbook 缺 weight」</p>
<ul>
<li>Reviewer A 從 <em>寫作規範</em> 角度 flag：missing frontmatter required field</li>
<li>Reviewer B 從 <em>跨檔一致性</em> 角度 flag：13 篇 deep article 有 weight、4 篇 migration 沒有、不對齊</li>
</ul>
<p>兩個獨立 reasoning path 到同一 finding、信號收斂、是 <em>結構性問題</em>。</p>
<h3 id="3-convergence-finding-修一次解決多-reviewer-flag">3. Convergence finding 修一次解決多 reviewer flag</h3>
<p>實作層：</p>
<ul>
<li>單軸 P0：修 → 解決 1 個 reviewer 的 flag</li>
<li>雙軸 convergence：修 → 解決 2 個 reviewer 的 flag</li>
</ul>
<p>ROI 上 convergence finding 修法效率 2x。</p>
<h3 id="4-convergence-揭露-audit-framework-blindspot-的補集">4. Convergence 揭露 audit framework blindspot 的補集</h3>
<p>如果某 finding <em>所有 reviewer 都沒 flag</em>、可能：</p>
<ul>
<li>沒問題（true negative）</li>
<li>所有 axis 都看不到（structural blindspot）</li>
</ul>
<p>如果某 finding <em>只一 reviewer flag</em>、可能：</p>
<ul>
<li>Niche but real（axis-specific catch）</li>
<li>Axis-specific bias</li>
</ul>
<p>如果某 finding <em>多 reviewer flag</em>、強：</p>
<ul>
<li>多 axis 收斂 → 高度 likely true positive</li>
<li>排除 axis-specific bias</li>
</ul>
<h2 id="修法cross-reviewer-convergence-matrix">修法：Cross-reviewer convergence matrix</h2>
<h3 id="1-multi-reviewer-audit-後做-convergence-matrix">1. Multi-reviewer audit 後做 convergence matrix</h3>
<p>收齊 N 個 reviewer report 後、不是 merge findings list、是建 matrix：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Finding          | Reviewer A | Reviewer B | Reviewer C | Reviewer D | Convergence
</span></span><span class="line"><span class="ln">2</span><span class="cl">─────────────────┼────────────┼────────────┼────────────┼────────────┼────────────
</span></span><span class="line"><span class="ln">3</span><span class="cl">Missing weight   |     P0     |     P0     |            |            |    **2**
</span></span><span class="line"><span class="ln">4</span><span class="cl">Frame uniformity |     P1     |            |            |     -      |    **2**
</span></span><span class="line"><span class="ln">5</span><span class="cl">FK claim 過時    |            |            |     P0     |            |    1
</span></span><span class="line"><span class="ln">6</span><span class="cl">CTE version 錯   |            |            |     P0     |            |    1
</span></span><span class="line"><span class="ln">7</span><span class="cl">Conn memory 衝突 |            |     P0     |            |            |    1</span></span></code></pre></div><p>Convergence column 自動標 priority bump — 2+ 列為 <em>首要 fix</em>、1 列為 <em>依 severity 處理</em>。</p>
<h3 id="2-priority-list-按-convergence-排序不是純按-severity">2. Priority list 按 convergence 排序、不是純按 severity</h3>
<p>修法 priority：</p>
<ol>
<li><strong>2+ convergence finding</strong>（系統性 pattern）— 必修、高 ROI</li>
<li><strong>單軸 + 高 severity finding</strong>（如 FK claim 過時 invalidates premise）— 必修、specific</li>
<li><strong>單軸 + 中 severity finding</strong>（如 CTE version 錯）— 修、ROI 中等</li>
<li><strong>單軸 + 低 severity finding</strong> — 可選</li>
</ol>
<h3 id="3-convergence-揭露的-pattern-寫進-retro">3. Convergence 揭露的 <em>pattern</em> 寫進 retro</h3>
<p>2+ convergence finding 通常是 <em>寫作流程 / 模板</em> 級議題、修了該 case 還要回頭看 <em>為什麼會系統性發生</em>：</p>
<ul>
<li>Missing weight：寫 migration playbook 模板沒有 weight、是 <em>template gap</em></li>
<li>Frame uniformity：「5 個踩雷」frame 在所有 article 重複、是 <em>frame template too rigid</em></li>
</ul>
<p>把這些 pattern 寫進 retro / report card、未來不再踩。</p>
<h2 id="跟既有原則的關係">跟既有原則的關係</h2>
<ul>
<li><a href="../sibling-coverage-asymmetry-blindspot-in-priority/">Sibling Coverage Asymmetry Blindspot in Priority</a>：本卡是 <em>audit finding 的 priority weighting</em>、那卡是 <em>batch coverage 的 priority weighting</em>、不同 layer</li>
<li><a href="../multi-pass-review-frame-granularity-blindspot/">Multi-Pass Review Frame Granularity Blindspot</a>：multi-pass 是 <em>同 reviewer 多輪</em>、本卡是 <em>多 reviewer 平行</em>、不同模式</li>
</ul>
<h2 id="反向驗證">反向驗證</h2>
<p>不該誤用：</p>
<ul>
<li><em>Convergence &gt; severity</em> 不是絕對 — 單軸高 severity finding（如 invalidates premise）仍是必修、不該因為「只一軸 flag」延後</li>
<li>N=1 reviewer audit 不適用本卡 — 至少 2 個 reviewer 才有 convergence 概念</li>
<li>2 個 reviewer 用 <em>同樣 axis</em> 都 flag 不算 convergence — 必須 <em>不同 axis</em> 才是真正收斂</li>
<li>Reviewer 之間 <em>互相看過彼此 report</em> 後再 flag 不算 convergence — 必須 <em>獨立 parallel</em> 跑</li>
</ul>
<h2 id="觸發再評估">觸發再評估</h2>
<ul>
<li>N-reviewer audit 跑超過 5 輪後、check convergence finding 的 follow-up rate 是否真比單軸 finding 高</li>
<li>出現 <em>3 軸以上 convergence</em> 的 finding 時、是否 trigger framework-level review（不只是 content fix）</li>
<li>累積足夠 reviewer convergence case 後、考慮抽出 <em>axis design 原則</em>：哪些 axis 組合的 convergence 最 informative</li>
</ul>
]]></content:encoded></item><item><title>多輪審查至少三輪是硬底線</title><link>https://tarrragon.github.io/blog/report/multi-round-review-minimum-three-rounds/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/multi-round-review-minimum-three-rounds/</guid><description>&lt;h2 id="結論">結論&lt;/h2>
&lt;p>多輪審查（multi-round-review）的最低輪數是三輪，不是「看 finding 數決定要不要繼續」。Round 3 不是可選的加深，而是覆蓋 Round 1-2 結構性盲區的必要輪。&lt;/p>
&lt;h2 id="為什麼">為什麼&lt;/h2>
&lt;p>Round 1（compliance / baseline）和 Round 2（cadence / reader journey）用的 frame 都是「從作者端出發」的維度——規範有沒有遵守、句型有沒有重複、讀者走路線順不順。這兩輪能 catch 的問題有一個共同特徵：它們在「文章已經寫出來的內容」裡找錯。&lt;/p>
&lt;p>Round 3 的 frame 是「從文章沒寫的東西出發」——enumeration 有沒有漏選項（steelman）、其他系列有沒有反向引用（outbound）、搜尋落地粒度夠不夠（search landing）、知識卡缺口。這類問題在 Round 1-2 的 frame 下結構性不可見，因為 reviewer 在已有內容裡掃描時，不會主動問「這裡應該還有一個選項但沒寫」。&lt;/p>
&lt;h2 id="反模式">反模式&lt;/h2>
&lt;p>「Round 2 修完、finding 數下降、覺得差不多了就停」是最常見的反模式。multi-round-review skill 已經明確寫了「停止訊號是 frame 涵蓋、不是 finding 數遞減」，但實際執行時仍然會在 Round 2 結束後問「要不要繼續」——這個提問本身就是 finding 遞減直覺在主導判斷。&lt;/p>
&lt;h2 id="evidence">Evidence&lt;/h2>
&lt;p>Dotfile 系列（29 篇 + 知識卡）三輪審查的 finding 分布：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Round&lt;/th>
 &lt;th>Frame&lt;/th>
 &lt;th>Finding 數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>規範 / fact-check / 一致性&lt;/td>
 &lt;td>15&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>Cadence / 讀者旅程 / 冷讀&lt;/td>
 &lt;td>14&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>Steelman / Outbound&lt;/td>
 &lt;td>14&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Round 3 的 14 項不是 Round 1-2 的殘餘——它們是全新類型的問題：macOS 原生 tiling 遺漏、yadm/mise 選項缺失、跨系列反向引用斷裂、知識卡缺口。這些問題在 Round 1-2 的 frame 下不會被 catch。&lt;/p>
&lt;p>先前的 backend 教學模組 review 也觀察到類似分布：三輪各 catch 不同類型的問題、finding 數不遞減。&lt;/p>
&lt;h2 id="修法">修法&lt;/h2>
&lt;p>把「至少三輪」從「建議」升級為「硬底線」。Round 3 結束後才進入「要不要繼續」的判讀——此時用七軸涵蓋度和「想不出新 frame」作為停止訊號。&lt;/p>
&lt;h2 id="跟其他原則的關係">跟其他原則的關係&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/report/writing-multi-pass-review/" data-link-title="Writing 的 multi-pass review：N 輪 review、每輪換 frame" data-link-desc="寫文章 / 註解 / 文件 / prompt 的「寫」不是單次動作 — 是 N 輪 review。第 1 輪生成、第 2 輪對意圖（#67）、第 3 輪檢查機會成本語氣、第 4 輪 grep-ability、第 5 輪反例 / 邊界。每輪不同 frame、單輪寫不出全部維度。本卡是 #82 在「寫」這個 output 動作的具體實例。">#114 multi-pass frame 顆粒度盲點&lt;/a> — 同 frame 多輪無增益，多輪價值在 frame 切換&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/report/cross-round-review-stopping-signal/" data-link-title="跨輪 review 停止訊號是 frame 涵蓋、不是 finding 數遞減" data-link-desc="判斷「該不該再來一輪 review」的訊號是『frame 軸是否還有未動』、不是『上一輪 finding 變少』；多輪 review 的 ROI 不是 monotonically decreasing、而是 frame 切換的質性轉換 — Round N 用新 frame 通常仍會抓出 substantial finding、但內容從 surface compliance 往深層 structural issue 走；停止訊號是「下一輪可用的新 frame 已經想不出來」、不是 finding 數遞減；本卡填補 #114 / #126 / #147 沒覆蓋的「何時夠了」判讀缺口">#148 跨輪 review 停止訊號&lt;/a> — 停止訊號是 frame 涵蓋、不是 finding 遞減&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/report/writing-review-multi-axis-completeness/" data-link-title="寫作 review 是多軸完整性、不是單軸深度" data-link-desc="寫作 review 的完整性不是單一軸越做越深、是多軸交集都對齊；#83 frame 軸 &amp;#43; #121 instance 軸 &amp;#43; #97 surface 軸 &amp;#43; #95 scope 軸 &amp;#43; #122 cadence 軸 &amp;#43; #124 timing 軸 &amp;#43; #114 granularity 軸、七軸正交、缺任一軸都會 systematic miss；review 設計時要 enumerate 七軸覆蓋狀況、不是只跑一兩個維度做深；是 #79 五維決策對話在 review 工具設計的姊妹卡">#126 review 七軸完整度&lt;/a> — 七軸動完是停止條件之一，三輪是動完七軸的最低路徑&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;p>以下情境代表三輪硬底線正在被繞過：&lt;/p></description><content:encoded><![CDATA[<h2 id="結論">結論</h2>
<p>多輪審查（multi-round-review）的最低輪數是三輪，不是「看 finding 數決定要不要繼續」。Round 3 不是可選的加深，而是覆蓋 Round 1-2 結構性盲區的必要輪。</p>
<h2 id="為什麼">為什麼</h2>
<p>Round 1（compliance / baseline）和 Round 2（cadence / reader journey）用的 frame 都是「從作者端出發」的維度——規範有沒有遵守、句型有沒有重複、讀者走路線順不順。這兩輪能 catch 的問題有一個共同特徵：它們在「文章已經寫出來的內容」裡找錯。</p>
<p>Round 3 的 frame 是「從文章沒寫的東西出發」——enumeration 有沒有漏選項（steelman）、其他系列有沒有反向引用（outbound）、搜尋落地粒度夠不夠（search landing）、知識卡缺口。這類問題在 Round 1-2 的 frame 下結構性不可見，因為 reviewer 在已有內容裡掃描時，不會主動問「這裡應該還有一個選項但沒寫」。</p>
<h2 id="反模式">反模式</h2>
<p>「Round 2 修完、finding 數下降、覺得差不多了就停」是最常見的反模式。multi-round-review skill 已經明確寫了「停止訊號是 frame 涵蓋、不是 finding 數遞減」，但實際執行時仍然會在 Round 2 結束後問「要不要繼續」——這個提問本身就是 finding 遞減直覺在主導判斷。</p>
<h2 id="evidence">Evidence</h2>
<p>Dotfile 系列（29 篇 + 知識卡）三輪審查的 finding 分布：</p>
<table>
  <thead>
      <tr>
          <th>Round</th>
          <th>Frame</th>
          <th>Finding 數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>規範 / fact-check / 一致性</td>
          <td>15</td>
      </tr>
      <tr>
          <td>2</td>
          <td>Cadence / 讀者旅程 / 冷讀</td>
          <td>14</td>
      </tr>
      <tr>
          <td>3</td>
          <td>Steelman / Outbound</td>
          <td>14</td>
      </tr>
  </tbody>
</table>
<p>Round 3 的 14 項不是 Round 1-2 的殘餘——它們是全新類型的問題：macOS 原生 tiling 遺漏、yadm/mise 選項缺失、跨系列反向引用斷裂、知識卡缺口。這些問題在 Round 1-2 的 frame 下不會被 catch。</p>
<p>先前的 backend 教學模組 review 也觀察到類似分布：三輪各 catch 不同類型的問題、finding 數不遞減。</p>
<h2 id="修法">修法</h2>
<p>把「至少三輪」從「建議」升級為「硬底線」。Round 3 結束後才進入「要不要繼續」的判讀——此時用七軸涵蓋度和「想不出新 frame」作為停止訊號。</p>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<ul>
<li><a href="/blog/report/writing-multi-pass-review/" data-link-title="Writing 的 multi-pass review：N 輪 review、每輪換 frame" data-link-desc="寫文章 / 註解 / 文件 / prompt 的「寫」不是單次動作 — 是 N 輪 review。第 1 輪生成、第 2 輪對意圖（#67）、第 3 輪檢查機會成本語氣、第 4 輪 grep-ability、第 5 輪反例 / 邊界。每輪不同 frame、單輪寫不出全部維度。本卡是 #82 在「寫」這個 output 動作的具體實例。">#114 multi-pass frame 顆粒度盲點</a> — 同 frame 多輪無增益，多輪價值在 frame 切換</li>
<li><a href="/blog/report/cross-round-review-stopping-signal/" data-link-title="跨輪 review 停止訊號是 frame 涵蓋、不是 finding 數遞減" data-link-desc="判斷「該不該再來一輪 review」的訊號是『frame 軸是否還有未動』、不是『上一輪 finding 變少』；多輪 review 的 ROI 不是 monotonically decreasing、而是 frame 切換的質性轉換 — Round N 用新 frame 通常仍會抓出 substantial finding、但內容從 surface compliance 往深層 structural issue 走；停止訊號是「下一輪可用的新 frame 已經想不出來」、不是 finding 數遞減；本卡填補 #114 / #126 / #147 沒覆蓋的「何時夠了」判讀缺口">#148 跨輪 review 停止訊號</a> — 停止訊號是 frame 涵蓋、不是 finding 遞減</li>
<li><a href="/blog/report/writing-review-multi-axis-completeness/" data-link-title="寫作 review 是多軸完整性、不是單軸深度" data-link-desc="寫作 review 的完整性不是單一軸越做越深、是多軸交集都對齊；#83 frame 軸 &#43; #121 instance 軸 &#43; #97 surface 軸 &#43; #95 scope 軸 &#43; #122 cadence 軸 &#43; #124 timing 軸 &#43; #114 granularity 軸、七軸正交、缺任一軸都會 systematic miss；review 設計時要 enumerate 七軸覆蓋狀況、不是只跑一兩個維度做深；是 #79 五維決策對話在 review 工具設計的姊妹卡">#126 review 七軸完整度</a> — 七軸動完是停止條件之一，三輪是動完七軸的最低路徑</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>以下情境代表三輪硬底線正在被繞過：</p>
<ul>
<li>Round 2 結束後問「要不要繼續」「到這裡收嗎」</li>
<li>Round 3 的 frame 規劃被跳過、直接宣布 review 完成</li>
<li>用「Round 2 finding 數比 Round 1 少」作為停止依據</li>
</ul>
]]></content:encoded></item></channel></rss>