このリポジトリを改修するときの手引き。人にも、コーディングエージェントにも同じ ものを渡す——片方だけの決まりを作ると、もう片方が破る。
設計の意図そのものは不変条件と Contributing にある。ここに書くのは手順と、 実際に踏んだ罠。
今どこまで来ていて何が無いかは STATUS.md。 版・現在地・分かっている穴は そちらだけに書く(両方に書くと必ず片方が古くなる)。
ARK 識別子の払い出しと解決。一度配った名前が、別のものを指すようにならない ——設計の大半はこの 1 つの約束から出てくる拒否である。
だから、この基盤で最も避けたい失敗は「動かないこと」ではなく、間違ったまま 静かに動き続けること。以下の手順はほぼすべてそのためにある。
uv sync --frozen --all-extras # lock どおりに入れる
uv run pytest -q # 全部通ること
uv run ruff check src tests clients--frozen は lock と pyproject.toml がずれていたら落ちる。依存を足したら
uv lock を実行する(忘れると check.sh で落ちる。それが狙い)。
bash scripts/check.sh # 検査ぜんぶ
bash scripts/deploy-docs.sh # ドキュメントサイトを gh-pages へ
bash scripts/release.sh vX.Y.Z # 版を出す(--publish を付けたときだけ実際に出る)系統は 1 つにする。 手元と CI の 2 系統があると「片方では通る」変更が生まれ、
やがて誰も片方を見なくなる。GitHub Actions は置いていない——.github/ に残して
あるのは issue と PR のテンプレート、そして依存の更新(Dependabot)だけ。
3 本とも引数の扱いは同じ。 -h / --help で冒頭の注釈をそのまま出し、知らない
引数は exit 2 で止める。注釈の切り出しに行番号を使わない——sed -n '2,24p' は
見出しを 1 行足すたびにずれるので、「最初の # でない行まで」で切る:
-h|--help) awk 'NR > 1 && !/^#/ { exit } NR > 1' "$0"; exit 0;;知らない引数で止めるのは deploy-docs.sh のためでもある。素通ししていた頃は
--dryrun と打ち間違えると --dry-run として扱われず、何も言わずに gh-pages へ
force push していた。止めたつもりが出ているのがいちばん困る。
check.sh は sync(--frozen)→ ruff → pytest → 使い捨ての PostgreSQL を立てて
マイグレーションを往復(デモの DB には当たらない)→ 通しの検査(本番と同じ形に
建てて HTTP で叩く)→ OpenAPI のずれ → mkdocs --strict。
道具が無い項目は黙って通さず SKIP と出す——「入っていないから通った」がいちばん危ない。
AGENTS.md が正で、あちらは実行に移すための覚え書きである。 手順が変わったら
先にここを直す——2 か所に書くと必ず片方が古くなる、というのはこの文書が冒頭で
言っているとおりである。
| 何のため | |
|---|---|
release |
版を出す 6 手順と、踏んだ罠(節の差し込み先・日英・STATUS・OpenAPI の検査) |
measure |
測り方。クライアントの天井・ばらつき ±20%・時間で見えないものは数える |
chaos |
kind の arkhe を壊して可用性を測る。期限切れの chaos は apply で再実行されない |
書いてあるのは、間違えた記録である。 どれも実際にその形で誤ったことがあり、 次に同じところで止まらないために置いてある。
まずリポジトリ全体。どこに何があり、どれが生成物か。
src/arkhe/ 実装。層の説明は下
tests/ ファイル名が対象を表す。`tests/e2e/` は**通しの検査**(`-m e2e`。docker が要る)
clients/python/ Python クライアント(`arkhe-client`)。**別パッケージだが同じ網で検査する**
——`test_contract.py` が公開 OpenAPI と両方向で突き合わせる
alembic/ マイグレーション。**PostgreSQL で検証する**
docs/ MkDocs。`page.md` が英語、`page.ja.md` が日本語
compose/oidc/ Keycloak つきの体験環境。**見本であって手本ではない**
scripts/ check.sh / deploy-docs.sh / release.sh(**CI の代わり**)、export_openapi.py、
seed_e2e.py(**通しの検査の台帳**。`tests/e2e/` が呼ぶので腐らない)、
bench.py(**check.sh からは呼ばない**——機械とその日に左右される値は緑と赤で語れない)
.github/ issue と PR のテンプレート、Dependabot。**ワークフローは持たない**
AGENTS.md これ。手順と罠
STATUS.md 現在地と、分かっている穴
CHANGELOG{,.ja}.md 版ごとの変更。**未リリースの節に足す**
site/ dist/ db.sqlite3 生成物。**すべて .gitignore 済み**
そして実装の層。
arkspec/ ARK 仕様を純粋な関数で。**stdlib しか import しない**
domain/ 解決・認可・採番・管理・一覧の絞り込み。HTTP を知らない
db/ SQLAlchemy のモデル
auth/ 3 つの機構、1 つの Principal
api/ FastAPI のルータ、管理画面、i18n
cli.py 運用コマンド。**画面と同じ domain を呼ぶ**
arkspec/ と domain/resolution.py が何にも依存しないのは偶然ではない
——フレームワークを丸ごと入れ替えたときに 97 件のテストが無傷で移れた理由が
これ。仕様のロジックが DB を必要としたら、設計がどこか間違っている。
cli.py の冒頭に書いてあるとおり、CLI にしかできないこと・画面にしかできない
ことを作らない。実際に破れていた例が ark list で、画面にはあったが CLI に
無かった。絞り込みは domain/queries.py に置いて両方が同じ式を通る。
到達範囲の判定を 2 か所に書かない。 片方だけ直したときに、画面には出ない ものが CLI には出る——そして気づくのは、見えてはいけないものが見えた後になる。
api/i18n/ は画面で分けてある(_shell _ledger _clients _arks _signin
_audit)。言語で分けていないのは、対を離すと片方だけ足したことが差分に
出ないから——起動時の検査は最後の砦であって、最初の砦ではない。訳文に
` や ** を書かないこと(そのまま画面に出る。テストで縛ってある)。
到達範囲は 3 段(Authority)。上の段は下の段を含む。
SYSTEM RA の運用者。全 NAAN に届く
NAAN 1 つの NAAN の配下すべて
MANAGER 1 組織ぶん(shoulder_id を併せると 1 shoulder に固定)
到達範囲はクライアント登録の属性で、要求本文やトークンの中身からは決して 来ない。トークンは scope を狭めることしかできない。ここを緩める変更は 差し戻される。
CLI は _root() でシステム管理者として動く。サーバのシェルに入れる時点で DB に
届くので権限で絞っても意味の在る防御にならない——代わりに操作は必ず監査に残る。
Ark.published_at が null のものは未登録の名前と同じに扱う(完全一致でも祖先
passthrough でも拾わない)。落とす場所は 2 か所ある——db/repository.py の問い合わせと
domain/resolution.py の is_public。二重に見えるが、どちらも要る: 前者は
リゾルバに要らない行を載せないため、後者は決定ロジックが「行があるか」ではなく
「外に出した名前か」で決めることを、差し替え可能な repo の向こう側でも守るため。
domain/resolution.py の判断順(完全一致 → 祖先 → 検査桁 → shoulder の redirect →
404)は、順序そのものが結論を決める。検査桁が shoulder 委譲より先にあるので、
委譲先が検査桁を作らないと上位経由の解決だけが 404 になる——下位に直接来た要求は
通るので、いちばん見つけにくい壊れ方をする。
複数の arkhe で分担する構成の前提と穴は分散して運用する。 台帳を分けた瞬間、コードが守っていた不変条件のいくつかが人の手に移る(同じ shoulder を 2 か所で採らない、権威を持つ台帳は NAAN あたり 1 つ)。
同じ穴に二度落ちないために書いてある。 どれも実際に起きた。
alembic check の指摘は本物 |
use_alter の FK は op.create_table が落とす。「宣言してあるのに作られない」が実際に起きた。SQLite は通す |
| SQLite にも一度は流す | 制約の付け外し(create_foreign_key / drop_constraint)は SQLite に ALTER が無く、batch_alter_table を通さないと NotImplementedError で止まる。where 節も sa.text() にしないと落ちる。PostgreSQL だけ見ていた 3 版のあいだ、Quickstart のとおりに打つと途中で死んでいた。tests/test_migrations.py が頭まで流す |
| マイグレーションは PostgreSQL で見る | SQLite は PostgreSQL が弾くスキーマを通す。開発中に 2 回、これがバグを隠した。upgrade → downgrade base → upgrade → check まで回す |
CHANGELOG に s.index("### Added") を使わない |
ファイル先頭からの最初の一致を返すので、リリース済みの節に追記してしまう。2 回やった。「未リリース」の見出しを起点に探す |
CSP の script-src 'none' は Swagger UI を白紙にする |
/api/docs /api/redoc だけ DOCS_CSP を当てている。触ったら実物を開いて見ること |
/healthz は全モードに要る |
resolver / minter / admin のどれで起動しても要る。1 つに付け忘れて k8s に殺され続けた |
/readyz に Depends(get_session) を使わない |
依存がハンドラより先に落ちるので 503 でなく 500 になる。自前でセッションを開く |
SQLAlchemy の default は None を代入しても効く |
「値を入れていない」と「None を入れた」を区別しないので、Column(default=utcnow) に published_at=None を渡すと予約したはずの ARK が公開済みで入る。既定値を持たせず、domain/minting.py で決める |
| 公開前の ARK を消すとき、指している行を先に落とす | ArkChange と MintReceipt は ark.ark への FK を持つ。残すと外部キー違反で commit が落ちる |
dataclasses.replace は代入しないと効かない |
replace(res, ...) の戻り値を捨てたまま次行で return していて、直したつもりが直っていなかった |
i18n の訳文に ` や ** を書かない |
画面にそのまま出る。何度もやったのでテストで縛ってある |
| 部分ユニーク索引 | (manager_id, label) の一意制約は空文字を弾いてしまう。ラベル無しの主体は 1 組織 1 つしか作れなくなる |
| 参照ページは生成物ではない | docs/reference/cli.md と configuration.md は手で書く。Contributing に「生成される」と書いてあったのは誤りで、そのせいで設定 2 つとコマンド 1 つが未記載のまま残っていた。今は tests/test_docs.py が落とす |
uv lock は版を上げたら要る |
pyproject.toml の version を上げると lock とずれて --frozen が落ちる |
| Typer の help は import 時に確定する | だから CLI の言語は環境変数から一度だけ決まる。--lang のような実行時切り替えは作れない |
新しい挙動には、それが無ければ落ちるテストを付ける。認可に触るなら 通らないほうの場合も書く——面白いのは正しい主体が通ったことではなく、 間違った主体が通らなかったこと。
テストは文になっている(test_m3_another_organisations_ark_cannot_be_updated)。
落ちたときに、例外を投げた関数ではなく壊れた決まりが名指しされるように。
コードは英語で書く——識別子・コメント・docstring とも。日本語が残るのは
多言語カタログ(api/i18n/・cli_i18n.py・errors.py の ja)だけで、
あれは日本語の画面そのものである。文書(この文書・STATUS・docs/*.ja.md)は
今までどおり日本語。
API を足したらクライアントも落ちる。 clients/python/tests/test_contract.py が
公開 OpenAPI 文書と両方向で突き合わせているので、エンドポイントを足すと
「クライアントも持つべきか」を決めるまで緑にならない。決めずに素通りしないための仕掛け。
どのファイルが何を見ているかは STATUS.md の「テストの内訳」。 ここに並べ直すと必ず片方が古くなる——実際、この一覧は 5 本ぶん遅れていた。 ファイル名が対象を表すので、迷ったら対象の名前で探す。
MkDocs Material、接尾辞で 2 言語(page.md が英語、page.ja.md が日本語)。
図は Mermaid のコードブロック——再生成する画像ファイルを持たない。
uv run mkdocs build --strict # 警告 0 を保つ
uv run python scripts/export_openapi.py # API 仕様はここだけ生成物設定を足したら docs/reference/configuration.{md,ja.md} に、コマンドを足したら
cli.{md,ja.md} に、両方の言語で行を足す。 忘れるとテストが落ちる。
ページを 1 枚足すときにやること(どれか 1 つ抜けると片方の言語で消える):
docs/…/name.mdとdocs/…/name.ja.mdを対で作るmkdocs.ymlのnavに英語の題で足す- 同じ題を
nav_translationsに日本語で足す——ここを忘れると日本語版に英語の項目が並ぶ - 日本語の見出しに深いリンクを張るなら
{#anchor}を自分で書く(attr_listが効く)。 自動生成のアンカーは見出しを 1 つ足すとずれる
コミットメッセージは変更ではなく理由を書く。将来の読者が知りたいのは、 何を知っていたからそれが正解だったのか——特に答えが奇妙に見えるところで。
| ブランチ | 切る元 | 戻す先 | 何を載せるか |
|---|---|---|---|
main |
— | — | リリースした版だけ。 タグはここに打つ |
develop |
main |
— | 次の版に入るもの |
feature/<名前> |
develop |
develop |
機能・文書・手順の変更 |
bugfix/<名前> |
develop |
develop |
次の版で直すもの |
release/X.Y.Z |
develop |
main と develop |
版を上げ、CHANGELOG と STATUS を揃える。機能は足さない |
hotfix/X.Y.Z |
main |
main と develop |
出した版への急ぎの修正 |
main と develop には直接コミットしない。 変更は PR を通し、--no-ff で
マージする——ブランチの単位が履歴に残り、何をひとまとまりとして入れたかを後から
辿れる。Dependabot の PR も develop に向ける(.github/dependabot.yml)。
0.14.0 までは main へ直接コミットしていた。初期は速かったからで、方針では
なかった。 この節が以前「main へ直接コミットするこの体系」と書いていたので、
方針と読まれた。develop は 2026-10-04 に main から作った。
リリースも手元で回る。既定は「出さない」——確かめるのは安く何度でもできるが、 タグと GitHub のリリースはそうではない。
bash scripts/release.sh v0.0.9 # 検査と dist の作成だけ(どのブランチでも)
bash scripts/release.sh v0.0.9 --publish # タグ → push → GitHub のリリース(main でだけ)手順そのものも検査する——版の一致(pyproject とタグ)、CHANGELOG にその版の節と
リンク定義が日英ともあること、「未リリース」の比較リンクが新しい版を指していること。
どれも実際に間違えたことのある場所である。--publish は main の上でしか動かない
——タグが release/X.Y.Z の上に打たれると、main に無いコミットが版を名乗る。
リリースノートは CHANGELOG.md のその版の節から起こす(本文は英語、日本語は
在処を指すだけ)。gh --generate-notes は使わない。main へ直接コミットしていた頃は、
あれが並べるのは PR なので拾うものが無く、比較リンク 1 行だけの空の本文になった
(v0.0.9 と v0.2.0)。PR を通すようになっても使わない——PR の題を並べたものは、
同じ変更を別の言葉で書いたもう 1 か所で、CHANGELOG と必ずずれる。
版は pyproject.toml だけで決まる。ずれていれば release.sh が落とす
——「v0.0.2 と名乗る 0.0.1」を世に出さないため。手順:
git switch -c release/X.Y.Z developpyproject.tomlのversionを上げる →uv lock- CHANGELOG の「未リリース / Unreleased」の下に版の節を作る(リリース済みの 節に追記しない)。末尾のリンク定義も 2 言語ぶん、「未リリース」の比較リンクも 新しい版に置き換える
STATUS.mdの版と日付を合わせる(同じコミットに入れる——0.1.0 と 0.2.0 では触らずに出したので、「版 0.0.9」のまま 2 版ぶん据え置きになった。 リリースで動く数字を、リリースの手が触らないならそうなる)- コミットし、
bash scripts/release.sh vX.Y.Zが緑になるまでrelease/X.Y.Zで直す mainに--no-ffでマージ →mainの上でbash scripts/release.sh vX.Y.Z --publishmainをdevelopに--no-ffでマージして push し、release/X.Y.Zを消す ——戻し忘れると、次の版のdevelopに版上げと CHANGELOG の節が無いmainの上でbash scripts/deploy-docs.sh(変更履歴のページを追随させる。main以外からは出さない——サイトに未リリースの記述が載る)
急ぎの修正は hotfix/X.Y.Z を main から切り、2〜8 を同じにたどる。
arkhe を submodule として組み込んでいる側があるなら、そこのポインタも進める。 それは組み込んだ側の手順なので、ここには書かない——この文書は arkhe の手引きで あって、arkhe を使う人の手引きではない。
0.x のあいだはプレリリースとして出る。
seed_demo.py と realm-arkhe.json に置くのは例示用の名前と番号だけ。
本物の機関名を置くと、その機関が arkhe を使っているように読める——画面をその
まま見せる資料や録画に載ったときに、こちらの意図と関係なく既成事実になる。
NAAN も同じで、割り当て済みの番号は使わない(99999 は仕様が試験用に予約して
いる番号、12345 / 54321 は例示のための値)。
画面に関わる変更は、実物を開いて見る。 括弧書きの用語も、スマホ幅での はみ出しも、テストでは落ちなかった。
cd compose/oidc && docker compose up -d # arkhe :8057 / resolver :8058 / Keycloak :8080
docker compose exec -T arkhe arkhe ark listLAN から見せるときは lan.yml を重ねて ARKHE_DEMO_HOST を指定する。
イメージを焼き直したら古いコンテナを残さない——別ポートに古い版が生きて いて、見ている画面が違う、が実際に起きた。
識別子は振り直せない。 迷う変更のほとんどは、「間違えたときに取り返せるか」 で決まる。取り返せないものは、緩めるより拒否するほうを既定にする。