diff --git a/issues/PLAN64_secret-label-group-design.md b/issues/PLAN64_secret-label-group-design.md
new file mode 100644
index 00000000..6e17ec49
--- /dev/null
+++ b/issues/PLAN64_secret-label-group-design.md
@@ -0,0 +1,323 @@
+# PLAN64: 機密の参照の見出しにグループの読み替えを出す設計
+
+要求と受け入れ条件は [PLAN64_secret-label-group.md](PLAN64_secret-label-group.md) にある。この文書は「どう作るか」だけを扱う。
+
+## 機能一覧
+
+| # | 機能 | 誰が使うか |
+| --- | --- | --- |
+| F1 | 参照の見出しのグループ名が、読み替えの前と後(`default → nyle`)を出す | `env list` / `env backend test` / `env backend migrate` を打つ利用者 |
+| F2 | **引数なしの `label()` で参照を表示する**エラー文言・警告・ログは、読み替える前の名前のままにする(`display_group` を直接呼ぶ文言は今も前後を出し、変えない) | 失敗の原因を読む利用者と、ログを読む開発者 |
+| F3 | 参照の表示の文言を組み立てる場所を 1 つに保つ | devbase の開発者(新しい見出しを足す人) |
+| F4 | 確定仕様が、どの文言がどちらの形になるかを重ならない形で述べる | 確定仕様の読み手 |
+
+## 構成要素
+
+### コード
+
+| 要素 | 変更 | 責務 |
+| --- | --- | --- |
+| `SecretRef.label()`(`lib/devbase/env/secret_store.py`) | 変える | 参照の表示の文言を組み立てる唯一の場所。キーワード引数 `group_display` を足し、グループの括弧の中に入れる名前だけを外から受ける。省いたときは今までどおり `self.group`(読み替える前の名前)を入れる |
+| `SecretStore.display_label(ref)`(同) | 足す | 見出し用の表示を作る唯一の口。`storage_group(ref.group)` で読み替えの要否を判定し、要るときだけ `OpenBaoSettings.display_group` の結果を `label(group_display=...)` へ渡す |
+| `SecretStore.storage_group`(同) | 変えない | 「グループの表示に読み替えを出すか」の判定に使う。`backend` が `openbao` でない・設定が無い・`layout: flat`・グループが `None` のいずれでも `None` を返す |
+| `OpenBaoSettings.display_group`(`lib/devbase/env/backend_config.py`) | 変えない | 読み替えの前と後の文字列(`default → nyle`)を作る |
+| `cmd_env_list` の共通の節(`lib/devbase/commands/env.py`) | 変える | `env_file.ref.label()` を `store.display_label(env_file.ref)` にする |
+| `_group_suffix`(同) | 変える | 引数に `store` を取り、`display_label` の差分を取る。文言は写さない |
+| `cmd_env_backend_test`(`lib/devbase/commands/env_backend.py`) | 変える | 参照ごとの行の `ref.label()` を `store.display_label(ref)` にする |
+| `cmd_env_backend_migrate` の完了表示(同) | 変える | 「サーバ上の機密はそのまま残っています」の一覧の `unit.server_ref.label()` を `display_label` にする |
+| `_MigrationPlan._heading`(同) | 変える | 移行の計画の一覧の `unit.server_ref.label()` を `self.server_store.display_label(...)` にする |
+
+変えないものは次の 6 つである。
+
+| 変えないもの | 理由 |
+| --- | --- |
+| `SecretRef` のフィールドと等価性 | 参照の等価性とキャッシュの鍵に入っている |
+| `OpenBaoSettings.path_of` / `display_path` | 置き場のパスは今も読み替え後の名前を出している |
+| `env backend status` の表示 | すでに `display_group` を使っている |
+| `bundle.py` / `io_import.py` の「別の置き場のプロジェクト」の列挙 | 同上 |
+| `display_label` を通らない 47 か所の `label()` の呼び出し | 決定 2・決定 5 |
+| 一覧の桁幅(`:<24` / `:<28` / `:<40`) | 決定 7 |
+
+### 呼び出しの関係
+
+図は `label()` を呼ぶ関係だけを描く。設定の読み込みと backend の選択は省く。
+
+```mermaid
+graph TD
+ subgraph 見出しを出す側
+ L[cmd_env_list]
+ GS[_group_suffix]
+ BT[cmd_env_backend_test]
+ BM[cmd_env_backend_migrate
の完了表示]
+ MH[_MigrationPlan._heading]
+ end
+ subgraph 表示の口
+ DL[SecretStore.display_label]
+ SG[SecretStore.storage_group]
+ DG[OpenBaoSettings.display_group]
+ end
+ subgraph 文言
+ LB["SecretRef.label(group_display=…)"]
+ end
+ subgraph 誤りを伝える側
+ E[エラー文言・警告・ログ・巻き戻しの説明
43 か所]
+ end
+ L --> DL
+ GS --> DL
+ BT --> DL
+ BM --> DL
+ MH --> DL
+ DL --> SG
+ DL --> DG
+ DL --> LB
+ E --> LB
+```
+
+`E` は `label()` を引数なしで呼ぶ。`DL` を経由しないことが F2 の実現方式である。
+
+### 対象の数え方
+
+`lib/` の `.label()` の出現は 52 件 / 10 ファイル(2026-09-22 に `grep -rn "\.label()" lib/` で実測。issue 本文と一致)。
+このうち 1 件(`lib/devbase/commands/env.py` の `_group_suffix` の docstring)は文字列の言及で、実行される呼び出しではない。
+
+| 区分 | 件数 | 扱い |
+| --- | ---: | --- |
+| 見出し(読み替えのあるグループを持ちうる) | 5 | `display_label` へ寄せる |
+| 見出し(参照が常にグループを持たない。`env encrypt` / `env decrypt` / `env rekey`) | 3 | 変えない |
+| エラー文言・警告・ログ・巻き戻しの説明・使われていない委譲 | 43 | 変えない |
+| docstring の言及 | 1 | 変えない |
+| 合計 | 52 | |
+
+「変えない」は 47 件(3 + 43 + 1)で、内訳は上の図の `E`(43 件)、グループを持たない一覧(3 件)、docstring の言及(1 件)である。
+
+## 配置
+
+### システムの文脈
+
+この変更が触る外部は無い。`devbase` はホストで動き、置き場のパス・サーバへの要求・キャッシュのファイルはいずれも変わらない。変わるのは端末の画面に出る文字列だけである。
+
+```mermaid
+graph LR
+ U[利用者の端末] --> D[devbase CLI]
+ D -->|変わらない| B[OpenBao サーバ]
+ D -->|変わらない| F[DEVBASE_ROOT のファイル]
+ D -->|変わる: 見出しの文字列| T[端末の画面]
+```
+
+### モジュールの置き場所
+
+```text
+lib/devbase/
+├── env/
+│ ├── secret_store.py # SecretRef.label に group_display / SecretStore.display_label を足す
+│ └── backend_config.py # 変えない(display_group / storage_group をそのまま使う)
+└── commands/
+ ├── env.py # cmd_env_list の共通の節 / _group_suffix
+ └── env_backend.py # cmd_env_backend_test / cmd_env_backend_migrate の完了表示 / _MigrationPlan._heading
+tests/
+├── env/test_secret_store_label.py # 新設。label の契約と display_label の分岐
+└── commands/test_env_group_label.py # 新設。読み替えのあるグループでのコマンドの出力
+docs/specifications/secret-backend.md # 2 か所の書き分けと list の見出しの例
+docs/user/env-backend.md # 見出しの例に読み替えのある場合を足す
+docs/user/cli-reference/03-env.md # 同上
+CHANGELOG.md # Unreleased の Fixed
+```
+
+## 構造
+
+```mermaid
+classDiagram
+ class SecretRef {
+ +str kind
+ +Optional~str~ name
+ +str owner
+ +Optional~str~ group
+ +label(group_display) str
+ }
+ class SecretStore {
+ +config
+ +storage_group(group) Optional~str~
+ +display_label(ref) str
+ }
+ class OpenBaoSettings {
+ +str layout
+ +Dict~str,str~ group_aliases
+ +storage_group(group) str
+ +display_group(group) str
+ }
+ class BackendConfig {
+ +str backend
+ +int version
+ }
+ SecretStore --> BackendConfig : config
+ BackendConfig --> OpenBaoSettings : openbao (Optional)
+ SecretStore ..> SecretRef : display_label(ref)
+ SecretRef <.. OpenBaoSettings : (依存しない)
+```
+
+`SecretRef` は `frozen=True` のままで、フィールドを足さない。`group` は等価性とキャッシュの鍵に入っているため、表示のための値をフィールドとして持たせない。
+
+## 入出力の契約
+
+### `SecretRef.label(*, group_display=None) -> str`
+
+| 引数 | 型 | 既定 | 意味 |
+| --- | --- | --- | --- |
+| `group_display` | `Optional[str]` | `None` | グループの括弧の中に入れる名前。`None` なら `self.group`(読み替える前の名前) |
+
+| 状況 | 返り値 |
+| --- | --- |
+| `self.group` が `None` | `グローバル` / `プロジェクト 'web'` / `個人のグローバル` / `個人のプロジェクト 'web'`(`group_display` を渡しても無視する) |
+| `self.group` があり `group_display` が `None` | `グローバル(グループ default)` |
+| `self.group` があり `group_display` が `default → nyle` | `グローバル(グループ default → nyle)` |
+
+**`group_display` は文字列で受け、`OpenBaoSettings` を受けない。** 参照が設定の型を知らない状態を保つためである。
+
+### `SecretStore.display_label(ref) -> str`
+
+| 状況 | 返り値 |
+| --- | --- |
+| `storage_group(ref.group)` が `None`(`ref.group` が `None`、backend が `openbao` でない、設定が無い、`layout: flat`) | `ref.label()` と同じ文字列 |
+| 読み替えが無い(`storage_group(ref.group) == ref.group`) | `ref.label()` と同じ文字列。`display_group` が `→` を付けないため |
+| 読み替えがある | `ref.label(group_display='default → nyle')` |
+
+**判定を `storage_group` に任せるのは、`config.openbao` が `None` かどうかが backend の種類だけでは決まらないためである。** `backend: age` の設定に `openbao:` 節が残っていると、`config.openbao` は `None` にならない。判定しているのは `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)である(2026-09-22 に確認)。`storage_group` は backend の種類・設定の有無・`layout` の 3 つをまとめて見る唯一の判定である。`ref_group` も同じものを使っている。
+
+### 見出しの形
+
+| コマンド | 行 | 読み替えが無いとき | 読み替えがあるとき |
+| --- | --- | --- | --- |
+| `env list` | 節の見出し | `=== グローバル(グループ kkg) (…) ===` | `=== グローバル(グループ default → nyle) (…) ===` |
+| `env list` | 件数の行 | `グローバル(グループ kkg): 22変数` | `グローバル(グループ default → nyle): 22変数` |
+| `env list` | プロジェクトの節 | `=== プロジェクト: web(グループ kkg) (…) ===` | `=== プロジェクト: web(グループ default → nyle) (…) ===` |
+| `env backend test` | 参照ごとの行 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 変数` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 変数` |
+| `env backend migrate` | 計画の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 件: …` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 件: …` |
+| `env backend migrate --to age` | 完了後の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global` | ` グローバル(グループ default → nyle) devbase/team/nyle/global` |
+
+`version: 1` とファイル backend では、いずれの行にもグループが付かない(`ref.group` が `None`)。
+
+### 失敗の形
+
+`display_label` は例外を送出しない経路だけを通る。`storage_group` が `None` を返せば `label()` をそのまま返し、`None` 以外を返した時点でグループ名の検証は成功している。`display_group` はその直後に同じ `storage_group` を呼ぶため、新たに `BackendConfigError` になる場面が無い。終了コードを持たず、標準出力にも標準エラーにも自分では書かない。
+
+## 処理の流れ
+
+```mermaid
+sequenceDiagram
+ participant U as 利用者
+ participant C as cmd_env_backend_test
+ participant S as SecretStore
+ participant O as OpenBaoSettings
+ participant R as SecretRef
+
+ U->>C: devbase env backend test
+ C->>S: display_label(ref)
+ S->>O: storage_group(ref.group)
+ O-->>S: 'nyle'(読み替え後)
+ alt 読み替え後 ≠ 読み替え前
+ S->>O: display_group(ref.group)
+ O-->>S: 'default → nyle'
+ S->>R: label(group_display='default → nyle')
+ else 同じ、または None
+ S->>R: label()
+ end
+ R-->>S: 'グローバル(グループ default → nyle)'
+ S-->>C: 同じ文字列
+ C->>U: 見出しの行(隣に display_path)
+```
+
+エラー文言の経路はこの図に現れない。`label()` を引数なしで直接呼ぶためである。
+
+## 非機能の実現方式
+
+| 項目 | 条件 | 実現方式 |
+| --- | --- | --- |
+| 堅牢性 | 誤りを伝える文言の組み立てが、新たな例外を起こさない | `label()` を通して参照を出すエラー文言は、引数なしで呼ぶ。読み替えの解決(`storage_group` / `display_group`。名前の検証と予約語の検査で `BackendConfigError` を送出しうる)を新しく背負う文言を増やさない |
+| セキュリティ | 見出しに機密の値が出ない | 変えるのはグループ名だけで、キーも値も触らない。グループ名は `backend.yml` の設定で、機密ではない |
+| 互換性 | `version: 1` とファイル backend の出力が 1 文字も変わらない | `display_label` が `storage_group(ref.group) is None` で `ref.label()` を返す。`ref.group` はこれらの設定では常に `None`(`SecretStore.ref_group` の契約) |
+| 可読性 | 一覧の桁が崩れない | 桁幅を変えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で `:<28` を超えており(2026-09-22 に実測)、桁あふれはこの変更で始まるものではない |
+| 性能 | 1 回の表示で増える処理 | `storage_group` は辞書の参照と正規表現の検証だけで、サーバへの要求もファイルの読み込みも増やさない。参照 1 件あたり最大 2 回呼ぶ |
+
+## 決定の記録
+
+### 決定 1: グループの表示の責務を `label()` の契約へ寄せる(issue の `move_responsibility` を採る)
+
+`label()` の引数に `group_display` を足し、見出しの側は「括弧の中に入れる名前」だけを渡す。文言の組み立て(`(グループ …)` の括弧、`個人の` の接頭、チーム単位の文字列)は `label()` の中から出ない。`_group_suffix` が明記している従属関係(「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」)はそのまま保たれる。
+
+見出しの側で `f'(グループ {display})'` を組み立てる案は採らない。同じ文言が 5 か所へ複製され、`label()` を直したときに見出しが追随しない。
+
+### 決定 2: `label()` の既定の返り値は変えない
+
+読み替えの解決は `OpenBaoSettings.storage_group` を通り、グループ名の検証と予約語の検査で `BackendConfigError` を送出しうる。既定を読み替え後にすると、43 か所のエラー文言・警告・ログがこの解決に依存する。**誤りを伝える文言を組み立てる途中で新しい例外が起きる形**になり、元の失敗が利用者へ届かなくなる。この危険は仮想のものではない。`BackendConfigError` そのものの文言(`lib/devbase/env/backend_config.py` の `_group_of`)が `label()` を使っている。
+
+`display_group` を直接呼んでいる文言(`lib/devbase/commands/env.py` の `_project_group_mismatch` など)は、この決定の対象ではない。設定が読める場所で組み立てており、今も読み替えの前後を出している。
+
+既定を読み替え後にする案(52 か所すべてに及ぶ案)は採らない。上の理由に加えて、`SecretRef` が `frozen=True` の値であり、設定を持たないまま読み替えを解決する手段が無いためである。持たせるには全フィールドに設定への参照が要り、参照の等価性とキャッシュの鍵に影響する。
+
+### 決定 3: 見出し用の表示を作る口を `SecretStore.display_label` の 1 つに置く
+
+見出しの側が `storage_group` と `display_group` と `label` を毎回組み合わせると、組み合わせ方の誤りが 5 か所で起こりうる。`config.openbao` が `None` の場合の見落としがこれにあたる。判定を 1 か所に閉じ、見出しの側は `store.display_label(ref)` だけを呼ぶ。
+
+**`config.openbao` が `None` かどうかで backend の種類を判定しない。** `backend: age` に `openbao:` 節が残っていれば `None` にならないため、その判定は `backend: age` の端末をグループ別の置き場として扱いうる。受け入れ条件 6 のテストがこの取り違えをそのまま検査する。
+
+`OpenBaoSettings` に口を置く案は採らない。`config.openbao` が `None` の場合と backend が `openbao` でない場合を、呼ぶ側が毎回確かめることになる。`SecretStore` は `storage_group` / `ref_group` / `same_storage_group` で既に同じ判定を引き受けている。
+
+### 決定 4: `env backend migrate` の 2 つの一覧も直す
+
+`lib/devbase/commands/env_backend.py` には、参照の表示とサーバのパスを隣に並べる一覧がもう 2 つある。移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧である。直さなければ、同じ食い違い(`default` の隣に `nyle` のパス)がそこに残る。issue 本文はこの 2 か所を挙げていないため、受け入れ条件 3 を設計の時点で足した。
+
+### 決定 5: `env encrypt` / `env decrypt` / `env rekey` の一覧は直さない
+
+これらが組む参照は `SecretRef.for_global()` / `SecretRef.for_project(name)` で、`group=` を渡さない。`ref.group` が常に `None` になる。参照を組んでいるのは `_select_refs`(`lib/devbase/commands/env_migrate.py`)と `_encrypted_refs`(`lib/devbase/commands/env_ops.py`)である(2026-09-22 に確認)。見出しにグループが出ないため、直しても出力が変わらない。
+
+### 決定 6: 確定仕様は、どちらか一方を消さずに「文言の種類で分ける」形で解く
+
+`docs/specifications/secret-backend.md` の 2 つの記述は、どちらも正しい振る舞いを述べている。片方を消すと、残った側が対象外の文言まで巻き込む。
+
+| 今の記述 | どう直すか |
+| --- | --- |
+| 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 既定が読み替える前の名前であることを書く。`group_display` を渡すと前後になることを足す。エラー文言とログが既定を使う理由(読み替えの解決が失敗しうる)を添える |
+| 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | この規則を保ったまま、除くものを**引数なしの `SecretRef.label()` で参照を表示するエラー文言・ログ**に限ると書く。`display_group` を直接呼ぶ文言(`_project_group_mismatch` が組む `-p` の拒否の文言など)はエラーであっても前と後を出す |
+| `list` の見出しの例(`with` の 1 つだけ) | 読み替えのある場合(`default → nyle`)を足す。`with` だけでは前後が一致して矛盾が表に出ない |
+
+### 決定 7: 一覧の桁幅を変えない
+
+`env backend test` の `:<28` は文字数で数え、全角文字の表示幅を数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で既に超えている。読み替えの表示で 7 文字増えるが、桁あふれの性質は変わらない。
+
+幅を広げる案と、実際の最大長から幅を計算する案は採らない。どちらも読み替えの無いグループの出力を変え、受け入れ条件 5・6(変わらないこと)を崩す。桁揃えそのものの見直しは、この変更とは別の課題である。
+
+## テスト設計
+
+`DEVBASE_ROOT` は各テストが自分で差し替える既存の流儀に合わせる。使う道具と手本は次のとおりである。
+
+| 何を | どこから |
+| --- | --- |
+| 設定の書き込み | `tests/conftest.py` の `configure_openbao`(`layout='group'`、`group_aliases={'default': 'nyle'}` を渡す) |
+| `DEVBASE_ROOT` と作業ディレクトリ | `tests/conftest.py` の `openbao_root` fixture |
+| 手本にする fixture | `tests/commands/test_env_user_axis.py` の `grouped`(すでに同じ読み替えを設定している) |
+
+**「変わらないこと」(受け入れ条件 5・6)は、既存テストだけでは確かめられない。** `env list` の見出しを固定している既存の assert は `'=== グローバル'` の前方一致で、見出しに `(グループ default → nyle)` が付いても通る(`tests/commands/test_env_user_axis.py:229-253`。2026-09-22 に確認)。読み替えの無い設定で`(グループ` が 1 つも出ないことを見る出力テストを新しく置く。
+
+| 受け入れ条件 | 何で確かめるか |
+| --- | --- |
+| 1(`env backend test` の見出し) | `tests/commands/test_env_group_label.py` を新設。`layout='group'`・`group_aliases={'default': 'nyle'}` で `cmd_env_backend_test` を呼び、`capsys` の行に `グローバル(グループ default → nyle)` と `個人のグローバル(グループ default → nyle)` が出ることと、同じ行のパスが `…/team/nyle/global` であることを見る |
+| 2(`env list` の見出し) | 同ファイル。`cmd_env_list` を `projects/web`(グループの宣言なし → `default`)で呼び、`=== グローバル(グループ default → nyle) (` と `=== プロジェクト: web(グループ default → nyle) (`、および件数の行 2 つを見る |
+| 3(`env backend migrate` の 2 つの一覧) | 同ファイル。`cmd_env_backend_migrate(to='age', dry_run=True)` で**計画の一覧**の行に `グローバル(グループ default → nyle)` が出ることを見る。あわせて `dry_run=False`(偽サーバ)で `--to age` を通し、完了後の「サーバ上の機密はそのまま残っています」の一覧の行にも同じ見出しが出ることを見る。この 2 つは別の関数(`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、片方だけでは決定 4 を確かめられない |
+| 4(読み替えの無いグループ) | 同ファイル。`projects/web` の `env` に `DEVBASE_ACCOUNT_GROUP=kkg` を書き、見出しが `(グループ kkg)` のままで `→` を含まないことを見る |
+| 5(`version: 1`) | `tests/commands/test_env_group_label.py` に新規で 1 件。`configure_openbao(layout='flat')`(`version: 1`)で `cmd_env_list` と `cmd_env_backend_test` を呼び、**出力全体に `(グループ` が 1 つも出ない**ことを見る。既存の `tests/env/test_runtime.py`・`tests/env/test_groups.py` も変更なしで通す |
+| 6(ファイル backend) | 同ファイルに新規で 1 件。`backend: age` に `openbao:` 節を残した設定で `cmd_env_list` を呼び、出力全体に `(グループ` が 1 つも出ないことを見る。既存の `tests/commands/test_env_user_axis.py` も変更なしで通す |
+| 7(エラー文言) | `tests/env/test_secret_store_label.py` を新設。読み替えのあるグループの参照で `label()` を引数なしに呼ぶと `(グループ default)` になること、`label(group_display='default → nyle')` で前後が出ること、`group` が `None` の参照では `group_display` を渡しても無視されることを見る。あわせて、要求文書の受け入れ条件 7 が挙げる 2 つの文言に `→` が出ないことを見る。`layout: flat` でグループ付きの参照を拒む例外(`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である |
+| 8(`env backend status`) | 既存の `tests/commands/test_env_backend.py` を変更なしで通す |
+| 9・10・11(文書) | 実装 Pull Request のレビューで読んで確かめる(自動の検査を置かない) |
+| 12(退行) | `uv run pytest tests/ -q` の結果を実装 Pull Request の本文へ載せる |
+
+`display_label` の分岐(`storage_group` が `None` / 読み替えなし / 読み替えあり)は、受け入れ条件 1・4・5・6 のテストが 3 つとも通る。分岐だけの単体テストは `tests/env/test_secret_store_label.py` に 1 件置く。
+
+## 未確認のまま残ること
+
+| 項目 | 内容 |
+| --- | --- |
+| 実機での見え方 | この端末の機密の置き場は本番の系のため、確認は読み取り(`env backend test` / `env list --keys-only` / `env backend status`)に限る。`env backend migrate` の一覧は `--dry-run` でも移行元の読み取りを伴うため、実機では確かめずテストだけで見る |
+| CI | `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない(`.github/workflows/ci.yml` の対象が `main` だけ、#216)。手元の `uv run pytest tests/ -q` が唯一の証跡になる |
+| `tests/conftest.py` の `DEVBASE_ROOT` の隔離 | #217(`release/v3.7.0` 未マージ)が入ると、新しいテストの `DEVBASE_ROOT` の扱いを揃え直す余地がある。この設計では既存の流儀のままにする |
+| 桁揃えそのもの | 全角文字の表示幅を数えない桁揃えは、読み替えの有無に関わらず長い参照で崩れる。この変更では扱わない |
diff --git a/issues/PLAN64_secret-label-group.md b/issues/PLAN64_secret-label-group.md
new file mode 100644
index 00000000..11bf7e71
--- /dev/null
+++ b/issues/PLAN64_secret-label-group.md
@@ -0,0 +1,236 @@
+# PLAN64: 機密の参照の見出しに、グループの読み替えの前後を出す
+
+対象 issue: devbasex/devbase#188
+
+- ワークフローモード: `standard`
+ - 根拠: 利用者が読む公開の出力の振る舞いを変える(`env backend test` と `env list` の見出し)。
+ あわせて確定仕様 `docs/specifications/secret-backend.md` の自己矛盾を解く
+- 参照: release Pull Request devbasex/devbase#212(`release/v3.7.0`)
+
+## 目的
+
+`group_aliases` のある端末で、機密の参照の見出しに出るグループ名と、その隣に並ぶ置き場のパスが
+同じグループを指していると読めるようにする。あわせて、確定仕様の「参照の表示」と「文言の表示」の
+2 つの記述が食い違ったままにしない。
+
+## 曖昧語の具体化
+
+| 依頼文の語 | 具体化 |
+| --- | --- |
+| 「見出し」 | 利用者が正常系の一覧として読む行に限る。`env backend test` の参照ごとの行(`lib/devbase/commands/env_backend.py`)と、`env list` のチーム共通・個人共通・プロジェクトの節の `=== ... ===` と末尾の件数の行(`lib/devbase/commands/env.py`)。エラー文言・警告・ログは含まない |
+| 「読み替えの前後を出す」 | `OpenBaoSettings.display_group(group)` の返り値をそのまま使う。読み替えがあれば `default → nyle`、無ければ `default`(`→` を付けない) |
+| 「矛盾が解ける」 | 仕様書の 2 つの記述が、どの文言がどちらの形になるかを重ならない形で述べ、どちらを読んでも同じ結論になる。分かれ目は「引数なしの `SecretRef.label()` で参照を表示するか」で、`display_group` を直接呼ぶ文言はエラーであっても読み替えの前後を出す |
+| 「変わらない」 | 出力の文字列がバイト単位で今と同じ |
+
+## 前提
+
+- 前提 1: `version: 1` とファイル backend(`plaintext` / `age`)では `SecretRef.group` が常に `None` に
+ なる(`SecretStore.ref_group` の契約、確定仕様「参照のグループ」)。見出しにグループが付かない。
+ したがって「見出しが変わらない」ことは、グループの有無で分岐する形にすれば構造として保てる
+- 前提 2: **`config.openbao` が `None` かどうかで backend の種類を判定してはならない。**
+ `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)は `backend: age` でも `openbao:` 節が
+ 残っていれば設定を返す。ファイル backend で `None` になるのは、節が無く `version: 1` のときだけである
+ (2026-09-22 に確認)。表示の分岐は `SecretStore.storage_group(ref.group)` に任せる。この 1 つが
+ backend の種類・設定の有無・`layout`・グループの有無をまとめて見て、当たらなければ `None` を返す
+- 前提 3: `OpenBaoSettings.display_group` は `storage_group` を経由する。名前の検証と予約語の検査で
+ `BackendConfigError` を送出しうる。**誤りを伝える文言の組み立ての中では呼ばない。** 見出しは
+ 対応するパスの解決が成功した後に出る(`path_of` も `storage_group` を通る)。そのため
+ 見出しの経路で新たに失敗する場面は生じない
+- 前提 4: 一覧の桁揃え(`env backend test` の `{...:<28}`)は文字数で数えており、全角文字の表示幅を
+ 数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で 28 を
+ 超えている(2026-09-22 に実測)。桁あふれは本 issue の変更で始まるものではない
+- 前提 5: この端末の機密の置き場は本番の系である。実機での確認は読み取りに限る
+ (`env backend status` / `env backend test` / `env list --keys-only`)。`env set` / `migrate` /
+ backend の切り替えは行わない
+
+## 対象範囲
+
+含む:
+
+- `SecretRef.label()` の契約(グループの表示をどう決めるか)
+- `env backend test` の参照ごとの行の見出し
+- `env backend migrate` の移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧
+- `env list` のチーム共通・個人共通・プロジェクトの節の見出しと末尾の件数の行
+- `docs/specifications/secret-backend.md` の 2 つの記述の書き分けと、`list` の見出しの例
+- 読み替えのあるグループを使う新しいテスト
+- 利用者向け文書の見出しの例(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)と CHANGELOG
+
+含まない:
+
+- エラー文言・警告・ログに出る `label()` の表示(前提 3 の理由で読み替え前の名前のまま)
+- `env backend status` の表示(すでに `display_group` を使っており、変えない)
+- `env encrypt` / `env decrypt` の一覧(`lib/devbase/commands/env_migrate.py`)と `env rekey` の一覧
+ (`lib/devbase/commands/env_ops.py`)。参照を `group=` を渡さずに組んでいるため、`ref.group` が
+ 常に `None` になり、グループが見出しに出ない(`env_migrate.py:85-94`、`env_ops.py:56-63`。
+ 2026-09-22 に確認)
+- すでに `OpenBaoSettings.display_group` を直接呼んでいる文言。読み替えの前後を今も出しており、
+ 変えない。`bundle.py` / `io_import.py` / `env_backend.py` の「別の置き場のプロジェクト」の列挙と、
+ `lib/devbase/commands/env.py` の `_project_group_mismatch`(`--group` がプロジェクトのグループと
+ 違う置き場である旨の文言)がこれにあたる
+- 一覧の桁幅(`{...:<28}` / `{...:<40}`)の変更(前提 4)
+- `group_aliases` の設定方法・置き場のパスの組み立て・キャッシュの位置
+- `DEVBASE_ACCOUNT_GROUP` の警告文に付く ` --group <読み替える前の名前>` の引数
+ (引数は読み替える前の名前でなければ通らないため、変えない)
+- `tests/conftest.py` による `DEVBASE_ROOT` の隔離(別の Pull Request #217 が扱う)
+- `lib/devbase/commands/env_migrate.py` の使われていない `Target.label`(#222 として起票。出力が変わらない)
+- 新しい永続データ・画面の追加(そのため ER 図・テーブル定義・CRUD 図・画面遷移図を作らない)
+
+## 受け入れ条件
+
+見出しの表示(#188):
+
+- [ ] 1. 前提: `backend: openbao` / `version: 2` / `group_aliases: {default: nyle}`、対象のグループが `default`
+ 操作: `devbase env backend test` を実行する
+ 結果: チーム共通の行の見出しが `グローバル(グループ default → nyle)` になる。個人共通は
+ `個人のグローバル(グループ default → nyle)`。隣に並ぶパスは今と同じ(`…/team/nyle/global`)
+ 検証: 新規テスト(`env backend test` の出力の行を読む)
+- [ ] 2. 前提: 1 と同じ設定で、プロジェクト `web` のグループが `default`
+ 操作: `devbase env list` を `projects/web` で実行する
+ 結果: `=== グローバル(グループ default → nyle) (...) ===` と
+ `=== プロジェクト: web(グループ default → nyle) (...) ===` になり、末尾の件数の行
+ (`グローバル(グループ default → nyle): N変数` / `プロジェクト(グループ default → nyle): N変数`)も同じ形になる
+ 検証: 新規テスト(`capsys` の出力を読む)
+- [ ] 3. 前提: 1 と同じ設定
+ 操作: `devbase env backend migrate --to age --dry-run` と、`--dry-run` なしの `--to age` を実行する
+ 結果: 移行の計画の一覧の見出しと、完了後の「サーバ上の機密はそのまま残っています」の一覧の見出しが、
+ どちらも `グローバル(グループ default → nyle)` になる。この 2 つは別の関数
+ (`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、両方を見る
+ 検証: 新規テスト 2 件
+ (2026-09-22 追加。issue 本文は `test` と `list` だけを挙げるが、同じ一覧の形で
+ `label()` とサーバのパスを並べる箇所が `lib/devbase/commands/env_backend.py` に 2 つあり、
+ 直さないと同じ食い違いが残るため)
+- [ ] 4. 前提: 1 と同じ設定で、対象のグループが `kkg`(`group_aliases` に対応が無い)
+ 操作: `devbase env backend test` / `devbase env list` を実行する
+ 結果: 見出しは `グローバル(グループ kkg)` のまま。`→` は出ない
+ 検証: 新規テスト
+
+変わらないこと:
+
+- [ ] 5. `version: 1`(`layout: flat`)の `openbao` backend で、`env backend test` と `env list` の見出しに
+ グループが付かない(今と同じ文字列)
+ 検証: 新規テスト。`env list` の見出しが `=== グローバル (` で始まり、出力全体に `(グループ` が
+ 1 つも出ないことを見る。`env backend test` の参照ごとの行も同じ。あわせて既存テスト
+ (`tests/env/test_groups.py`・`tests/env/test_runtime.py`)が変更なしで通ること
+ (2026-09-22 変更。既存テストは `'=== グローバル'` の前方一致で見ており、見出しに
+ `(グループ default → nyle)` が付いても通ってしまうため、これだけでは条件を確かめられない)
+- [ ] 6. ファイル backend(`plaintext` / `age`)の `env list` の見出しが今と同じ文字列
+ 検証: 新規テスト。`openbao:` 節を残した `backend: age` の設定でも、見出しに `(グループ` が
+ 出ないことを見る(前提 2 の取り違えをそのまま検査する)。あわせて既存テスト
+ (`tests/commands/test_env_user_axis.py`)が変更なしで通ること
+ (2026-09-22 変更。理由は条件 5 と同じ)
+- [ ] 7. `SecretRef.label()` を通して参照を出すエラー文言・警告・ログは、読み替えのあるグループでも
+ 読み替える**前**の名前のままである。例は `layout: flat` でグループ付きの参照を拒む例外
+ (`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の
+ `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である
+ 検証: 新規テスト 1 件(読み替えのあるグループで、この 2 つの文言に `→` が出ないこと)
+- [ ] 8. `env backend status` の `グループ: default → nyle` が今と同じ
+ 検証: 既存テストが変更なしで通ること
+
+仕様書(#188):
+
+- [ ] 9. `docs/specifications/secret-backend.md` の「`label()` はグループがあれば `(グループ <名前>)` を
+ 後ろに付ける」と「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」が、
+ どの文言がどちらになるかを重ならない形で述べている
+ 検証: 設計 Pull Request と実装 Pull Request のレビュー(読んで確かめる)
+- [ ] 10. 同文書の `list` の見出しの例に、読み替えのある場合(`=== グローバル(グループ default → nyle) (...) ===`)が
+ 加わっている
+ 検証: 同上
+- [ ] 11. 利用者向け文書(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)の見出しの例に
+ 読み替えのある場合が加わっている
+ 検証: 同上
+
+退行しないこと:
+
+- [ ] 12. 全体テスト(`uv run pytest tests/ -q`)が通る
+ 検証: 実装 Pull Request の本文に実行結果を載せる(`release/v3.7.0` を base にした Pull Request では
+ CI が 1 件も動かないため、手元の実行が唯一の証跡になる。#216)
+
+## 影響
+
+| 対象 | 影響 |
+| --- | --- |
+| 公開インタフェース | 変わる: `group_aliases` のあるグループでのみ、`env backend test` / `env list` / `env backend migrate`(計画の一覧と `--to age` の完了表示)の見出しのグループ名が `default` から `default → nyle` になる。CHANGELOG では Fixed に書く |
+| データ | 変わらない。置き場のパス・キャッシュの位置・`backend.yml` の内容は同じ |
+| 既存の振る舞い | `SecretRef.label()` の既定の返り値は変えない。エラー文言・警告・ログは今のまま |
+| 確定仕様 | `docs/specifications/secret-backend.md` の 2 か所の記述を書き分け、例を 1 つ足す |
+
+## 検証手段
+
+| 項目 | 手段 |
+| --- | --- |
+| テスト | `uv run pytest tests/ -q`(`release/v3.7.0` を base にする Pull Request では CI が動かないため手元で行い、結果を Pull Request 本文へ載せる) |
+| 静的解析 | `ruff check --select=E9,F63,F7,F82 lib` |
+| 手動確認 | この端末(`group_aliases: {default: nyle}`)で `devbase env backend test` と `devbase env list --keys-only` を打ち、見出しとパスが同じグループを指すことを見る。**読み取りのみ**(前提 5) |
+
+## 前提とする取り決め
+
+| 項目 | 参照先 / 決めたこと |
+| --- | --- |
+| プロジェクト構造 | 参照の値と表示の文言は `lib/devbase/env/secret_store.py`、グループの読み替えの規則は `lib/devbase/env/backend_config.py`、コマンドの出力は `lib/devbase/commands/`。文言の組み立てはコマンド側へ写さない |
+| コーディング規約 | 既存に合わせる(`ruff`)。`SecretRef` は `frozen=True` のまま |
+| テスト戦略 | 表示の契約は単体テスト、コマンドの出力は `capsys` を使うコマンドのテスト。`DEVBASE_ROOT` は各テストが `monkeypatch.setenv` で自分で差し替える既存の流儀に合わせる(`tests/conftest.py` の autouse fixture による隔離は #217 が扱い、まだ `release/v3.7.0` に入っていない) |
+
+## 境界
+
+| 区分 | 内容 |
+| --- | --- |
+| 常に行う | 手元で全体テスト、`ruff` |
+| 確認してから行う | `SecretRef.label()` の引数の追加(設計 Pull Request の承認で確かめる)、確定仕様の書き分け |
+| 行わない | エラー文言・ログの表示の変更、桁幅の変更、実環境への書き込み |
+
+## 実装計画
+
+設計は [PLAN64_secret-label-group-design.md](PLAN64_secret-label-group-design.md)。
+
+## 依頼(原文)
+
+devbasex/devbase#188 より。
+
+> ## 何が起きたか
+>
+> `version: 2`(`group_aliases: {default: nyle}`)の端末で `devbase env backend test` を打つと、見出しが読み替え前の名前だけになる。
+>
+> ```text
+> グローバル(グループ default) devbase/team/nyle/global 22 変数
+> ```
+>
+> `env backend status` は `グループ: default → nyle` と両方を出すのに、`SecretRef.label()` を使う見出し(`env backend test`・`env list`)は `default` だけで、隣のパス(`nyle`)と食い違って見える。
+>
+> 現象は v3.6.0 でも再現する。`SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)は `self.group` をそのまま埋め込み、`storage_group` も `display_group` も呼ばない。
+>
+> ## 仕様書が自己矛盾している
+>
+> `docs/specifications/secret-backend.md` に、相反する 2 つの記述がある。
+>
+> | 箇所 | 記述 |
+> | --- | --- |
+> | 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 読み替え前を出す |
+> | 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | 読み替え後も出す |
+>
+> `list` の見出しの例は `=== グローバル(グループ with) (...) ===` で固定されているが、`with` は読み替えの無いグループなので、この例では前後が一致してしまい矛盾が表に出ない。**コードを直す前に、どちらを採るかを仕様の側で決める必要がある。**
+>
+> ## 修正レイヤー
+>
+> **現象レイヤー**: `env backend test` の見出し(`lib/devbase/commands/env_backend.py:472`)と `env list` の見出し(`lib/devbase/commands/env.py` の `cmd_env_list` / `_group_suffix`)。
+>
+> **修正レイヤー**: `SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)が返すグループの表示。呼び出される側の契約であり、`env list` の `_group_suffix` は「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」と明記して label() に従っている。見出しを 1 か所ずつ直すと、この従属関係が崩れる。
+>
+> `label()` は `SecretRef` にあり `OpenBaoSettings` を知らないため、読み替えの前後を出すには表示の側へ設定を渡す必要がある。`label()` のコメントは「チーム単位の文字列は変えない(誤りの伝達や桁揃えに埋め込まれている)」と断っているので、`label()` にグループの表示だけを差し替える引数を足すか、表示用のラッパを 1 つ置くかを設計で決める。
+>
+> **採る手**: 移動(`move_responsibility`)。グループの表示の責務を、呼び出し側から `label()` の契約へ寄せる。
+>
+> ### 波及の範囲
+>
+> `label()` は `lib/` の中で **52 か所 / 10 ファイル**から呼ばれる。大半はエラー文言とログである。
+>
+> **既定の振る舞いを変えると 52 か所すべてに及ぶ。** 見出しだけを変えるなら、差し替えるのは `commands/env_backend.py:472` と `commands/env.py` の `_group_suffix` の 2 か所で足りる。どちらにするかが設計の分かれ目である。
+>
+> ## 期待すること
+>
+> 見出しのグループ名も `status` と同じく読み替えの前後を出す(`OpenBaoSettings.display_group` を使う)。`version: 1` とファイル backend の見出しは変えない。
+>
+> ## 受け入れ条件
+>
+> - `group_aliases` のあるグループで `env backend test` と `env list` の見出しが読み替えの前後を出す
+> - `docs/specifications/secret-backend.md` の 2 つの記述の矛盾が解け、`list` の見出しの例に読み替えのある場合(`default → nyle`)が加わる
+> - `version: 1` とファイル backend の見出しが変わらない