コンテンツにスキップ

マスタ

本書は AGENTS.md の分冊(正典・AGENTS.md と同格)。優先順位は「実コード → AGENTS.md(本書を含む)→ DESIGN.md → PRODUCT.md」。 節は AGENTS.md から逐語で移設した(PR #nnn・2026-09-16)。対象パス: apps/web/src/lib/collection-site-name.ts` `apps/web/src/lib/partner-name.ts` `supabase/migrations/*collection_site*` `supabase/migrations/*partner*。この領域に触るワーカー / レビュアは本書を読む。

品目マスター(不変条件ラチェット・mig264)

  • 共通 + 自テナント: item_types.tenant_id IS NULL は共通の既定品目。SELECT は共通と自社に限定し、driver も読む。client の書込は自社の admin/normal のみ。UPDATE の USING は可視行を guard へ到達させ、共通行・driver・role 欠落を 42501 で拒否する。共通マスター全開放 SELECT へ戻さない。
  • 採番と保護列: item_types_fill_tenant は INVOKER・search_path=''・全列 BEFORE INSERT OR UPDATE。client INSERT は tenant を上書きし、code を t- + 8 hex で採番する。client UPDATE は共通行と tenant_id/code/created_at の変更を拒否する。共通行を作成できるのは既存の owner/DEFINER/service-role 内部経路だけで、新しい service-role 経路は作らない。
  • 名前とcodeの一意性: code は tenant(共通はゼロUUID)単位、有効な自社品目名は md5(collection_site_name_key(name)) の部分・式 unique。PostgREST upsert の onConflict に渡さない。画面は内部 code を編集・表示しない。無効化は参照を残す。
  • 参照先の境界: item_type_id の10参照表も共通または行のtenantに属する品目だけを許可する。9表の zz_item_type_tenant は既存fillの後に全列発火し、weighing_items は親weighing tenantを既存DEFINER guardで検証する。法定精算2表(external_partner_settlement_lines / buyback_settlement_lines)は owner/DEFINERにも参照検証を課す。親精算tenantとの照合を current_tenant_id へ置換しない。既存client直接書込禁止は維持する。
  • 取引先回収品目: client新規INSERTは item_type_id 必須、既存NULL行の他列UPDATEは可。選択時にIDと名称snapshotを同時に保存する。pci_partner_item_type_key の23505は制約名を照合して「同じ品目が既に登録されています」と表示する。backfillはNULL行の単一可視一致のみを紐付け、不一致・曖昧一致・既存linkとの衝突をNULLで残しNOTICEに列挙する。既存名称を上書きしない。
  • キャッシュと名称解決: useItemTypes(tenantId, activeOnly=true) はtenantキー必須、null時取得停止。有効候補と履歴(false)の両キーを保存後に無効化する。共通行と自社品目だけを読むRLSを維持し、委託先の名称解決は get_item_type_labels_for_external_partner()(所属検証・確定精算・生存link・id/nameのみ)経由。衛星への生master越境公開は禁止。
  • 既存業務の契約: collection_site_item_types 0行は全許可、単位換算と段別課金の式は維持。二次搬出の secondary_cargo_types と混同しない。検証は mig264 proof・S1 26-item-master / 15-external-partner-settlement・sentinel付きdev verifierで固定する。
  • リリースは夜間推奨: 旧Webが回収品目INSERTでNULLを送るためDB先行切替と非互換。100社相当シードのbackfill実測を同じPRに記載する。

本番データ作業の器(mig259)

  • seed_secondary_cargo_types / remove_partner_records は postgres 専用の INVOKER 関数。アプリの全ロールから EXECUTE を剥奪し、migration からだけ呼ぶ。
  • mig259 は定義だけで呼出し・データ変更を含めない。呼出しは本番 preflight 後の別 migration に tenant UUID、partner UUID + 期待名を固定して書く。mig260・261 は別用途で使用済みのため、呼出し用の番号は未マージ分を含む予約状況を確認して新規採番する。
  • 法定表は物理削除せず deleted_at で非表示化する。run の親 weighing は残し、対象取引先・回収先の明細だけを非表示化する。既存の物理削除禁止トリガを変更・無効化しない。
  • 請求書・買取精算がある取引先は削除済記録も含めて fail-closed(変更ゼロで例外)。partner_payment_profiles は不変。
  • 回収先と下流の運用行は物理削除するため、呼出しのリリースは夜間推奨、または当日 stop が無いことを確認する。契約と実証は docs/superpowers/specs/2026-09-07-prod-data-ops-design.md / scripts/proofs/mig259-prod-data-ops.sql
  • mig263 が preflight 済みの品目投入・6取引先撤去を1つの DO から呼ぶ。対象 collector 不在は seed skip、partner 不在は no-op とする。
  • 期待名不一致・税務記録あり・seed の例外は捕捉せず全呼出しを停止する。冪等再実行と環境耐性は scripts/proofs/mig263-prod-data-ops-calls.sql で検証する。

Driver Master(不変条件ラチェット・mig119)

ドライバーの免許・承認・事故歴を、既存 profiles の identity 権威を変えずに加法する。実体は supabase/migrations/00000000000119_driver_master.sqlnotes 列は PostgreSQL RLS では列マスクできないため mig119 に存在しない(将来の admin-only 別表は本スライス外)。

  • モデル / server-owned 列: driver_profiles.profile_idprofiles(id) への PK=FK であり、各 profile に高々 1 行の driver-profile だけを許す 1:1 拡張である。両表の tenant_idBEFORE INSERT OR UPDATE fill trigger が親から再導出する。driver_profilesstatus/申請・承認列/hired_on/license_front_pathlicense_back_path/timestamp/soft-delete 監査列は RPC 経由のみで書く。driver_profiles_guard_protected_colsSECURITY INVOKERBEFORE INSERT OR UPDATE guard で、authenticated/anon の直 INSERT/PATCH を遮断し、公式 RPC 内の transaction-local app.bypass_driver_profile_guard='on' だけを carve-out とする。driver_incidents も INVOKER guard で recorded_by/created_at/soft-delete 監査列を保護し、同一イベントで fill より先に動く aaa_ trigger が INSERT 偽造を遮断する。
  • 自己登録 / 再申請: upsert_my_driver_profileSECURITY INVOKERsearch_path=''auth.uid() の本人 profile だけを解決する。新規・pending は pending のまま、rejectedpending にのみ戻せ、RLS は USING status IN ('pending','rejected') / WITH CHECK status='pending' を維持する。guard bypass はこの RPC 内だけで local に設定し、免許画像 path は <tenant_id>/<profile_id>/<non-empty-file> の tenant/profile 二階層接頭辞を検証する。approved は本人経路で更新不可である。
  • admin lifecycle / 事故歴: approve_driver_profileSECURITY DEFINERsearch_path='' で、current_user_role() IS DISTINCT FROM 'admin' による fail-closed 判定、呼出元 tenant とロックした対象 tenant の明示比較を必須にする(pending と承認済み訂正の approved のみ承認可能)。reject/soft-delete も admin・明示 tenant 検証・guard bypass の RPC に限定する。record_driver_incidentSECURITY INVOKER の admin RLS/RPC であり、recorded_by は INSERT 時に fill trigger が auth.uid() の profile から必ず上書きする。事故歴 soft-delete は admin 検証付き DEFINER RPC に限定する。
  • 可視性 / Storage: 非削除 driver_profiles の SELECT は tenant admin 全件、または profiles.user_id = auth.uid() に一致する 本人の1行だけであり、本人 SELECT に role 条件は置かない。機微な非本人情報である非削除 driver_incidents の SELECT は tenant admin のみで、本人・normal・他 tenant へは開かない。private driver-files は tenant を第1 folder、所有者 profile を第2 folder として RLS で固定し、owner は driver、または normal/admin かつ can_drive=true の自身の second-folder、admin は自身 tenant のみを SELECT/INSERT できる。
  • mig93 非干渉 / 回帰: driver_profiles.status や承認結果は mig93 の weighings_stamp_external_partner の入力にしてはならない。scripts/proofs/mig119-driver-master.sql の DM30 が stamp 関数に driver_profiles 参照がないことを固定し、dev verifier は mig119_driver_master_applied() sentinel で未適用を soft-skip と漏洩 FAIL に分ける。

回収先名の一意性(不変条件ラチェット・mig164)

取引先内の回収先名の重複を DB で機械強制する(従来は app 層のガードだけで、読取→書込の間の競合=並行 INSERT / 並行有効化を止められなかった)。実体: supabase/migrations/00000000000164_collection_sites_name_unique.sqlscripts/proofs/mig164-collection-sites-name-unique.sql

  • スコープは where active: 出荷済み app の不変条件は「同一取引先で active な同名を 2 件持たない」で、作成・編集・有効化の 3 経路すべてが hasDuplicateCollectionSiteNameapps/web/src/lib/collection-site-name.tssite.active のみ対象)で判定する。索引もこれに一致させる。述語を外して全行対象へ広げてはならない — app より厳しくなり「無効な同名がある状態で新規作成/改名」という正当な既存フロー(無効化した回収先の名前は再利用できる)が生の 23505 で落ちる。CSV 取込(planCollectionSiteImport)が active/inactive 全件を索引化するのは「一致したら INSERT せず UPDATE する」照合ルーティングであって拒否条件ではないため矛盾しない。
  • 正規化は collection_site_name_key(text) に一元化: app の正規化は JS の s.trim().toLowerCase()String.prototype.trim は ASCII 空白だけでなく NBSP(U+00A0) / BOM(U+FEFF) / U+2000–U+200A / U+2028 / U+2029 / U+202F / U+205F / U+1680 / U+000B / U+000C / 全角スペース(U+3000) も落とす。btrim の除去文字集合をこの全集合に一致させないと 'ABC' + NBSP が PostgREST 直叩きで索引をすり抜け、実質同名の active 行を 2 件作れる。除去集合はこの IMMUTABLE 関数 1 箇所にだけ書き、索引・述語・重複整理が必ず同じ判定を使う。この関数の定義を変えたら索引を REINDEX するcreate or replace だけでは既存索引は再構築されない)。normalizeNameKey / normalizeCollectionSiteName を変えるときも同じ変更で追随させる。
  • 索引キーは md5 で固定長: name は長さ無制限の text で CSV 経路に上限が無い。素の式 B-tree だと約 2.7KB 超の名前が既存データにあるだけで CREATE UNIQUE INDEXindex row size ... exceeds btree maximum で失敗し prod の db push が止まる。md5 は IMMUTABLE で固定 32 byte なので索引タプル長が名前長に依存しない。衝突は「別名を同名と見なして 2 件目を拒否」= 過剰拒否側に倒れるだけで重複の見逃しにはならない
  • sha256 を採らなかった理由(当初の記述を訂正): 組込みの sha256() は bytea 引数で、text→bytea の convert_toSTABLE なので式索引に使えない(実測)。一方 pgcrypto の digest(text, 'sha256') は IMMUTABLE で式索引に使える(実測・pgcrypto は mig000 で導入済み)ので「sha256 は使えない」は誤りだった。それでも md5 を維持するのは、pgcrypto のインストール先スキーマが環境依存(Supabase は extensions、素の create extensionpublic)で式索引に安全な明示修飾ができず、索引を張り替える migration が本番の db push を落とすリスクの方が、md5 の理論的な意図的衝突(= 自テナントで特定の名前を登録できなくするだけ)より大きいため。将来 sha256 へ移す場合は digest の実スキーマを動的解決してから索引を張り替える。
  • 整理と索引作成を 1 つの DO ブロックに閉じ、先頭で EXCLUSIVE ロックを取る: UPDATE の RowExclusive は他セッションの INSERT を止めないため、別々の文にすると「整理完了 → 別セッションが重複を commit → CREATE UNIQUE INDEX が失敗」という窓ができる。PL/pgSQL は必ずトランザクション内で動くのでロックはブロック終端まで保持され、supabase db push(migration ごとに 1 トランザクション)でも psql の autocommit 実行(docker proof の全 migration 連結)でも両方を覆う。トップレベルの lock table は使わない(autocommit で LOCK TABLE can only be used in transaction blocks になる)。
  • 空名は索引対象外: 正規化後が空文字の name は部分索引の述語で除外する。app 層も空キーを照合対象から外しており、既存の空名行同士を後から衝突させない。
  • 42P10: これは「部分 + 式」一意索引なので PostgREST の .upsert(onConflict) の arbiter には推論できない([[run-weighing-upsert-42p10-partial-index]])。collection_sites への .upsert 直呼びを新設してはならない(現行は planCollectionSiteImport が create/update を振り分ける)。
  • 既存重複の扱い: 最古(created_at, id 昇順)を原本として残し、2 件目以降へ (重複N) を付けて改名する。対象は索引と同じ active 行だけ。改名は 1 件ずつ NOTICE に出し、デプロイログから利用者が正しい名前へ直せるようにする(本番マスタの表示名を書き換えるので黙って直さない)。重複が無ければ完全な no-op。
  • 23505 の人向け文言: 一意索引に当たり得る client 経路(CollectorSitesImportPageexecuteRow / CollectionSitesSection の作成・更新・有効化)は isUniqueViolation を「同じ名称の回収先が既にあります」へ写して生の Postgres 文字列を出さない。app ガードがあっても競合窓では 23505 が出るため必須。
  • 既知の境界: lower() は DB の collation 依存で JS の toLowerCase() と完全一致する保証はない(トルコ語 I 等)。Supabase の既定 collation では実害未確認。collation provider を変えた場合はこの索引を REINDEX する。一次ガードは app 層で、索引は競合窓を閉じる backstop である。
  • 恒久チェック: scripts/proofs/mig164-collection-sites-name-unique.sql(N1 sentinel / N2 active 同名拒否 / N3 JS trim 相当の全空白+大小差を 11 通り拒否 / N4 別取引先は許可 / N5 inactive 既存とは共存可 / N6 空名は共存 / N7 active 述語 + md5 キーの部分+式索引 / N8 有効化衝突を拒否 / N9 6.4KB の name を索引でき同名 2 件目を拒否 / N10 重複整理 DO ブロックの決定性・active 限定・索引再作成)を scripts/proofs/*.sql 全流しで [NG]=0 を確認する。N10 は migration ファイルを \ir で再適用して検証する(\i は CWD 相対で解決できない)。

取引先名の一意性(不変条件ラチェット・mig246)

同一テナント内の active な取引先名は重複させない。実体は supabase/migrations/00000000000246_partners_name_unique.sqlapps/web/src/lib/partner-name.tsscripts/proofs/mig246-partners-name-unique.sql

  • DB backstop: partners_tenant_name_key_unique(tenant_id, md5(collection_site_name_key(name))) の部分・式一意索引で、述語は deleted_at is null and active and collection_site_name_key(name) <> ''。inactive / soft-delete / 正規化後空の名前は共存を許可する。部分 + 式索引なので PostgREST .upsert(onConflict) の arbiter には使わない。
  • 正規化の権威: collection_site_name_key(text) の定義は mig164 のまま変更しない。改名整理・索引・proof は同じ関数を再利用し、関数定義を変える場合は別途 REINDEX を伴う migration にする。
  • 既存重複: migration は最古(created_at, id)を残し、active な重複を (重複N) へ 1 件ずつ改名して NOTICE を出す。法定マスタを黙って補正せず、inactive / soft-delete は改名しない。partners は物理削除せず soft-delete する。
  • app の一次ガード: CollectorPartnersPage の作成、CollectorPartnerDetailPage の編集・有効化は hasDuplicatePartnerName(active のみ・trim + lower・自分自身除外)を使い、競合窓の 23505 は「同じ名称の取引先が既にあります」へ写す。CSV 取込も同じ 23505 写像を使う。
  • CSV 復旧: 重複整理後の取引先名は buildPartnerCsv / buildCollectionSiteCsvparse*plan* の往復で ambiguity を作らない。apps/web/src/lib/csv/master-export.test.ts の mig246 ケースと proof N11 を維持する。
  • 恒久チェック: scripts/proofs/mig246-partners-name-unique.sql の N1–N11(同一 tenant 拒否 / JS trim 全 11 通り / 越境 tenant 許可 / inactive・soft-delete 共存 / 空名 / 索引定義 / 有効化 / 長名 / NOTICE 付き決定的改名 / CSV 親照合)を scripts/proofs/*.sql 全流しで [NG]=0、各 psql exit 0 で確認する。N10 は migration ファイルを \ir で再適用して検証する。

権限緩和(不変条件ラチェット・mig137/138)

権限監査で確定した過剰制約の是正。実体: supabase/migrations/00000000000137_normal_tenant_read.sql00000000000138_profiles_self_update.sqlscripts/proofs/mig137-normal-tenant-read.sqlscripts/proofs/mig138-profiles-self-edit.sql

  • fleet/cost の SELECT(mig137): factories / vehicles / vehicle_daily_logs / vehicle_cost_entriesnormal=同一テナント全読み・driver=factory-scope。admin は従来どおり tenant 全読み、全経路で tenant_id=current_tenant_id()(cost は deleted_at is null)を維持する。normal を再び factory-scoped に戻さず、driver の factory scope を normal 全読みのために緩めない。
  • profiles self-UPDATE(mig138): profiles は本人の display_name を self-UPDATE 可とするが、role / tenant_id / user_id / can_driveprofiles_guard_protected_cols(SECURITY INVOKER・authenticated/anon 発火)の IS DISTINCT FROM guard で client 変更を遮断する。表示コピーの実権限は JWT app_metadata、特権列の更新は service_role/team API のみ。admin の表示名更新と service_role carve-out を維持する。
  • item_unit_settings の層一致(mig131 / web): item_unit_settings は UI/RLS とも staff(normal 含む)とする。/collector/item-unit-settings を admin 限定へ戻さず、COLLECTOR_STAFF_ROLES と mig131 の staff policy を一致させる(/collector/drivers の admin 限定は維持)。
  • 回帰: mig137/138 proof を含む scripts/proofs/*.sql 全流しで [NG]=0 を確認し、既存の mig133 cost factory-scope proof は normal tenant 全読みの期待へ更新する。vehicle_cost_line_itemscurrent_user_can_access_vehicle() 継承など本スライス対象外の helper scope は変更しない。

取引先 soft-delete の依存確認(不変条件ラチェット・mig191)

partners は法定記録のため物理削除せず、既存の soft_delete_legal_record を使う。partners 分岐は admin 限定・対象行 FOR UPDATE・直前の依存再確認を行い、依存があれば 23514 で恒久拒否する。 依存件数は DEFINER の get_partner_delete_dependencies が同一 tenant の active な子行をまとめて確認する。

マスタ統一ユニークコード(不変条件ラチェット・mig232 / mig235)

回収先 S- / 車両 V- / 委託先 E- / 工場 F- に 6 桁の一意コードを持たせる。手動採番も自動採番も可。 取引先は mig117 で対応済みで触らない(写経元)。メンバーは対象外(login_id と別物になり混乱するため)。

  • vehicles.short_code を一意コードとして扱ってはならない。 本番では種別のラベルとして使われており (同一テナントで リフト が 19 件・住友三井リース が 6 件)、一意ではない。mig232 は当初これを昇格させて 本番の db push を停止させた。車両のコードは mig235 が新設した vehicles.code が唯一の器で、 short_code には索引も CHECK も backfill も加えない。
  • vehicleIdentity / vehicleIdentityLabelplateNumber ?? chassisNumber ?? shortCode)と 越境ラベル discover_collector_vehiclescoalesce(plate_number, short_code, vehicle_name))を 変えてはならない。 新しい code を越境へ出さない。
  • コード列は業務列で、既存 staff の INSERT/UPDATE RLS 経路で書く。DEFINER に閉じるのは (tenant, prefix) カウンタの increment だけ(mig117 の作法)。next_*_code_seqsecurity definer / search_path='' / staff 限定 / collector 限定 / p_tenant の NULL 拒否 / 呼出元テナントとの一致検証 / revoke all from public + grant execute to authenticated
  • 部分 unique の述語は active(+ その表が持つなら deleted_at is null)に限る。 述語を外して全行対象へ広げてはならない(app より厳しくなり、無効化した同名の再利用という 正当な運用が生の 23505 で落ちる。mig164 と同じ判断)。
  • 既存重複を破壊的に補正しない。 索引を張る前に重複を検出し、対象を列挙して migration を停止する (mig117 の作法)。top-level LOCK TABLE を使わない(docker proof は autocommit 適用のため LOCK TABLE can only be used in transaction blocks になる)。
  • backfill は code is null の行にだけ採番し、手動で入っている値は温存する。 再実行しても非破壊。
  • 回帰は scripts/proofs/mig232-master-unique-codes.sqlmig235-vehicle-code.sql。後者は short_code に一意索引が存在しないことをカタログで固定する(本番を止めた原因の再導入を検出する歯)。