データモデル¶
Naan → Manager → Shoulder → Ark の 1 本で全 NAAN を扱う。個別 NAAN を持つ組織でも
shoulder を必ず使う——使わないと NAAN ごとにモデルが分岐し、first-digit 規約が NAAN に
よって成立したりしなかったりする。
erDiagram
NAAN ||--o{ MANAGER : "名前空間を委譲する"
NAAN ||--o{ SHOULDER : "配下に持つ"
NAAN ||--o{ ARK : "権威を持つ"
NAAN ||--o{ CLIENT : ""
MANAGER ||--o{ SHOULDER : "預かる"
MANAGER |o--|| SHOULDER : "既定の採番先"
MANAGER |o--o{ MANAGER : "統廃合で承継する"
MANAGER ||--o{ CLIENT : ""
SHOULDER ||--o{ ARK : "この名前空間で採番された"
SHOULDER |o--o{ CLIENT : "固定する(任意)"
CLIENT ||--o{ CREDENTIAL : "持つ"
ARK ||--o{ MINT_RECEIPT : "控え"
ARK ||--o{ ARK_CHANGE : "行き先が変わった記録"
NAAN {
string naan PK "N2: 文字列。099999 と 99999 は別の NAAN"
string name
bool is_authoritative "D3: 未知名を 404 と言えるか"
string redirect "権威を持たないときの委譲先"
string na_policy "永続性宣言(NP | NR, OP, CC | 2026 | URL)"
string minter "採番を外に委ねている場合の案内先"
date hold_until "保留の期限。null は保留なし"
string hold_reason "保留の理由(公開する)"
}
MANAGER {
int id PK
string naan FK
string name "内部専用。公開しない"
int default_shoulder_id FK "shoulder 省略時の採番先"
string commitment_level "NLM の permanence ratings"
int quota_per_day "R3: null は無制限"
bool active
int succeeded_by_id FK "承継先。識別子は壊さない"
}
SHOULDER {
int id PK
string shoulder "例 /x9"
string naan FK
int manager_id FK "null は組織未割当"
string redirect "N2T: 解決の委譲($id / ${blade} / 303)"
string minter "N2T: 採番の委譲先"
string status "active / reserved / delegated / retired"
string note
date hold_until "保留の期限。null は保留なし"
string hold_reason "保留の理由(公開する)"
}
ARK {
string ark PK "naan/name。**一度公開したら、理由と跡を残してしか消えない**"
string naan FK
int shoulder_id FK
string assigned_name
string url "空なら記述を返す(D6)"
date published_at "今 公開中か。null は公開のリゾルバで解決しない。取り下げで戻る"
date first_published_at "一度でも公開したか。**片道で、消さない**。削除の重さを決める"
date hold_until "保留の期限。null は保留なし"
string hold_reason "保留の理由(公開する)"
string commitment "この対象への約束"
string metadata
string who "ERC"
string what_title "ERC: title 列"
string when "ERC"
string created_by "R2: 監査証跡"
string updated_by
}
CLIENT {
int id PK
string client_id UK "外部に見せる識別子。OIDC の azp と突き合わせる"
string naan FK
int manager_id FK
string subject_type "machine / person"
string authority "system / naan / manager"
int shoulder_id FK "1 つに固定する(任意)"
string allowed_scopes "ark:mint ark:update ..."
bool active "無効化でトークンが即座に効かなくなる"
date expires_at "authority=naan では必須"
}
CREDENTIAL {
int id PK
int client_pk FK
string kind "api_key / client_secret / password"
string prefix "照合を O(1) にする前置き。秘密ではない"
string hashed "Argon2。**平文は保存しない**"
bool active "失効させても行は消さない"
int failed_attempts "総当たり対策"
date locked_until
}
MINT_RECEIPT {
int id PK
string client_id "主体ごとに独立"
string request_id "F4: 冪等鍵"
string ark FK
}
ARK_CHANGE {
int id PK
string ark FK
date at
string action "update / tombstone / hold / publish"
string before_url "**復元したいのはこれ**"
string after_url
string by
string ip
}
AUDIT_EVENT {
int id PK
date at
string client_id
string authority
string action "mint / update / succeed / depart ..."
string target
json detail
}
WITHDRAWN_NAME {
string ark PK "公開前に取り下げた名前。**二度と採らない**"
string naan
string assigned_name
int shoulder_id "どの名前空間の容量を使ったか"
date minted_at
string minted_by
date withdrawn_at
string withdrawn_by
string reason "消えた行の唯一の説明"
string ip
}
UNKNOWN_SUBJECT {
int id PK
string subject "認可サーバ側の識別子(azp / client_id / sub)"
string issuer
date first_seen
date last_seen
int seen
string ip
}
UNKNOWN_SUBJECT は、認可サーバのトークンは正しいのに台帳に登録の無かった主体
である。client_id の綴りが 1 文字違うだけで 401 になるが、弾いた時点で arkhe は
正しい文字列を手に持っている——azp はもう署名検証を通っている。捨てずに残せば、
運用者は打ち直さずに登録できる。登録が済めば一覧から自動的に消える(照合は
問い合わせのたびに行うので、消す操作が要らない)。
他の表と外部キーで結ばない。どの組織のものかは分からないからで、推測もしない ——だから見えるのは NAAN 以上に届く主体だけにしてある。
ARK_CHANGE は ARK の行き先が変わった記録で、監査ログとは別に持つ。監査は
NAAN 単位以上の操作しか残さないが、採番も付け替えも組織が行うので、監査だけ
では肝心の変更が落ちる。NR を宣言する体系で「この識別子は変わらない」と言う
なら、変えたのは何でいつ誰が変えたのかを示せなければならない——さもないと、
約束を検証する手段が利用者の側に無い。
WITHDRAWN_NAME も外部キーを持たない。指す先の行がもう無いからであり、
記録は対象より長く残るべきだからでもある。
AUDIT_EVENT は他の表と外部キーで結ばない。記録は対象が消えても残るべきもので、
参照整合性で縛ると「消せないから記録も消す」という逆の力が働く。
図に描けないこと¶
ER 図は形しか示さない。arkhe の設計の中身は制約のほうにある。
| 公開した ARK は、軽くは消せない | 行を消すと解決が止まる=識別子が壊れる。before_delete は、そのセッションがその ARK を名指ししていないかぎり拒む——見るのは「一度でも公開したか」であって「今 公開中か」ではない(後者だと取り下げてから消せてしまう)。対象が失われただけなら tombstone にするか url を空にして記述を返す |
| 一度も公開していない ARK は軽く削除できる | first_published_at が null のものだけ(公開を取り下げただけのものは含まない——published_at は往復するが、first_published_at は片道で消えない)。まだ外に出していない名前は NR が縛る対象ではない——ただし名前は WITHDRAWN_NAME に移り、二度と採られない |
| 保留は行き先を消さない | hold_until / hold_reason(と hold_by)は NAAN・shoulder・ARK が同じ列で持ち、狭いほうが優先する。転送だけを止めて 200 と記述を返し、元の行き先は残す。期限切れは解決のたびに時計で判定するので、戻すための書き込みが無い |
| shoulder も削除できない | 乱数割当が同じ文字列を再び当てうる=NR 違反の芽。status=retired にする |
| retired からは戻せない | 引退した名前空間の再開は、その間に外部が同じ名前を使った可能性を否定できない |
| 採番は UPDATE に化けない | 主キー衝突は必ず失敗させる。出発点にした arklet で最重大の欠陥がこれだった(本家では解消済み) |
| 到達範囲は登録属性 | authority / manager_id / shoulder_id / allowed_scopes はクライアント登録の属性で、リクエストやトークン要求では広がらない |
| 人と機械を分ける | subject_type=machine は外部ログインで名乗れず、person は API キーを持てない |
| 循環参照 | manager.default_shoulder_id ⇄ shoulder.manager_id。PostgreSQL は CREATE TABLE の時点で参照先を要求するので、use_alter で後付けにしてある |
容量について¶
子リソースは採番しない。 ark:99999/x9tn1qkq2g7/page/3 のような深い参照は suffix
passthrough が賄うので、1 レコード 1 採番で足りる。ここが容量設計でいちばん効く。
容量には二つある——名前がいくつ作れるかと、台帳がどれだけ太るか。前者は桁数 だけで決まり、後者は採番数で決まる。
名前空間の容量¶
shoulder の桁数が、1 つの NAAN から切り出せる名前空間の数を決める。 first-digit
規約により shoulder は子音の並び+末尾の数字 1 桁で、子音は betanumeric から母音と
l を除いた 19 種、末尾は 0-9 の 10 種。スラッシュを除いた文字数を桁数とすれば
| shoulder の桁数 | 例 | 切り出せる数 |
|---|---|---|
| 2 | /x9 |
190 |
| 3(既定) | /bc7 |
3,610 |
| 4 | /bcd7 |
68,590 |
3,610 は800 組織に対して使用率 22.2%——4.5 倍の余裕がある。ただし shoulder は
乱数で採る(連番にすると加入順が漏れる)ので実際には手前で衝突が始まり、
retired にした分は二度と使えない。余裕は使い切る前提で見ない。
blade の桁数が、1 つの shoulder で採れる ARK の数を決める。 arkhe は blade を betanumeric 29 文字から 8 桁の乱数で採り、末尾に検査桁を 1 桁足す——1 shoulder あたり 29⁸ = 約 5,002 億。
この上限が先に来ることは、実際には無い。 1 億件で台帳は約 40 GB(次節)で、
名前が尽きるよりずっと手前に台帳の大きさが効く。桁数は上限としてではなく衝突率
として現れる——mint は衝突を握りつぶさず数えて採り直し(10 回で失敗)、その率の
上昇が名前空間の枯渇を知らせる唯一の合図になる(採番 API はいまこの回数を返していない)。
1 行の実寸¶
PostgreSQL 17、100 万件、表と索引をすべて含めた実測。
| ARK 1 件あたり | |
|---|---|
url + title + who + when |
362 バイト |
url だけ(記述なし) |
158 バイト(表のみ) |
ark_change 1 行(行き先を変えた記録) |
258 バイト |
線形である——10 万件でも 100 万件でも 362 B/件。したがって
そして掛ける相手が参照ではなく対象の数なので、
| 採番数 | 台帳 |
|---|---|
| 100 万 | 約 0.4 GB |
| 1,000 万 | 約 4 GB |
| 1 億 | 約 40 GB |
1 億件でも、ふつうの PostgreSQL 1 台に収まる。 文書ストアと同じ勘定で見積もるのが よくある取り違えで、arkhe が持つのは名前とその行き先であって、対象そのものではない。
自分の台帳で測る¶
-- 実寸と、ここでの 1 件あたり
select pg_size_pretty(pg_total_relation_size('ark')) as total,
round(pg_total_relation_size('ark')::numeric / count(*), 0) as bytes_per_ark
from ark;
-- 索引ごと——メモリに載っていてほしいのは主キー
select indexrelname, pg_size_pretty(pg_relation_size(indexrelid))
from pg_stat_user_indexes where relname = 'ark'
order by pg_relation_size(indexrelid) desc;
記述をどれだけ持つかで 2 倍以上変わる(158 B 対 362 B)ので、一般論より自分の 台帳を測るほうが確かである。処理量と構成はデプロイにある。
列の幅は仕様から決まる¶
このうち 2 つは我々が選べる値ではない。draft-kunze-ark-42 は受け取る側に
NAAN 16 オクテット以上(§2.3)と Base Name + Qualifier 255 オクテット以上
(§3.1)への対応を義務づけている。したがって naan.naan は varchar(16)、
ark.assigned_name は varchar(255)、台帳の鍵 ark.ark(<naan>/<name>)は
varchar(272)。NAAN や ARK を入れる列はすべてこれに揃えてある——経路によって
入る値と入らない値が変わるほうが、どこでも入らないより悪い。
これを超える名前は DB のエラーではなく、上限を示した 400 で断る。索引できない
のは我々の事情であり、仕様も「長い文字列を作る側は、受け取る実装が扱えないかも
しれないと理解すべき」と書いている。