横断規約・リリース
本書は AGENTS.md の分冊(正典・AGENTS.md と同格)。優先順位は「実コード → AGENTS.md(本書を含む)→ DESIGN.md → PRODUCT.md」。 節は AGENTS.md から逐語で移設した(PR #nnn・2026-09-16)。対象パス:
apps/web/src/scenarios/**` `docs/harness/templates/**` `.github/workflows/**。この領域に触るワーカー / レビュアは本書を読む。
KYO-123 Hardening sweep(不変条件ラチェット・mig127)¶
T1 監査で、現行の最終定義に残っていた技術的負債は fill_tenant trigger の UPDATE OF 限定 4 件だけだった。DEFINER の role guard(mig106)と代表的な金額 numeric CHECK(mig22/92/95)は既に修正済みであり、mig127 は既存関数の再写経をせず trigger を全列発火へ収束させる。監査の接地列挙は .worker-specs/sweep-audit.md、実証は scripts/proofs/mig127-hardening-sweep.sql で固定する。
- DEFINER role guard:
current_user_role()の拒否判定は NULL を通す<>/!=/NOT INを使わず、IS DISTINCT FROMまたは明示的な NULL 拒否で fail-closed にする。 - numeric NaN: 金額・数量・重量等の非負
numericCHECK は、非負条件だけでなく<> 'NaN'::numericを同じ CHECK に含める。total_amountのように負値が正規の列も NaN は拒否する。 - parent-derived fill_tenant: 親から
tenant_id等を再導出する trigger は必ずBEFORE INSERT OR UPDATE(全列)で発火させる。BEFORE INSERT OR UPDATE OF <fk>に戻してはならない(tenant-only PATCH の改ざんを許すため)。 - 恒久チェック:
scripts/hardening-sweep-ratchet.mjs/hardening-sweep-ratchet.test.mjsは mig127 以降の migration に上記 3 パターンが再発していないことを検査し、pnpm turbo run test(従ってpnpm verify)で実行する。新しい例外は migration と同じ変更でこの不変条件・テストを更新する。 - 認可 deny の errcode: guard / RPC の認可拒否(role・tenant・caller profile・サーバ所有列)の
raise exceptionにはusing errcode = '42501'を付け、proof はsqlstateと文言の両方を照合する(when raise_exceptionは P0001 専用の条件名で 42501 を受けない)。mig267 が Group A(driver_profiles guard / soft_delete_legal_record / restore_legal_record)に適用した写経元。既存 143 関数・549 箇所の全面 sweep は行わない(proof handler 312 箇所が P0001 に結合しているため別判断)。引数不正・record not found・内部整合の raise は対象外。 - 全履歴の最終カタログは
scripts/proofs/qa6-catalog-sweep.sqlが RLS・DEFINER grant/search_path・soft-delete guard・fill・denylist列集合を走査する。名前付きの歴史的例外は未修正所見であり、安全認定ではない(QA-6 全スキーマ監査報告)。
業務シナリオテスト S1(不変条件ラチェット・2026-08-21)¶
設計正典は docs/superpowers/specs/2026-08-21-scenario-test-design.md。導入の根拠:
直近 1 週間で unit/render/proof/mutation の 4 層を全部緑で通過して出荷され、実際に動かして
初めて見つかったバグが 5 件あり、5 件中 4 件はデータ層の合成(画面と画面・状態と状態の
継ぎ目)で壊れていた(解析待ち滞留 v1.76.1 / 目方の便跨ぎ #625 / 再解析が飾り #622 /
破棄ブラックホール #621 / 請求書の会社名 #622)。
- 業務フローを跨ぐ変更(複数画面・複数状態・driver⇄staff を跨ぐもの)は
*.scenario.test.ts(S1・実 DB シナリオテスト)を必須とする。 置き場所はapps/web/src/scenarios/。単一画面内の変更は従来の unit/render で足りる。 - 仕様(worker spec / 設計書)には業務シナリオをユーザー承認の 9 ステップ形式で必ず含める (2026-08-21 の目方精査でユーザーが書いた形式が原型)。S1 テストはそのシナリオの 各ステップを assert し、レビューは「各ステップが assert されているか」を突合する。
- S1 では lib/db の内部をモックしない。 差し替えてよいのは
@/lib/supabaseの client singleton(ローカル PostgREST + ロール別 JWT へ向ける)と、外部 API(Workers の OCR 等)の fetch 境界・Storage の upload だけ。supabase-js → PostgREST → 実 Postgres(shim + 全 migration) → 実 RLS/guard を通す。ハーネス自体の自己検査(越境 read が実際に拒否される)を持つ。 - seed-lite は
scenarioUsers.driverの工場とprofile_factories所属を用意する(工場スコープ検証のためにシナリオ側で所属を補完しない)。 seed-lite は再適用可(冪等)。 - S1 の PostgREST は max-rows=1000(本番相当)。
scripts/scenario/docker-compose.ymlのPGRST_DB_MAX_ROWSを維持し、大量明細の単発取得による欠落を実 DB シナリオで検出する。 - 歯: シナリオが守る修正を巻き戻すと赤になることをミューテーションで実証してから完了とする。
- S1 から Worker の実 handler を import するときの型境界: API の公開 Bindings 型は
KVNamespace等を@cloudflare/workers-typesから明示的に type import する。 API の tsconfig にだけある ambient 型へ依存させない(Web の build で TS2304 になる)。 - S1 の外部 HTTP 境界を差し替える仮想 origin は HTTPS にする。ローカル PostgREST の
接続先はハーネスの環境変数から転送先にだけ使い、仮想 API に HTTP URL を書かない
(semgrep の
react-insecure-requestで検出。テスト用の抑制は追加しない)。 - per-PR は S1 のみ(目標 5 分以内・rls-proofs の postgres 起動手順を共用)。ブラウザ E2E (S2 golden-path)はリリース PR + 夜間に限定し、per-PR に入れない(速度とフレーク対策)。
- S1 の起動条件: import / spawn する
apps/web外のファイルは.github/workflows/scenario-tests.ymlの push / pull_request の paths に含める。scripts/scenario-paths-ratchet.test.mjsが到達条件と両集合の一致を固定する。
テスト規約 + 共通ヘルパ(不変条件ラチェット・2026-07-27)¶
QA スイープのテストパターン監査で確定した規約。基盤は健全(colocated 100%・__tests__ 0 件・
snapshot 乱用 0・assertion ゼロのテスト 0)なので、弱点だけを規約化する。
- テストは日本語で書く(web 82% が既に日本語)。既存の英語タイトルを一括改名する価値はない。
scripts/**/*.test.mjsも実行器は Vitest(scripts/vitest.config.mjs)。node:testを混ぜると 単体のnode --testは緑でも verify の Vitest がNo test suite foundで落ちるため、vitestから test/expect を import する。 ファイル名の infix は.render(react 2 コピー制約下の末端 render)と.freshness(React Query invalidate 契約)と.scenario(S1・実 DB シナリオ)だけを分類として使う。 それ以外の造語 infix は新規で作らない。 - モックは「実 hook / 実 DB と同じ形」にする。引数を無視して固定値を返すモックは、
配線漏れや遷移バグを緑のまま通す。実例: 一覧の
totalが未取得ページで 0 に落ちる遷移を 再現していなかったため「2 ページ目へ進めない」バグが通過し、fetchAllPagesの.range()を 無視するモックは月次集計の未配線を検出できなかった(どちらも 2026-07-26 に修正)。 - db テストは書込先テーブルも assert する(
expect(mocks.from).toHaveBeenCalledWith('<table>'))。 列マッピングの assert だけではfrom('別表')への取り違えが緑で通る。 - 車両の表示識別子は
vehicle-labels.tsのvehicleIdentity/vehicleIdentityLabelに集約。plateNumber ?? chassisNumber ?? shortCodeをインラインで書かない(既定値が 4 通りに分裂し、 同じ「車番の無い車両」が画面ごとに違う文字で見えていた)。越境表示には使わない (chassis_numberは台帳機密。discharger 向けは mig166 の RPC が返すラベル)。 2 段版の文言は 2026-08-04 に車番未設定へ統一済み。 .dependency-cruiser-known-violations.jsonは空を保つ。抑制を残すと同じ辺の循環再導入を CI が黙認する(2026-07-27 に stale な 1 件を撤去して空にした)。新しい循環は抑制せず直す。- 未 import のコンポーネントは残さない。テストが緑でも production 経路に無いものは
偽のカバレッジになる(
SiteAggregationTableを削除済み)。
NaN / 非有限 numeric の全表担保(不変条件ラチェット・mig170)¶
金額・数量の numeric 列は NaN と ±Infinity を書けてはならない。Postgres は NaN を最大値として
扱い SUM も NaN になるため、精算・単価・数量が壊れる。PostgREST から 'NaN' の文字列で書ける。
- 権威は
scripts/proofs/mig170-nan-guard-sweep.sqlの N10(実カタログを走査し、金額・数量の numeric 列に NaN ガードが無いものが 0 件であることを検査する)。scripts/hardening-sweep-ratchet.mjsのENFORCED_FROM_MIGRATION=127は「127 より前は安全」を意味しない — mig004/053/094 の 7 列が 一度も掃かれず無ガードで残っていた(2026-07-28 に実カタログで発見)。テキストを見るラチェットは 速い事前検査で、下限を下げて代わりにはできない(歴史的 migration の旧 DDL を拾って壊れる)。 - 符号規約を勝手に厳しくしない。
external_partner_settlements.adjustment_amountは符号付き (mig094 §1-2)、net_amountは減算で負になりうる、weighings.total_amountは買取超過で負 (mig111)。これらは非負を課さず NaN / ±Infinity だけを拒否する。非負にすると正当な精算が 入らなくなる(proof N9 が固定)。 - 精度付き numeric は Infinity を型が弾く。
numeric(12,2)への'Infinity'は 22003 (numeric field overflow)で CHECK に到達しない。精度なしnumericの列(weighings.total_amount・weighing_items.*・item_unit_settings.kg_per_unit等 8 列)は型が弾かないので CHECK が必須。 - N10 は NaN の権威であり Infinity 全列の証明ではない。 QA-6 で見つかった
weighing_itemsのgross_kg/tare_kg/line_net_kg/kago_biki/dust_mizu_bikiは mig262 で Infinity 上限を是正済み。既存 Infinity は自動補正せず migration を停止する。精度なし numeric の全列上限はscripts/proofs/qa6-catalog-sweep.sqlの Q6-10(検証済み CHECK・例外0)で固定し、5列個別の constraint_name・旧 NaN/負値拒否・有限値 finalize はscripts/proofs/mig262-qa6-hardening.sqlが固定する。 - proof の deny 検査は制約名まで照合する。 不正な列値で別の CHECK が先に落ちると、
当てたい CHECK を検査せずに
[OK]を出す(pricing_rules.unitの許容値はper_kg/per_ton/per_m3/per_runで、'kg'を渡して偽陽性を出した実例がある)。 external_partner_settlementsは法定記録で物理削除できない(block_physical_delete_legal)。 proof の後片付けでdeleteすると ON_ERROR_STOP 下で psql が exit 3 になるが、[OK]文字列だけを 見る全流しでは見逃す。全流しは psql の exit code も判定する([[local-verify-order-gitleaks-and-proof-exitcode]])。
監査列 created_by のサーバー所有(不変条件ラチェット・mig171)¶
created_by を持つ表は client 供給値を無視してサーバーが決める。INSERT で上書きし、
UPDATE では OLD を保持する(created_at も同様)。実体は
supabase/migrations/00000000000171_audit_created_by_guard.sql・
scripts/proofs/mig171-audit-created-by.sql。
- 参照先が表ごとに違う。
vehicle_daily_logs/dispatch_ride_alongs/dispatch_stop_ride_alongs/vehicle_cost_entriesはprofiles(id)を参照するのでfill_audit_created_by()(auth.uid()→ profile を解決)を使う。jobs.created_byは FK が無くauth.users.idを入れる設計(apps/web/src/lib/db/jobs.tsに明記)なのでfill_audit_created_by_auth_uid()を使う。取り違えると列の意味が変わり既存データと不整合になる。 新しい表へ足すときは FK の実体を確認してからどちらを当てるか決める。 - 権威は proof の A6(カタログ全表検査)。守り方は「fill トリガが
created_byを代入」か 「INVOKER guard がcreated_byの変更を raise」のどちらでもよく、両方無い表を 0 件に保つ。 手作業のスイープでは 2 表しか見つからず、カタログ検査が追加で 2 表を出した(jobs/vehicle_cost_entries。他の列を守る guard を持つため手作業の除外条件をすり抜けていた)。 - トリガの発火順は名前順。
vehicle_daily_logsは既存 4 トリガ(vdl_*)があるのでvdl_z_で 最後に置き、tenant 導出・driver ガードの判断を上書きしない。 - proof で claims を使うときは
begin; ... rollback;で囲む。set_config('request.jwt.claims', ..., true)は transaction-local なので psql の autocommit では 次の文に残らずcurrent_setting(...)::jsonbが空文字で 22P02 になる(4 回はまった)。