4本のアプリのストア掲載文を多言語で並べ直していたとき、フォルダを眺めたまま数分手が止まりました。日本語の説明文は自分で書いたもの、英語版はその日本語を Claude に訳させたもの、韓国語版とドイツ語版も同じ経路です。並んでいる限りはどれも同じ「私が用意したテキスト」でした。
そこへ、2026年8月2日以降にリリースされた Claude モデルの生成テキストには機械可読の透かしが埋め込まれる、という話が入ってきます。透かしはモデルの側で織り込まれ、コピー&ペーストで移動し、多少の編集でも残ることがある。つまり英語版の説明文には、私が意図するかどうかと無関係に「Claude を通った」という信号が乗っている可能性があります。
厄介なのは、この信号が「Claude が書いた」ではなく「Claude が処理した」を意味することです。翻訳も校正も要約も、すべて処理に含まれます。個人開発を続けてきた私自身の線引き — 中身は自分で決め、届けるための作業は道具に任せる — は、この一点で引き直しが必要になりました。
二つの印は、効き方がまるで逆
Anthropic が実装しているのは性質の異なる二つの仕組みです。片方は消えにくく、もう片方は簡単に消えます。
項目 テキストの透かし ファイルの来歴メタデータ
付与される場所 モデルの内部(出力そのもの) 生成ファイルに付随(C2PA 署名)
対象 すべての生成テキスト .svg / .png / .jpg など対応形式
コピー&ペースト 一緒に移動する 該当しない
編集への耐性 ある程度は残ることがある 再保存で消えることがある
意図せず残る側/消える側 残る側 消える側
短い断片 信号が足りず検出されないことがある 形式変換で失われる
この非対称が、実務での扱いを分けます。テキストは「気づかないうちに印が付いたまま出ていく」方向に倒れ、画像は「印を残したいのに剥がれる」方向に倒れる。どちらも既定の挙動に任せると、自分の意図とずれた結果になります。
Anthropic 自身も限界を明示していて、印が検出されても内容の出所を完全に確定するものではなく、逆に検出されないからといって AI を通していない証明にもならない、としています。前提として押さえておくと判断が楽になります。詳細は How Claude marks AI-generated content(Anthropic ヘルプセンター) に載っています。
「生成した」ではなく「通した」で線を引き直す
私がこれまで使っていた基準は「AI が作ったか、自分が作ったか」でした。作品にあたる部分は自分の手で作り、告知や運用は道具に任せる。区分としては明快です。
ところが透かしの基準は「生成」ではなく「処理」です。自分で書いた日本語を Claude に英訳させれば、英語版は生成テキストです。自分で書いた英文を Claude に校正させても、返ってくる文は生成テキストです。書いたのは自分でも、出ていく成果物は Claude の出力になります。
ここが今回いちばん予想と違った点でした。AI で作らない方針を持っている人ほど、翻訳と校正で無自覚に印を乗せやすい のです。生成には慎重でも、翻訳や校正は「自分の文の手直し」という感覚で通してしまうためです。
そこで基準を「作ったのは誰か」から「Claude を通ったか」へ変えました。実務上の判定はこの一言に収まります。
その文字列は、一度でも Claude の応答として返ってきたものか。
戻り値をそのまま採用したなら、たとえ元が自分の文でも「通った」側です。応答を読んだうえで自分の言葉で書き直したなら「通っていない」側に置けます。線が引きやすく、後から検証もできる基準になりました。
配信物を四つに仕分ける
基準を決めたら、次は配信物ごとの方針です。個人開発で外に出るものを四つに分け、それぞれ扱いを固定しました。
区分 該当するもの Claude を通すか 理由
作品にあたる文 アプリ内の情緒的な文言、テーマ解説、作品説明 通さない(翻訳・校正も含む) 体験の中心。人の手で決める部分を外に出さない
掲載情報 ストアの説明文、サブタイトル、キーワード 通してよい(記録は必須) 文字数制限と多言語の作業量が大きく、道具の効きが良い
応答文 レビュー返信、問い合わせへの返答 下書きのみ。送信前に自分の言葉へ 相手が実在する。定型の返信は相手に伝わる
運用の文 更新告知、リリースノート、SNS 投稿 通してよい 事実の伝達が主目的。速度が価値になる
この表で重要なのは、右端の理由列です。「通す/通さない」だけを決めると、半年後の自分が理由を思い出せず、忙しい日に境界が動きます。実際、レビュー返信を1セッションで30件以上さばく日は、下書きをそのまま送りたい誘惑が強くなります。理由を1行添えておくと、その誘惑に対して自分で反論できます。
ストア掲載文の多言語化そのものの進め方は、以前に書いた Claude API でApp Storeの多言語ストア掲載情報を文字数制限つきで生成する で扱っています。ここでは、その成果物を「どう記録して出すか」の側を扱います。
画像側は、書き出しのどこで消えるのかを確かめた
来歴メタデータは C2PA という標準に沿って署名付きで付与されます。改ざんの検出もできる、しっかりした仕組みです。ただし個人開発のスクリーンショット生成では、その前提が成り立たない場面が多くあります。
ストア提出用の画像は、たいてい元画像そのままではありません。解像度ごとにリサイズし、端末フレームに合成し、容量のために形式を変える。この各工程でメタデータが生き残るかを確認しました。Linux 上で C2PA 相当のカスタムメタデータを PNG に埋め、代表的な書き出し処理を通した結果です。
from PIL import Image, PngImagePlugin
import os
# 1. 来歴メタデータ相当のテキストチャンクを埋めた元画像を用意する
img = Image.new( "RGB" , ( 1290 , 2796 ), ( 30 , 40 , 60 ))
meta = PngImagePlugin.PngInfo()
meta.add_text( "c2pa" , '{"claim_generator":"Claude","signature":"..."}' )
meta.add_itxt( "XML:com.adobe.xmp" , "<x:xmpmeta><c2pa:manifest/></x:xmpmeta>" )
img.save( "source.png" , pnginfo = meta)
def probe (path):
"""保存後にどのメタデータキーが残っているかを見る"""
info = Image.open(path).info
return sorted (k for k in info if k not in ( "jfif" , "jfif_density" , "jfif_unit" , "jfif_version" ))
# 2-a. 解像度振り分けのためのリサイズ再保存
Image.open( "source.png" ).resize(( 1242 , 2688 )).save( "resized.png" )
# 2-b. 容量削減のための JPEG 変換
Image.open( "source.png" ).convert( "RGB" ).save( "converted.jpg" , quality = 90 )
# 2-c. 端末フレームへの合成
canvas = Image.new( "RGB" , ( 1290 , 2796 ), ( 255 , 255 , 255 ))
canvas.paste(Image.open( "source.png" ).resize(( 1100 , 2382 )), ( 95 , 207 ))
canvas.save( "composited.png" )
# 2-d. メタデータを明示的に引き継いだ再保存
src = Image.open( "source.png" )
keep = PngImagePlugin.PngInfo()
for key, value in src.info.items():
if isinstance (value, str ):
keep.add_text(key, value)
src.resize(( 1242 , 2688 )).save( "resized_keep.png" , pnginfo = keep)
for name in ( "source.png" , "resized.png" , "converted.jpg" , "composited.png" , "resized_keep.png" ):
print ( f " { name :18 } { probe(name) } " )
手元で実行した結果は次のとおりでした。
source.png ['XML:com.adobe.xmp', 'c2pa', 'xmp']
resized.png []
converted.jpg []
composited.png []
resized_keep.png ['XML:com.adobe.xmp', 'c2pa', 'xmp']
リサイズ、形式変換、合成のいずれも、通した時点でメタデータは残りませんでした。残ったのは、引き継ぎを明示的に書いた最後のケースだけです。これは Pillow の仕様というより、画像処理ライブラリ全般に共通する既定の振る舞いです。読み込み時に取得したメタデータを、保存時に書き戻す指示がなければ、新しいファイルには何も付きません。
実務上の帰結は二つあります。第一に、スクリーンショット生成パイプラインを一度でも通した画像は、来歴メタデータを持たないものとして扱うのが安全です。第二に、逆向きの推論も成り立ちません。メタデータが無いことは、AI を通していないことの証明にはならない のです。剥がれた画像と、最初から人の手で作った画像は、ファイルを見る限り区別がつきません。
だから「印が付いているかどうか」を自分の管理手段にはできません。管理は、印とは別に自分で持つ必要があります。
出所台帳を、提出ゲートとして持つ
そこで作ったのが出所台帳です。仕組みは単純で、配信物として出るファイルを列挙し、それぞれの出所を宣言し、宣言と実物のハッシュが食い違ったら提出を止めます。
#!/usr/bin/env python3
"""配信物の出所ゲート。未宣言と human-only の無断改変を落とす。"""
import hashlib
import json
import sys
from pathlib import Path
SHIP_ROOT = Path( "ship" ) # ストアに出る成果物だけを入れる
LEDGER = Path( "provenance.json" ) # 出所台帳。人がレビューする唯一の場所
TARGET_SUFFIXES = { ".txt" , ".strings" , ".xml" }
ORIGINS = { "human-only" , "claude-assisted" , "claude-generated" }
def sha256 (path: Path) -> str :
return hashlib.sha256(path.read_bytes()).hexdigest()
def load_ledger () -> dict :
if not LEDGER .exists():
return {}
return json.loads( LEDGER .read_text( encoding = "utf-8" ))
def main () -> int :
ledger = load_ledger()
failures, notices = [], []
for path in sorted ( SHIP_ROOT .rglob( "*" )):
if not path.is_file() or path.suffix not in TARGET_SUFFIXES :
continue
key = path.as_posix()
entry = ledger.get(key)
if entry is None :
failures.append( f "UNDECLARED { key } 出所が台帳にありません" )
continue
if entry.get( "origin" ) not in ORIGINS :
failures.append( f "BAD_ORIGIN { key } origin= { entry.get( 'origin' ) !r } " )
continue
digest = sha256(path)
if entry.get( "sha256" ) == digest:
continue
# human-only は「Claude を通していない」ことの宣言。中身が変わったら宣言し直す
if entry[ "origin" ] == "human-only" :
failures.append(
f "REDECLARE { key } human-only 宣言の内容が変わりました "
f "( { entry[ 'sha256' ][: 8 ] } → { digest[: 8 ] } )"
)
else :
notices.append( f "UPDATED { key } origin= { entry[ 'origin' ] } (台帳のハッシュを更新してください)" )
for line in notices:
print ( " " + line)
for line in failures:
print ( "NG " + line)
if failures:
print ( f " \n 出所ゲート: { len (failures) } 件の未解決。提出を中止します。" )
return 1
print ( f " \n 出所ゲート: 通過(notices { len (notices) } 件)" )
return 0
if __name__ == "__main__" :
sys.exit(main())
台帳の中身は、パスをキーにした素朴な JSON です。
{
"ship/metadata/ja-JP/description.txt" : {
"origin" : "human-only" ,
"sha256" : "371619c6..." ,
"note" : "作品説明。手書き"
},
"ship/metadata/en-US/description.txt" : {
"origin" : "claude-assisted" ,
"sha256" : "0000..." ,
"note" : "手書きの日本語を Claude で英訳"
},
"ship/ios/Localizable.strings" : {
"origin" : "claude-generated" ,
"sha256" : "9f2b41d0..."
}
}
未登録のファイルを2件わざと残した状態で実行すると、こう出ます。
UPDATED ship/metadata/en-US/description.txt origin=claude-assisted(台帳のハッシュを更新してください)
NG UNDECLARED ship/android/values-ja/strings.xml 出所が台帳にありません
NG UNDECLARED ship/metadata/ja-JP/review_reply_tpl.txt 出所が台帳にありません
出所ゲート: 2 件の未解決。提出を中止します。
claude-assisted の側は情報表示にとどめ、提出は止めません。生成物は更新されて当然だからです。止めるのは、宣言そのものが欠けている場合だけにしてあります。ここを厳しくしすぎると、台帳を通すこと自体が面倒になって形骸化します。
human-only を守っているのはハッシュ
このゲートで実際に効くのは、human-only の扱いです。同じファイルの本文を一文字だけ書き換えてから再実行すると、こうなります。
NG REDECLARE ship/metadata/ja-JP/description.txt human-only 宣言の内容が変わりました (371619c6 → 2c7abe29)
human-only は「このファイルは Claude を通していない」という自分自身への宣言です。宣言した時点の内容にしか意味がありません。中身が変わったなら、その変更が本当に自分の手によるものかを、もう一度言い直す必要があります。
一見すると過剰な確認です。私も最初は警告どまりにしていました。それを止める側に変えたのは、Claude の応答を読みながら説明文を推敲していたある晩に、下書き用のバッファと提出用のファイルを取り違えて上書きしていたことに、翌朝まで気づかなかったからです。応答をそのまま貼るつもりは無かったのに、結果としてそうなっていました。
意図的な違反は自分では起こしません。起きるのは、こういう取り違えです。ハッシュはそれを翌朝ではなく提出の直前に教えてくれます。
台帳は CI に載せるより先に、手元の提出手順に載せる方が効きます。私は Fastlane の実行前に一度呼ぶだけにしていますが、これで「後から出所を再現できない」という状態はほぼ無くなりました。
台帳が誤爆する落とし穴は、文字コードと改行
この仕組みを回し始めて最初にぶつかったのは、何も編集していない Localizable.strings が REDECLARE で止まる、というものでした。原因はエンコーディングです。iOS の文字列ファイルは UTF-16 で保存されることがあり、エディタや変換スクリプトを一度通すと UTF-8 に変わります。中身の文字は一字も動いていないのに、バイト列は別物になります。
同じ一行を三通りで保存して確かめました。
utf-8 bytes= 35 sha256=1c5798128d73b031
utf-16 bytes= 52 sha256=121c18994f6a858e
utf-8+CRLF bytes= 36 sha256=4b88f272788ef77a
改行コードでも同じことが起きます。Windows 環境から受け取ったファイルを取り込んだだけで CRLF になり、ハッシュが変わります。
こうした誤検出が続くと、人は human-only の警告を読まなくなります。ゲートで最も避けたい状態です。対処は、バイト列ではなく正規化したテキストのハッシュを取ることでした。
def stable_digest (path: Path) -> str :
"""BOM と改行コードの差でハッシュがぶれないようにしてから取る"""
data = path.read_bytes()
if data[: 2 ] in ( b " \xff\xfe " , b " \xfe\xff " ): # UTF-16 の BOM
text = data.decode( "utf-16" )
else :
text = data.decode( "utf-8-sig" ) # UTF-8 BOM も落とす
normalized = text.replace( " \r\n " , " \n " ).rstrip( " \n " )
return hashlib.sha256(normalized.encode( "utf-8" )).hexdigest()
この関数に差し替えたところ、上の三通りはすべて同じダイジェスト(1c5798128d73b031...)に収まりました。バイナリを混ぜる場合はこの正規化を通せないので、拡張子で分岐させて .strings や .xml などのテキスト資産にだけ適用しています。
ここで一つ注意点があります。rstrip("\n") は末尾の空行差を吸収しますが、行頭の空白は残します。インデントの変更まで無視したくなりますが、そこまで緩めると「整形しただけ」と「書き換えた」の区別がつかなくなります。誤検出を減らす調整と、宣言としての意味を守ることは、どこかで釣り合いを取る必要があります。私は末尾の改行だけを吸収する今の線が、実務上ちょうど良いと感じています。
印は、開示の代わりにはならない
最後に、混同しやすい点を整理しておきます。
Anthropic が付ける印は、Anthropic 自身の透明性に関する取り組みです。Claude を組み込んだ製品を配布する側には、その製品として何を開示すべきかという別の検討が残ります。ヘルプセンターにも、自社製品に何が求められるかは各自で評価するように、と明記されています。
つまり印の有無を根拠に「これは開示済み」とは言えません。開示が必要かどうかは、配布先の規約や適用される法令、そして自分がどこまでを AI に任せたかによって決まります。私は法律の専門家ではありませんので、判断に迷う領域では確認を取ることをお勧めします。ここで言えるのは、判断の材料になる記録を自分で持っているかどうかで、その確認にかかる時間がまったく変わるということだけです。
台帳を持つ本当の効用もそこにあります。誰かに「この文は誰が書いたのか」と問われたときに、推測ではなく記録で答えられる。個人開発では、この差が半日と数分の差になります。
問い 印だけで答えられるか 台帳があれば
この文は Claude を通ったか 検出できれば可能性は分かる 宣言として残っている
Claude が書いたのか、訳しただけか 区別できない origin の三区分で分かる
いつの版から通したか 分からない ハッシュの履歴で追える
通していない部分はどれか 証明できない human-only の一覧がそれ
次にやること
まず ship/ に相当するディレクトリを一つ決めてください。ストアに出る文字列と画像だけを、そこに集めます。集める作業そのものが棚卸しになり、「これは誰が書いたのか思い出せない」ファイルが必ず数件見つかります。私の場合は5件出てきました。そこが出発点です。
透かしの導入は、私にとっては制約というより、先延ばしにしていた整理を促す合図でした。どこまでを自分の手で決めるのかを、感覚ではなく記録で言えるようになると、道具を使うことへの迷いがむしろ減ります。お読みいただきありがとうございました。