Publish Next ADR — 公開ドメイン・API・ライフサイクル
状態と適用範囲
提案・未実装(2026-10-05)。#7224 のうち、#7229 が所有するドメイン、URL、公開操作、権限の決定記録です。現行の説明は Publication Design と Publish API に残します。本ページの型・ルート・制限値は、特記した現行調査を除いてすべて将来契約です。実装済み API の利用案内として読まないでください。
内蔵公開は「明示的に渡された note のスナップショットと著者の公開プロフィール」のサービスです。時系列一覧と平坦なタグを加えますが、階層型 documentation、外部 SSG、汎用エディタの frontmatter schema を所有しません。哲学 に従い、公開用 title、description、tags、visibility、theme は Publish が所有する metadata とします。frontmatter の title / tags / draft を特別扱いせず、取り込みを行う場合も利用者が指定した対応から公開要求へ明示的にコピーします。note の保存や frontmatter の変更だけでは公開しません。
pgen adapter、実行基盤、render job、R2 の render hash は Publish OGP ADR が所有します。本ページはそれらが満たすべき可視性、版、入力境界を決めます。UI の配置や visual design、pgen の導入、実データの移行・公開・deploy は本 ADR に含めません。
全 deliverable の対応、scenario ごとの受け入れマトリクス、後続実装 issue の仕様は Publish 受け入れマトリクスと実装 issue 分割 を参照してください。
現行調査の根拠
調査したコードは base 750f131eaf6108ce0f00e02586689e41a3d79ee7。以下のリンクはこの版に固定しています。
| 現行の事実 | 根拠 |
|---|---|
user_public_profiles は user_id が主キー、username は全体で一意。published_pages は (user_id, slug) が一意。プロフィール公開とページ公開は既に存在する。 | schema 0003、row types |
canonical は / と /。公開一覧は updated_at DESC LIMIT 50 でパスワード付きページを除外。public JSON も存在するが cursor・タグ・unlisted はない。 | public handlers |
| profile PUT、create、replace、additional、snapshot PUT、asset upload は Pro 資格を検証。active/trial の pro と developer allowlist が対象で、trial 終了後 3 日の grace がある。unpublish、archive、restore、削除、password 管理、owner read は同じ Pro check を要求しない。 | profile handlers、subscription check、page handlers、upload |
| 本文と presentation は保存済み snapshot。profile の username/display name と canonical は配信時 slot。replace/PUT は revision guard、create/replace/additional は idempotency ledger、同一公開 fingerprint は no-op。 | mutations、document renderer、schema 0038 |
source の workspace/path は owner 用。workspace 所有を検証した一致だけが replace 候補になる。slug だけでは同一 note と断定しない。複数公開と legacy_unknown を扱う。 | decision、source schema |
| archive は公開停止、restore は非公開、永久削除は archived 条件を DELETE 時にも再検査。password は gate epoch 付き capability。public asset は page/profile/role/gate を検証。 | page handlers、password gate、asset read |
| client は HTTP 契約を bridge で共有。composer は note の明示的な取り込み、target/revision、asset 再送を扱い、account/workspace/note の切替で古い操作を無効化する。 | bridge client、use-publish |
現行 username は ASCII 小文字・数字・hyphen、3–30 文字で先頭末尾 hyphen と連続 hyphen を拒否。slug は同じ文字集合で 2–100 文字、先頭末尾 hyphen を拒否しますが連続 hyphen は許可します。validation の username 予約語は admin, api, www, help, support, about, health, status, login, signup, settings。現行 rename の履歴表はなく、旧 URL は origin で 404 になります。これを「既存 redirect がある」と扱ってはいけません。
ドメインと所有権
以下は logical schema。名前を変えるためだけに published_pages を作り直さず、PublishedPost はその次期ドメイン名とします。UUID はサーバー発行の opaque identifier。日時はサーバーの UTC RFC 3339、ミリ秒精度(例 2026-10-05T01:00:00.000Z)。revision は 1 以上の safe integer、schema version と renderer version は別物です。
PublicProfile
| フィールド | 型・制約・意味 |
|---|---|
userId | owner-only。users.id への主キー/FK、1 account に 1 profile。body の userId は受け付けず認証から取得。public projection に出さない。 |
username | 全 account と username alias の namespace で一意。URL 規則は後述。inactive でも予約を維持。 |
displayName, bio | plain text、各 1–80 / 0–500 Unicode scalar。制御文字を拒否し bio の LF だけ許す。HTML として解釈しない。 |
avatarAsset | null または profile 専用の検証済み画像 identity {sha256, mediaType, width, height}。サーバー upload receipt から解決し、任意 URL/path は不可。 |
links | 最大 5 個の {id, label, href}。label 1–40 scalar、href は絶対 HTTPS・最大 2048 bytes、userinfo 禁止。label も plain text。順序を保存。サーバーはリンク先を fetch しない。 |
themeSelection | 次節の PublishingThemeSelection。新規公開の既定値。既存 post を暗黙に改版しない。 |
branding | {mode: "attribution"} / {mode: "none"} / {mode: "cta", label, href}。CTA は 1–80 scalar、HTTPS 2048 bytes、userinfo/HTML 不可。実効値は現在の plan が決める。 |
isActive | boolean。false は profile と全 post の公開配信を遮断。true へ戻す場合、保存済み公開 post が再び見える旨を含む明示的 reactivate 操作が必要。普通の profile 編集で true にしない。 |
profileRevision | profile の意味のある変更ごとに +1。post の revision を一斉に増やさない。 |
createdAt, updatedAt | 作成時刻、profile 変更時刻。 |
avatar は本文 asset の公開可否から独立した profile asset として upload・所有権・digest・画像実体を検証し、2 MiB / 最大 2048×2048 px に制限します。inactive profile の avatar 配信も遮断します。非公開 workspace 画像が勝手に avatar になることはありません。
PublishedPost
| フィールド | 型・制約・意味 |
|---|---|
id, userId | id 主キー、userId owner FK。profileId を重複保存せず PublicProfile.userId で所属を決める。所有権移転なし。 |
source | owner-only の null または {workspaceId, notePath}。組で検証し、workspace ID は非空・64 文字以内、workspace の所有を確認。notePath は workspace 相対 .md、512 文字以内、絶対/./../空 segment/backslash 不可。source に UNIQUE を置かず同じ note の追加公開を許す。 |
slug | (userId, slug) UNIQUE。unpublished/archived でも予約。旧 slug alias とも衝突不可。 |
title, description | 公開専用 plain text、1–200 / 0–500 scalar。制御文字禁止。HTML title と OGP の default。 |
contentMarkdown, bodyHtml, documentHtml | 明示的に渡された Markdown とサーバーの検証済み静的出力。Markdown は owner snapshot のみ、public JSON は安全な body HTML を返す。private source を後から fetch しない。 |
presentation | 公開時に確定した reading layout/options/cover/sharing。現行 PublicationPresentation の bounded な契約を維持し、Publishing Theme と別に保存。raw CSS/HTML/script は不可。 |
themeSelection | profile default または明示指定を公開時にコピーした値。template revision は固定。profile default の変更に追従しない。次期 metadata をまだ持たない現行 post は null(既存 presentation を維持)。 |
visibility | public / unlisted。一覧での発見性。公開状態と password gate から独立。 |
state, archivedAt | published / unpublished / archived。現行 is_published と archived_at の一貫した projection。archived のみ時刻あり、archived は必ず非公開。 |
createdAt, publishedAt, updatedAt | 初回公開で作るため createdAt = publishedAt。publishedAt は初回公開日時で不変、updatedAt は post mutation 時刻。note の時刻は使わない。 |
contentRevision, publishRevision | 次期 metadata 未作成の contentRevision は null、作成時に 1。以後 contentRevision は公開 snapshot(本文・公開 metadata・presentation/theme・tag 集合)変更時のみ +1。publishRevision はその変更と状態・slug・source・password 変更時に +1。変更なしは両方不変。 |
contentHash, publicationFingerprint, rendererVersion | 本文 digest、公開結果に関わる確定入力の digest、HTML renderer の版。source、profile live 値、upload receipt は公開 fingerprint に入れない。 |
ogp | null または {renderHash, url, width, height, provenance}。provenance は templateId/revision、rendererVersion、font/asset digests、OGP に使った profile identity digest。詳しい render 契約は Publish OGP ADR。 |
brandingAtPublish | 公開当時の実効 policy revision と mode の記録。配信時の権限を与えるものではない。 |
passwordProtected | owner/public の安全な boolean。内部 verifier version/iterations/salt/verifier と gate epoch は別の server-only fields として現行の意味を保持。 |
source は public HTML/JSON、OGP fields、render adapter、URL、ログ、idempotency の安全な応答に含めません。workspace 削除で公開ページやその provenance を cascade 削除しない現行原則を維持します。source を検証できなくなったら legacy_unknown と同じく置換の証拠に使わず、owner が post ID と revision を明示して公開 snapshot を管理します。
Tag と PublishedPostTag
タグは profile ごとの語彙です。Tag {id, userId, key, createdAt, updatedAt} の主キーは UUID、UNIQUE(userId, key)。表示文字列と URL の tag 名は同じ key とし、大文字版などの別 display label は保存しません。PublishedPostTag {userId, postId, tagId, createdAt} は (postId, tagId) 主キー。親側にも UNIQUE(userId, id) を置き、(userId, postId) と (userId, tagId) の複合 FK で別 owner の tag を結べないようにします。順序は key の UTF-8 byte 昇順、階層・カテゴリ・ランキングなし。
索引は post の (userId, state, visibility, publishedAt DESC, id DESC)(実装時は state の元列に対応)、source の (userId, sourceWorkspaceId, sourceNotePath)、tag join の (tagId, postId)、owner archive の (userId, archivedAt, id) を用意します。公開一覧は必ず profile active・published・非 archived・public・password なしを predicate に含めます。集計 tag はこの predicate を満たす post にだけ由来し、非公開 tag の存在や件数を漏らしません。
PublishingThemeSelection
type PublishingThemeSelection = {
formatVersion: 1;
templateId: string; // サーバー catalog の ID
templateRevision: string; // immutable revision、"latest" は保存しない
values: {
accent?: string; // catalog の palette token
pattern?: string; // catalog の pattern token
font?: string; // 同梱 font token、URL/path 不可
subtitle?: string; // plain text、0–120 scalar
};
};未知の key、任意 field path、Composer JSON、remote font、raw color/CSS は拒否します。catalog は theme と OGP template の対応、許容 token、Free/Pro 資格を一つの immutable revision として定義します。未指定値はその版の default で解決して保存。自動更新される template revision はありません。HTML reading theme と pgen template を同じ ID だと仮定しません。
タグの正規化と変更
正規化は Unicode 15.1 の normalization・property 定義に固定し、サーバーと全 client で同じ fixture を共有します。実行環境の Unicode 版任せに文字集合を拡張せず、版更新は別の契約変更とします。順序を固定します。
Unicode NFKC を適用。
Unicode White_Space の連続を ASCII space 1 個に置換し、前後を除去。
ASCII
A–Zのみをa–zへ変換。locale 依存 lowercase / Unicode case-fold は行わない。各文字を Unicode Letter / Mark / Number、ASCII space、
-、_に制限。最低 1 個の Letter または Number を必要とし、先頭 Mark と制御・format・bidi 文字を拒否。結果が 1–32 Unicode scalar かつ UTF-8 128 bytes 以下であることを確認。1 post は正規化・重複除去後で最大 10 tag、入力配列自体も最大 10 個。空入力は除去せず 400。
日本語・漢字・かなを使えます。NFKC により全角英数字、半角カナは統合します。ひらがなとカタカナ、漢字の異体字は統合しません。非 ASCII の大文字小文字は別物(É と é は異なる)。これを曖昧な「case insensitive」と記述しません。
| 入力 | 保存 key / 結果 |
|---|---|
AI 設計 | ai 設計 |
React, react | 同じ react に重複除去 |
カナ, カナ | 同じ カナ |
設計/実装, #開発, emoji のみ | 禁止文字のため 400 invalid_tag |
URL は正規化した key を UTF-8 percent encoding した 1 segment、space は %20 とします。例 /。一度だけ decode し、壊れた UTF-8、encoded slash/backslash、二重 decode を必要とする入力は 400。妥当な非 canonical 表現(大文字 React 等)は正規形へ 308。ただし公開対象 tag が存在する場合だけ redirect し、非公開 tag は 404 です。
初期契約には一括 tag rename API を設けません。「rename」は明示した post の metadata 更新で旧 key を外し新 key を付ける操作です。react → frontend は既存の frontend Tag があればそれへ結び、他 post は変えません。旧 tag URL は対象が残ればその一覧、0 件なら 404。tag alias は作りません。最後の参照が消えた Tag は GC できますが UUID を public URL の契約にしません。
URL・衝突・rename
canonical の決定
| 用途 | HTML | public JSON(次期) |
|---|---|---|
| profile | / | / |
| 時系列一覧 | profile ページの一覧、続きは ?cursor=… | / |
| tag 一覧 | / | 同じ posts route の ?tag=:tagKey |
| single post | / | / |
初期 canonical は現行 / を維持します。/ は読みやすい一方、第二の route と cache/SEO の重複を増やすため初期実装には入れません。後で導入する場合は / への 308 alias だけとし、HTML canonical、og:url、JSON の canonicalUrl は / のままにします。URL の末尾 slash は取り除く canonical へ 308、未知の post/profile は redirect 前に 404 とします。
username/slug の文字集合・長さ・連続 hyphen の差は現行 validation を維持します。大文字や全角からの暗黙の変更はせず 400。現行 username 予約語に u, p, og, assets, tag, tags, posts を追加し、slug の予約語は tag, tags, posts とします。すでに有効な route がこの追加予約語を使っている場合は保持し、新規取得・rename のみ拒否します。/ という既存 post と / は segment 数が違うため共存できます。
username は全体で一意、slug は owner 内で一意。競合は SQL UNIQUE と alias namespace の予約を同じ transaction で検査し、409 username_taken / slug_taken。ランダム suffix を勝手に採用しません。候補 hello-2 を返しても利用者の新しい要求が必要です。別 owner の衝突情報には owner ID/profile 情報を返しません。
今後の rename だけを記録する
リンク継続の価値があるため、契約導入後の username/slug rename に限り forward-only alias を採用します。
username claim は
username → userId、slug claim は(userId, slug) → postId。current と alias は同じ一意 namespace で、alias も取得済みとして保持します。再取得や他 account/post への再割当は禁止。username rename は
expectedProfileRevision、slug rename はexpectedRevisionと idempotency key を必要とします。保存成功と旧 claim の alias 化を原子的に行います。alias は別 alias の文字列を参照せず stable owner/post ID を参照し、現在の username/slug へ直接 308。
aki/hello→aki/hello-world→aki-notes/hello-worldでも 1 hop です。旧 username と旧 slug の組み合わせも同じ ID に解決します。rename 後の新旧どちらの URL でも、まず active/published/archive/password/visibility を検査します。停止した target は 404 private/no-store、保護された target は未認証時に generic challenge/401 とし canonical を教える redirect を出しません。unlock 後は現在の canonical へ 303。password POST 自体を 308 で転送しません。
永久削除した post の slug claim は target のない予約 tombstone として残し、公開応答は 404。title/source/content は tombstone に残さず、alias の再利用によるリンク乗っ取りを防ぎます。account 削除時の username 予約も同じ方針です。
例: profile aki、post hello-world を first-post に rename すると / は / へ 308。過去に保存しなかった旧 username/slug は復元できません。 note title、現在の slug、ログから推測して alias を作らず、未知の旧 URL は 404 のままです。
次期 owner API
全 owner route は / にそろえ、Better Auth bearer JWT から userId を解決します。API version を変えるのは lifecycle にも CAS と idempotency を要求し応答を統一するためで、既存 v1 がこれを実装しているという意味ではありません。Tauri/web/iOS は同じ bridge DTO と validation fixture を使います。
| Method / route | 入力・成功応答 |
|---|---|
GET /profile、PUT /profile | read は {profile, capabilities}。create は expectedProfileRevision: 0、update は現在の版と profile fields。201 / 200。 |
POST /profile/deactivate、POST /profile/reactivate | profile revision を検査し、200。reactivate は acknowledgePublishedPosts: true 必須。 |
POST /posts | 明示 snapshot、slug、tags、visibility、presentation/theme、任意 source と初回 protection。201。source 候補や slug conflict は 409 decision。 |
POST /posts/additional | intent: "create_additional" と検証済み source、新しい slug と snapshot、初回 protection。201。 |
POST /posts/:id/replace | intent: "replace", expectedRevision, 同一 source と snapshot。URL・publishedAt・gate を維持。200。 |
PUT /posts/:id/snapshot | ID/revision を指定した snapshot 更新。source 一致を推測しない owner 管理用。公開停止中を勝手に publish せず、archived は 409。200。 |
PATCH /posts/:id/metadata | title/description/tags/presentation/theme の指定した field だけを確定。既存の公開 Markdown を使い、現在の note を読み直さない。200。 |
POST /posts/:id/rename | revision と新しい slug。200。本文を取り込まない。 |
PATCH /posts/:id/source | revision と新しい検証済み source または null。owner の明示的な関連付け変更だけを許す。200。本文・公開状態は不変。 |
POST /posts/:id/publish、…/unpublish、…/archive、…/restore | revision と intent。publish は保存済み snapshot を明示的に公開。200。 |
PATCH /posts/:id/visibility | revision と public/unlisted。本文を取り込まない。200。 |
PUT /posts/:id/password、DELETE /posts/:id/password | revision と password(PUT のみ)。epoch を更新、200。 |
DELETE /posts/:id | revision、archived 必須、{deleted: true, id}。200。 |
POST / | 最大 100 件の明示 {id, expectedRevision} 配列。全件の ownership/archive/revision を transaction 内で検査し、失敗時は全体を 409。200 {deleted: number}。未指定の新しい archive を巻き込まない。 |
GET /posts、GET /posts/:id、GET /posts/:id/snapshot | owner 一覧/metadata/機密 snapshot を分離。全状態を扱う。snapshot 以外に本文・verifier を載せない。 |
POST /publication-assets と owner preview の GET /posts/:id/assets/:role/:sha256 も v2 に対応させ、現行の digest/role/ownership 宣言を維持します。avatar の upload は POST /profile/assets として本文 asset と分離します。所有者への成功応答は private/no-store です。
型と操作例
以下の PublicationPresentation と PublicationAssetDeclaration は現行 renderer / Worker 契約を import する型です。OwnerPost は上の PublishedPost から本文・verifier・source・ogp 内部 provenance を除き revision を残した安全な mutation projection。source は owner の GET metadata/snapshot にだけ追加し、replay ledger と mutation 応答には含めません。public DTO とも共用しません。
type PublishSnapshot = {
title: string;
description: string;
contentMarkdown: string;
tags: string[];
presentation: PublicationPresentation;
themeSelection: PublishingThemeSelection;
assets: PublicationAssetDeclaration[];
};
type CreatePostRequest = PublishSnapshot & {
slug: string;
visibility: "public" | "unlisted";
protection: { kind: "none" } | { kind: "password"; password: string };
source: { workspaceId: string; notePath: string } | null;
};
type ReplacePostRequest = PublishSnapshot & {
intent: "replace";
expectedRevision: number;
source: { workspaceId: string; notePath: string };
};
type MutationResponse = {
contractVersion: 2;
changed: boolean;
post: OwnerPost;
};
type MutationError = {
contractVersion: 2;
error: string;
field?: string;
currentRevision?: number; // 所有者にだけ返す
requestId: string;
};新規 POST / は Authorization: Bearer … と Idempotency-Key: publish-first-0001 を送り、たとえば {slug: "first-post", title: "最初の投稿", description: "設計メモ", contentMarkdown: "# 最初の投稿\n\n本文", tags: ["AI", "設計"], visibility: "unlisted", protection: {kind: "none"}, source: null, presentation: defaultPresentation(), themeSelection: resolvedFreeTheme, assets: []} を指定します。defaultPresentation() と resolvedFreeTheme は説明上の値で、wire JSON には解決済み object を送ります。
201 の post は state: "published", visibility: "unlisted", publishRevision: 1, contentRevision: 1, 正規化 tag ai / 設計, canonical URL を持ちます。同じ key の再送は同じ 201 を返します。title を変える PATCH は expectedRevision: 1 と新しい key を送り、成功時は両 revision が 2。古い revision 1 の別要求は 409 {contractVersion: 2, error: "publish_decision_stale", currentRevision: 2, requestId: "…"} で止めます。
revision・retry・エラー
すべての owner mutation(profile、lifecycle、password、delete を含む)は Idempotency-Key header を必須とし、現行の 8–128 ASCII key 制約を維持します。ledger の主キーは (userId, key)、fingerprint は契約版・method/route・target・expected revision・全意味的入力から計算します。key を body と header の両方には置きません。password は平文や単純 hash を ledger に残さず、専用 server secret による HMAC を request fingerprint の入力にします。本文/source は fingerprint 計算のみで使い ledger 応答には残しません。
同一 key・同一要求は保存済みの安全な応答と status を replay、異なる要求は 409 publish_idempotency_conflict。ledger は完了から 30 日保持し、それを超えた retry の exactly-once は保証しません。切断した client は owner read で状態を確認して新しい intent を作ります。pending は同じ key で再開し、処理中なら 409 publish_in_progress と Retry-After。render などの一時失敗は snapshot を commit せず、同じ key で再試行可能です。
JWT と ownership は replay 時にも確認します。成功済み要求の replay は新しい公開を起こさず、途中の downgrade でも元の安全な応答を返せます。現在の表示と資格は owner read で取り直します。新規/pending mutation は commit 時点の plan と profile revision、post revision、source ownership を再検証し、render 中の変更は 409 で止めます。no-op は revision が一致して初めて判定し、stale な同一入力を成功にしません。
| Status | 契約 |
|---|---|
| 400 | invalid_request, invalid_tag, invalid_username, invalid_slug, invalid_cursor。unknown field/type/limits も拒否。 |
| 401 | bearer 不在/無効。public gate は password_required。 |
| 403 | capability_required, workspace_not_owned。他人の source 内容や ID を応答で追加しない。 |
| 404 | post/profile がない、または自分の対象でない。公開側は inactive/unpublished/archived も同じ not-found。 |
| 409 | publish_decision_required, publish_decision_stale, publish_idempotency_conflict, username_taken, slug_taken, page_archived, page_not_archived, invalid_state, asset_upload_expired。decision は owner の安全な候補のみ。 |
| 413 / 422 | サイズ超過 / publication_blocked と診断。保存を行わない。 |
| 429 / 503 | rate limit / 一時的な gate・render・依存サービス障害。Retry-After を返す。 |
source 候補が複数なら選択を要求します。追加公開は新しい slug、replace は exact-source の選んだ ID を使います。source 不明・所有喪失を slug 一致で補いません。profile の theme/branding 変更は PUT の一部として資格を検証します。プロフィールの username 変更もこの PUT に含め、旧 claim の alias 化を同じ CAS transaction で行います。全 mutation の CAS 化、profile revision、統一 error shape は現行からの変更提案です。
public read 契約
type PublicProfileView = {
username: string; displayName: string; bio: string;
avatarUrl: string | null;
links: Array<{ label: string; href: string }>;
canonicalUrl: string;
};
type PublicPostSummary = {
id: string; slug: string; title: string; description: string;
tags: string[]; publishedAt: string; updatedAt: string;
canonicalUrl: string; ogImageUrl: string | null;
};
type PublicTimeline = {
contractVersion: 2;
profile: PublicProfileView;
items: PublicPostSummary[];
nextCursor: string | null;
};
type PublicSinglePost = {
contractVersion: 2;
profile: PublicProfileView;
post: PublicPostSummary & { bodyHtml: string; passwordProtected: boolean };
};profile lookup は {contractVersion: 2, profile}。存在して active なら投稿 0 件でも 200。timeline は GET /(実際の送信では query を percent encode)。limit は整数 1–50、default 20、tag は 1 個だけで AND/OR 配列を受け付けません。未知の tag は JSON では空の 200、HTML tag route は 404。total を返さず、private 件数を推測させません。
一覧は publishedAt DESC, id DESC の不変な first-publication 順。更新・republish で上へ移動しません。cursor はサーバー署名付き opaque base64url、最大 2048 bytes、内容は version、owner scope token(server HMAC で userId から導出し元 ID は含めない)、正規 tag、limit、最初の query の上限 tuple、最後に返した tuple、24 時間の期限。改ざん・期限切れ・別 profile/filter/limit への流用は 400 invalid_cursor。次ページは上限以下かつ last tuple 未満を keyset query し、毎回現在の可視性を再評価します。
これは時点固定の snapshot transaction ではありません。途中の unpublish/タグ変更で抜けることはありますが、先頭への新規投稿による offset のずれや同じ post の重複は防ぎます。途中で古い post を公開・tag 追加した場合、未走査範囲なら現れる可能性があります。最初から一覧を更新すると新しい先頭を得ます。
single post は公開中の public/unlisted を直接 lookup でき、password 付きは capability がなければ本文を読まず 401。HTML は安全な challenge、JSON は {error: "password_required"} のみ。gate 構成が壊れていれば 503 で閉じ、保護を外して配信しません。unknown route は 404。source/workspace/userId/Markdown/verifier/internal theme config はどの public projection にも含めません。owner 一覧も limit=20(1–100)と署名 cursor を用い、状態 filter と (createdAt DESC, id DESC) に固定して、public cursor と別の purpose で署名します。
公開状態・保護・操作の遷移
「draft」は private note または client の未確定編集です。公開前に post record を自動生成しません。公開後の unpublished は、owner だけが読める保存済み公開 snapshot です。
| 開始状態 → 操作 → 終了状態 | snapshot・時刻・版・公開への影響 |
|---|---|
| draft → publish → published | 明示 snapshot を確定、ID と initial publishedAt を作り revision は 1。profile は別途明示作成が必要。 |
| published → note 編集 → published | note だけが変わり、公開値・revision・OGP は不変。 |
| published → snapshot update / metadata update → published | 指定した公開値を確定。本文の取り込みは snapshot update のみ。変更時に contentRevision/publishRevision +1、updatedAt 更新。 |
| published → unpublish → unpublished | URL/asset を閉じ、snapshot と publishedAt を保持。publishRevision +1。 |
| unpublished → snapshot/metadata update → unpublished | 新しい保存 snapshot を owner だけに確定し、勝手に公開しない。 |
| unpublished → publish → published | 保存済み snapshot の明示 republish。publishedAt 不変、publishRevision +1。現在の note を読まない。 |
| published/unpublished → archive → archived | 非公開化と archivedAt を原子的に保存。snapshot/password を保持。publishRevision +1。 |
| archived → restore → unpublished | archivedAt を消し、必ず非公開。publishRevision +1。 |
| archived → publish/update/replace | 409 page_archived。まず restore、次に明示 publish。現行 replace の reactivate 経路をこの二段階へ制限する提案。 |
| archived → permanent delete → record なし | CAS + archived 条件でのみ削除。tag join、page asset、OGP association を除去し、孤立 bytes を GC。URL claim のみ内容なしで予約。 |
| published → visibility/password/slug 変更 → published | 明示操作だけで変更。publishRevision +1、本文・contentRevision 不変。gate 変更は epoch も +1。 |
| 任意 → profile の表示名/bio/link/avatar 編集 → 同じ状態 | live profile を更新。post contentRevision/publishRevision は不変。profileRevision +1。 |
同じ state への unpublish/archive/restore などの無変更要求は revision が一致すれば 200 changed: false。restore を published に対して送るのは 409 invalid_state とし、意図せず公開停止しません。replace は URL と初回日時と password を維持し、unpublished の replace もその状態を保ちます。公開再開は publish に分離します。
| 条件 | 直接アクセス | profile/timeline/tag/sitemap | robots / cache |
|---|---|---|---|
| public・公開中・password なし | 誰でも 200 | 掲載 | index 可 |
| unlisted・公開中・password なし | URL を知る人は 200、認証ではない | 掲載しない。タグ件数にも含めない | noindex,nofollow、private/no-store |
| public または unlisted・password あり | capability 後のみ本文/asset、未認証は challenge/401 | 一切掲載しない | noindex,nofollow、private/no-store、Vary: Cookie |
| unpublished/archived/inactive profile | 404、owner preview のみ別途可能 | 一切掲載しない | private/no-store |
初回 create/additional の protection: {kind: "password", password: "…"} は verifier と snapshot を同一 commit で確定し、公開後に別要求で gate を付けるまでの露出を作りません。通常の snapshot/metadata 更新は protection/visibility を受け付けず、既存 gate と visibility を保存します。password は将来へ先送りせず現行の verifier、bounded unlock、rate limit、page ID/host/epoch に結び付く capability、epoch rotation を保持します。title や canonical を challenge で出す現行面も、次期は未認証の generic 表示へ絞ります。password を削除すれば visibility が public の post は一覧に戻るため、削除は明示的な公開範囲変更です。
本文 asset、cover、share、生成 OGP は page ID への association と現在の profile/page/gate で配信を認可します。R2 object の hash を知るだけで読める公開 bucket は使いません。owner preview は JWT・private/no-store。保護ページは認証後も og:image を出さず、unauthenticated crawler 用の title/description/card も generic にします。unlisted は共有を目的とするため直接ページに通常の OGP を載せますが検索登録しません。
現行の public HTML/asset は共有 cache が最長 600 秒残る制約があります。次期で即時遮断を契約にするなら、HTML・JSON・redirect・画像の全経路で認可前の共有 cache を使わず、route gate 後に bytes cache を読む必要があります。初期次期契約は public 応答も Cache-Control: no-store とし、render 結果の内部再利用だけを許可します。後続 cache 設計で gate を迂回しないことを証明してから緩和します。すでに第三者へ配信済みの bytes や social cache は撤回できません。
live 値、公開 snapshot、OGP の版
snapshot: Markdown/body、title/description、tag 集合、presentation、post の解決済み theme。明示 publish/update でのみ確定。
live: username/canonical、display name、bio/avatar/links、実効 branding、active 状態。profile/plan の変更で公開済み本文を再取得しない。
別の派生 artifact: OGP。公開が許された fields だけを入力とし、source や workspace API credential を render adapter に渡さない。v1 pgen は image asset を拒否するため avatar は描画入力にしない。検証済み public avatar digest の使用は将来 adapter 拡張時の契約。
OGP の identity は theme/template revision、正規化した実際の fields、renderer/font/asset digests に依存します。title、使用される subtitle、使用される author display name/avatar、theme の変更は新 hash/URL が必要。bio や link が template の入力でなければ、その変更で生成しません。username は初期 OGP の text 入力に含めず rename は URL metadata だけを更新。tag も初期 OGP 入力に含めないので tag 変更だけなら再利用します。body 更新だけで見出し用 fields が同じなら OGP 再利用可能です。
profile identity 変更は public/unlisted の公開中 post だけに派生 OGP 更新を enqueue できます。新 artifact 完了までは既存 OGP を保持し、live HTML の名前と一時的に異なることを許容します。古い job は expected profile identity digest/post contentRevision で CAS し、新しい画像を上書きしません。この profile 起因の job は protected/unpublished/archived の private snapshot を材料にしません。再公開時に現在の入力 digest を評価します。普通の profile theme default 変更は既存 post の theme に影響せず、既存 OGP も作り直しません。
canonical・og:url は現在の route、title/description と og:title/og:description は確定 metadata、author は現在の public profile を使います。生成画像があれば og:image/寸法/alt と twitter:card=summary_large_image、なければ本文と矛盾しない generic fallback card を返します。Free にも同じ metadata 品質と自動 OGP を提供します。OGP の失敗時の初回公開処理や adapter の timeout/retry 詳細は Publish OGP ADR が定めますが、失敗で private note を別経路から fetch したり、確認していない snapshot を公開したりしてはいけません。
server-authoritative Free / Pro
Free に publish/create/update と asset upload を開放するのは現行 Pro-only writes からの変更提案です。client のボタン表示を資格の根拠にしません。server は認証済み account と subscription を解決し、全 mutation の commit 時と public branding の配信時に capability policy を適用します。
| 能力 | Free / expired / downgrade 後 | Pro active / 有効 trial |
|---|---|---|
| profile、public/unlisted publish/update、標準 OGP | 許可 | 許可 |
| password enable/rotate/remove、unpublish、archive/restore、削除、owner read | 許可 | 許可 |
| theme | catalog の Free template/revision、すべて default 値 | catalog の Pro を含む template/revision |
| semantic override | 禁止。default と同じ canonical 値は許可 | palette/pattern/font token、subtitle のみ |
| footer branding | 必須 Published with zudo-text + 固定 official HTTPS link | attribution / none / bounded CTA |
| custom domain | 不可 | 将来 hook のみ。初期機能は disabled |
| 安全・resource 上限 | 同じ validation/asset bounds/rate limit | 同じ validation/asset bounds/rate limit |
次期 policy では plan=pro && status=active、または plan=pro && status=trial && now < trialEnd + 3 days を Pro とします。有効な trialEnd がない・不正な trial date は Free に倒します(現行では欠損/不正日時で trial expiry check を通る点との差)。期限切れ、解約後 inactive、free plan は Free。subscription が読めない障害は新しい公開・有料操作を 503 とし、安全な read/公開停止・削除を課金障害で妨げません。public branding は資格不明なら Free attribution へ倒します。developer allowlist は server secret の運用例外として残し、client や public profile にその資格を保存しません。
downgrade で記事を削除・公開停止せず、password も外しません。公開済み Pro template と bounded override はその snapshot の見た目として維持しますが、公開 footer は直ちに Free attribution へ置き換えます。Free の本文/metadata 更新は既存 theme の完全一致を保持する場合に限り許可し、新規 template、override 変更、追加 post へのコピーは Free catalog に限定します。theme を変えるなら Free default へ明示的に切り替えます。これにより downgrade が記事の訂正や保護操作を阻害しません。
footer は body HTML に埋めた広告ではなく、server-owned document shell の独立した構造です。template や article CSS が隠せる任意 slot にせず、layout/reading theme 全組合せで必ず描画される要素にします。Free は client が新たに none/CTA や非 default override を要求すれば 403(既存 Pro theme の完全一致維持は前述の例外)、server が勝手に許可したことにしません。Pro CTA は text と HTTPS link だけで script、tracking pixel、任意 HTML/CSS を持てません。OGP 内には plan ごとに外す branding を入れず、plan 変更だけで OGP 全件を再生成しない方針です。
将来 domain hook は owner/domain verification・active entitlement・domain revision を持つ専用 binding とします。profile の任意 URL を canonical origin に使わず、検証済み origin registry だけを参照します。初期 canonical はサービスの /、domain 対応や downgrade 時の redirect は別 ADR と acceptance ができるまで有効化しません。
既存状態からの契約切替
CLAUDE.md の pre-release 方針に従い、旧 format/settings/persisted state の migration/backfill、歴史的データを推測する修復コード、長期の v1/v2 dual-write は実装しません。新しい schema の定義と producer/consumer は一つの coherent version として切り替えます。
その上で「有効な既存 public link は可能な範囲で保つ」を次のように満たします。
| 対象 | 決定 |
|---|---|
| 現在存在する profile/post の route | /、username、slug、page ID、公開状態、gate、現在の snapshot をそのまま残す構成を選ぶ。ドメイン名 PublishedPost のための table rename/copy は不要。 |
| 新しい tags/theme/OGP 情報 | 新しい関連 table の record が無いことを「tag なし、次期 theme 未選択、生成 OGP なし、visibility=public」と定義。既存 password gate と公開停止状態を必ず先に適用するため可視性は広がらない。既存 content/presentation は現行保存形式のまま使う。過去行を書き換える backfill は行わず、次の明示 update で次期選択を保存。 |
| version の開始 | new contentRevision は補助 metadata record 作成時に 1。profile の新補助 record が無い時の profileRevision は 1 と読み、最初の変更では元行の変更と補助 record の挿入を一つの CAS transaction にする。既存 publishRevision をリセットしない。現行 row の createdAt は初回公開時刻として定義し、失われた timestamp を捏造しない。 |
| v1 owner API/client | producer/consumer を同時更新。v1 mutation の互換 adapter は新設しない。旧 client は契約不一致を明示し更新を要求。公開 HTML URL と owner API version は別契約。 |
| 旧 public JSON consumers | v2 を正式契約に切替。旧 JSON format の永続互換を約束しない。共有済み HTML URL の維持と混同しない。 |
| 旧 rename 履歴 | 導入前の履歴は未知。alias/backfill なし、導入後の成功した rename だけ記録。 |
| private note・unpublished・archived・inactive | 自動取り込み、自動公開、profile 再有効化はしない。restore も非公開。 |
| 既存共有画像 / OGP | 明示設定された share asset を維持。導入だけで自動 OGP 全件生成はしない。新しい自動 OGP を選ぶ publish/update または使用 field 変更時だけ必要な hash を生成。 |
新しい関連 table と版を定義することは、過去 format の変換器を書くことではありません。実装では現行 row を読める現在の公開 renderer を保ち、欠けた次期関連 record を上記の正常状態として扱います。現行 content_markdown が null の post は明示的な新 snapshot 入力なしに本文を再生成しません。metadata 編集で再生成が必要なら 409 snapshot_required とし、private note から自動補完しません。過去 document_html 欠落に対する既存の互換経路は現行説明どおりで、新たな歴史的形式の判定分岐は増やしません。データ再作成を選ぶ pre-release 環境では失われた row の route は維持できないため、事前にその制約を明記します。本 ADR は自動 reset/delete を指示しません。
リスク・却下案・実装 PR の確認
主要なリスクは、alias namespace の競合、profile/plan 変更中の render、unlisted/password の一覧漏れ、cache が認可を迂回することです。Free publishing の abuse/cost を抑えるため現行 asset 上限を維持し、追加する publish/render quota は Publish OGP ADR の初期値を server 側に適用します。trial grace、downgrade 時の既存 theme 維持、tag 正規化は本 ADR の product assumption であり、変更時は版と共通 fixture を更新します。
却下した案は、note 保存に追従する自動公開、frontmatter の固定 key をエディタに導入、slug 一致による自動置換、未記録の旧 URL 推測、初期 canonical の / 切替、階層 tag、一括 tag rename、任意 pgen Composer state、本文内の attribution、downgrade による記事削除です。各案は snapshot の明示性、URL の安定性、schema の所有境界、または安全な停止/訂正操作を損ないます。
後続 implementation PR は次の判定を満たしてからこの ADR を「実装済み」に更新します。
初回 publish、同一 key replay、異なる要求の key 再利用、stale CAS、同一 fingerprint no-op、render 中の profile/plan 変更を Worker の実 DB 契約で検証する。
note 編集だけでは公開が変わらず、metadata-only 更新は最後の公開 Markdown を使う。公開停止中の update/replace/restore は公開しない。
tag の NFKC/ASCII case/日本語/禁止文字/byte limit/10 個制限、追加削除と URL encoding、owner をまたぐ join を検証する。
username/slug の同時取得、予約語、inactive/archived の予約、複数 rename の 1 hop、未記録旧名の 404、archive-delete と restore の競合を検証する。
public/unlisted/protected × active/inactive × 全 lifecycle を HTML/JSON/timeline/tag/sitemap/asset/OGP/alias の各経路で検証する。no-store と protected body fetch 前の gate も含む。
timeline の同時投稿、同時 unpublish、同時 tag 更新、同時刻の ID tie-break、cursor 改ざん/別 filter/期限切れを検証する。
Free/TRIAL grace/expired/downgrade の server matrix、既存 Pro theme の完全一致維持、構造上必須の attribution、Pro CTA の制限を検証する。
title/theme/使用 profile identity の変更で新 OGP identity、本文だけ・未使用 bio/link・tag・plan 変更では無駄な生成なし。古い job の CAS と protected 画像の非公開性を後続 render 契約と一緒に検証する。
bridge の同じ typed 契約が desktop/web/mobile で使われ、公開 API・render job・ログに source/verifier/private Markdown が出ない。preview と公開 renderer の安全境界を保つ。
ドキュメントは
pnpm --filter doc check。manager が docs build を行う場合は heavy guard 経由で実行し、broken-anchor warnings を失敗扱いにして生成ページを確認する。docs-only の変更で native build は不要。
実装分割の境界は、(1) schema/DTO と capability policy、(2) owner mutation/alias/CAS、(3) public query/visibility、(4) theme/OGP adapter と cache、(5) bridge/client 契約適用です。これは後続 issue 化の入力であり、このページだけで #7224 全体や UI acceptance を完了したことにはしません。
2026-10 amendments
採択した実装契約(2026-10-06、#7328 / #7329)。以下は承認済み UI に合わせた追記です。この追記と以前の提案・現行説明が矛盾する場合は追記を優先します。追記自体は runtime の実装・release・deploy の証拠ではありません。上書きの全体表と型の正本は Publish Next ADR、描画境界は Publish OGP ADR を参照してください。
過去の決定を上書きする範囲
| 決定 | 以前の提案・制限 | この epic の採択契約 |
|---|---|---|
| D1 | owner/public JSON を / に統一、rename alias・purge・ledger v2 を同時導入 | / と / を維持し、release 前の field 変更は破壊的に同時出荷。v2 と関連操作は下の延期リストへ。 |
| D1 / D2 | presentation の options.cover / focalPoint / sharing.image、旧 public JSON の pages | presentation formatVersion: 2 で三つの field を除去。thumbnail は post 所有、public timeline は ADR の items shape。 |
| D3 / D4 | post:<id> seed、利用者の seed refresh は対象外、単一 slice-1 | PictureSpec の client UUID と counter から seed を固定し、承認済み thumb-3x2 / ogp slices を使う。 |
| D3 / D5 | Free palette は default のみ、任意色は拒否 | Free は 2 catalog palettes。Pro の custom colors だけを bounded な semantic 入力として許可。raw CSS / template JSON は引き続き拒否。 |
| D4 / D5 | 標準 title card 中心の OGP 選択 | post の sharingCard に card / picture / picture-title と treatment、thumbnail 連動を保存。写真 + title は延期。 |
| D7 | profile は theme/branding の新規公開 default、snapshot replace は既存 gate を維持 | 公開設定一式を PublicProfile に保存。create/replace の visibility と任意 initial password を snapshot と同時 commit。既存 gate の解除・回転は明示操作を維持。 |
| D9 | 現行 public response は shared cache が最長 600 秒残る | 全 public response を authorization-before-cache と no-store に切替。内部 render reuse のみ cache 可。 |
| OGP commit | 通常 publish は常に PNG 成功を待つ | admission open の render は atomic commit の一部。closed は ogp.status: "none" と generic service card で成功。 |
| F gate | #7223 UI acceptance を待つ | 承認済み方向が #7328 に保存され、F の #7223 acceptance gate は満たした。 |
この表が上書きしない却下案はすべて維持します。note 保存による自動公開、frontmatter の固定 key、slug による自動置換、未記録 URL の推測、/ canonical、階層 tag、一括 tag rename、任意 pgen Composer state、本文内 attribution、downgrade による記事削除は導入しません。
D1: v1 namespace と snapshot の切替
owner の /、/、/ と public JSON の /、/ を維持します。profile HTML は /、tag HTML は /、post HTML は /。新規 capability read は GET /。v1 namespace は field の永続互換を約束する version ではなく、client/server を一つの coherent version として出荷します。
public JSON timeline は以前の PublicTimeline の contractVersion: 2, profile, items, nextCursor shape と署名 cursor / tag query 契約を採用し、pages を返しません。PublicPostSummary と single post の public DTO に thumbnailUrl: string | null を追加します。contractVersion: 2 は timeline DTO の契約版であり、延期した / route namespace とは別です。idempotency ledger v2 fingerprint rules はこの field の採用から推論して追加しません。
presentation の formatVersion: 2 は options.cover、cover の focalPoint、sharing.image を持ちません。旧 formatVersion: 1 row は保存済み document_html の配信を継続し、導入だけで自動 re-render/backfill しません。owner が旧 row を update すると 409 snapshot_required。新 composer から明示 snapshot と version 2 presentation を渡して republish するまでこの guard を解除しません。既存の停止・保護操作はこの guard で妨げません。
D2–D4: post thumbnail と sharing card
以下は post の保存契約です。PictureSpec は pattern thumbnail と sharing card の両方で同じ identity を使います。
type PictureSpec = {
pictureId: string; // client UUID、post ID の発行前から不変
templateId: string;
templateRevision: string; // immutable server registry revision
palette: string; // catalog token
customColors?: { colors: string[]; bg: string }; // save 時の immutable copy
seedCounter: number; // integer 0–999
};
type PostThumbnail =
| { kind: "pattern"; picture: PictureSpec }
| { kind: "asset"; sha256: string; width: number; height: number; pictureId: string }
| null;
type SharingCard = {
mode: "card" | "picture" | "picture-title";
treatment: "plain" | "outline" | "plate" | "band";
followThumbnail: boolean;
picture?: PictureSpec;
ogpAsset?: { sha256: string };
};PublishedPost.thumbnail は上の PostThumbnail、PublishedPost.sharingCard は SharingCard。post-level seedCounter は設けず、seed は常に pic:<pictureId>:<seedCounter>。preview が server post ID を得る前と公開後で picture の identity を変えません。
写真は client が一度だけ 3:2 の 1200×800 に crop し、一覧・article hero は同じ thumbnail を使います。写真 thumbnail には client-made 1200×630 derivative も用意します。asset roles は thumb と ogp。server は client 宣言でなく inspectImage の実寸で thumbnail 比率 1.5 ± 0.01 と sharing derivative 比率 1.905 ± 0.01 を検証します。任意の focal point を配信側で再 crop しません。
pattern は server registry の pgen template で、各版に thumb-3x2(1200×800)と ogp(1200×630)を持ちます。fields は Palette、Pro の Custom colors(最大 6 個の #rrggbb と background、双方を検証)。titled OGP slice は Title / Author / Text treatment も使います。Custom colors は profile の palette を参照し続けず、save 時の値を immutable copy として保存します。
followThumbnail: false かつ mode !== "card" では picture が必須。写真 thumbnail で mode === "picture" なら ogpAsset が必須で、上の client-made derivative を指します。写真 thumbnail の picture-title は受け付けません。card は標準 card、picture は文字なし、picture-title は pattern に title/author と treatment を載せます。legibility verdict は client の derived value であり保存しません。mode、treatment、連動設定、解決した PictureSpec / derivative digest を OGP identity / renderHash に含めます。
D5: server-authoritative Free / Pro
| 能力 | Free | Pro |
|---|---|---|
| reading theme | curated 4 themes | 全 themes |
| picture pattern / palette | 3 templates + 2 palettes | 全 catalog、検証済み custom colors |
| card mode | card, picture | card, picture, picture-title |
| override | theme default のみ | accent / pattern / font / subtitle |
| text treatment / unlinked card picture | 有料選択は不可 | treatments と thumbnail から独立した card picture |
| branding | attribution 必須 | attribution / own text / bounded CTA |
GET / で server の実効 policy を返します。有料 field の不正選択は 403 {error: "capability_required", field: "…"}。UI の表示を資格根拠にしません。own text も plain text として検証し、任意 HTML/CSS/script を許可しません。subscription、trial grace、downgrade 後の snapshot 維持、必須 server-owned footer、安全操作を課金障害で止めない方針は以前の ADR のままです。
D7: PublicProfile の公開設定
themeSelection, branding, customPalette, sharingCardDefault, thumbnailDefault を PublicProfile に保存します。default は新規公開時にコピーし、既存 post を暗黙に変更しません。Save as defaults はこの一組を保存し、named presets を作りません。profile 未作成の Settings は Create your public profile step を示します。
profile save は expectedProfileRevision を検査し、不一致は 409 profile_revision_stale。通常 save は is_active を決して変更せず、reactivation は公開済み post が再び見えることを確認した明示 action に限定します。create/replace は visibility: "public" | "unlisted" と任意 initial password を mutation に含め、最初の snapshot と protection を原子的に確定します。既存 password を省略で消さず、解除・回転は明示 action のままです。
D9: authorization-before-cache を今採用する
全 public HTML、JSON、/ asset、/、thumbnail 応答に Cache-Control: no-store と CDN-Cache-Control: no-store を付けます。unlisted と protected は X-Robots-Tag: noindex も必須。profile/post/state/role/password gate を検査した後だけ内部 bytes / render cache を読めます。owner preview と拒否応答も no-store を維持します。
後から no-store を付けても以前 shared cache に保存された応答は取り消せず、従前の最大 600 秒は visibility 変更後に漏れる時間になります。したがってこの epic で shared caching を止め、render の内部再利用だけを許可します。既に受信者や social service が保存した bytes の撤回保証はありません。
この epic からの延期
次の項目を後続 epic へ移します。このリストは実装済み・互換経路の追加を意味しません。
/route set。api/ v2 username/slug alias tables と、それらを使う 308 aliases。
POST /posts/:id/rename。POST /。posts/ archives/ purge idempotency ledger v2 fingerprint rules。
写真 thumbnail 上の
picture-title(photo + title)。pgen が image inputs を受け付けるまで延期。named presets。v1 は profile 上の一組の既定値だけを持ち、操作名は Save as defaults。