스튜디오가 생기기 전엔 피파의 아바타 폴더가 곧 원본이었어. 정본이 될 다른 사본이 아예 없었지. 그러니까 Avatars는 빈 채로 시작해서 권한을 주장할 수가 없었어. 이미 있던 폴더를 넘겨받아야 했지. 그것도 바이트 하나 안 다치게, 자기가 만든 적 없는 그림에 가짜 역사를 붙이지 않고. 그리고 넘겨받은 뒤엔 문을 닫아서 옛 방향이 다시는 열리지 않게 해야 했어. 아키텍처는 그 결과를 이렇게 적어. 아바타 권한은 한 방향, Avatars에서 피파로만 흐른다고. 이 레슨에서 볼 건 그 한 줄이 어떻게 딱 한 번에 참이 됐고, 어떻게 계속 참으로 남는지야.
한 번만 도는 가져오기
Studio.import_existing은 먼저 소유권 기록이 있는지 물어. 있고 상태가 canonical이면 가져오기는 아무것도 안 가져오고 사본 감사 결과만 돌려줘. 있는데 아직 adopting이면 그 입양을 마저 끝내. 소유권 기록이 없을 때만 피파의 레거시 카탈로그를 읽어. 소울마다 컬렉션, 프레임마다 경로와 SHA-256, 그리고 폴더의 리비전. 이 카탈로그의 docstring에 피파가 적어 둔 말이 있어. 처음 한 번 띄우기 위한 레거시 목록일 뿐이고, 스튜디오 레퍼런스의 출처로는 절대 안 쓴다고.
레거시 컬렉션 중에 Avatars가 이미 가진 세트는 건너뛰어. 옆 주석에 따르면, 가져오기는 아빠가 지금 골라 둔 선택을 절대 초기화하지 않아. 나머지는 프레임을 전부 읽고, 경로가 피파의 아바타 폴더 안에 머무는지 확인하고, 바이트를 카탈로그의 해시와 비교해. 안 맞으면 An existing avatar changed during import. Retry the import., 가져오는 도중에 기존 아바타가 바뀌었으니 다시 하라는 거절이야. 프레임은 하나하나 출처가 imported인 원본이 되고, 자기 소울에 묶여. 새 세트는 같은 맵을 선택으로도, 발행된 프레임으로도 받고, 카탈로그의 리비전을 라이브 리비전으로 받아. 피파가 내주고 있는 게 정확히 그거니까.
가져오기가 끝나면 발행된 프레임은 있는데 발행 기록은 없는 세트를 전부 목록에 담아. 그리고 고정된 요청 id legacy-avatar-authority-v1로 소유권 intent를 adopting 상태로 커밋해. id가 고정이라 intent 자체가 멱등이야. 두 번째로 돌아도 같은 기록을 찾아.
입양 모드는 아무것도 설치하지 않아
finish_adoption은 목록의 세트마다 요청 id adopt-<set id>로 adopt 모드 발행을 해. 이미 대기 중인 게 있으면 그걸 이어 가고. 다 끝나면 소유권을 canonical로 표시해. 입양 모드에서 Avatars는 세트의 발행 맵을, 자기가 적어 둔 리비전에 맞춰 발행해. 그래서 가져온 뒤로 바뀐 사본은 오래된 미리보기처럼 거절돼.
피파 쪽에서 입양 모드는 준비도 바꾸기도 절대 안 해. 먼저 라이브 폴더의 파일이 전부 자기가 주장하는 프레임과 해시가 맞는지 확인해. 안 맞으면 Legacy copies do not match the Avatars originals로 거절해. 레거시 사본이 Avatars 원본과 다르다는 말이야. 맞으면 폴더를 백업하고, legacy-adoption 표시를 단 prepared 영수증을 써. 마무리할 땐 리비전을 한 번 더 확인하고, 기존 폴더에 avatar-lineage.json 하나만 써. 피파 테스트는 그 뒤에 파일 이름, 수정 시각, 바이트가 전부 그대로인지, PNG가 새로 안 생겼는지 확인해. 발행 기록에도 legacy-adoption이 적혀. 입양은 지난 생성 기록도, Ember 영수증도 지어내지 않아. 아키텍처가 그렇게 못 박아 뒀어.
README에 결과가 적혀 있어. 레거시 입양으로 컬렉션 32개, 이미지 301장을 지켰어. 기준 발행 영수증도 붙었고, 피파 사본은 바이트까지 똑같아.
canonical 다음부터 어긋남은 보고야
WebUI에서 가져오기로 들어가는 문은 사이드바의 Verify published copies 버튼이야. 발행된 사본 확인하기라는 뜻이지. 소유권이 canonical이 되면 그 버튼은 감사만 해. copy_status는 발행된 세트마다 Avatars가 적어 둔 해시를 가지고, 피파와 같은 공식으로 리비전을 다시 계산해. 그리고 피파한테 라이브 리비전을 물어. 같으면 사본이 충실한 거야. 다르면 보고가 떠. 발행된 컬렉션 몇 개가 Avatars 원본과 다르니 살펴보고 다시 발행하라는 내용이야. 바뀐 파일은 절대 안 읽고, 절대 안 가져와. docstring도 딱 그렇게 말해. 설치된 사본을 비교할 뿐, 그 변경은 절대 가져오지 않는다고.
입양 테스트가 이걸 하나도 빼놓지 않고 붙들어. 첫 가져오기는 피파가 적용한 뒤에 실패하고, 소유권은 아직 adopting이야. 두 번째 가져오기가 그걸 canonical로 끝내. 피파에 보낸 페이로드는 똑같고, 레거시 카탈로그는 딱 한 번만 읽혀. 이번엔 테스트가 사본이 전부 없어진 것처럼 꾸며. 감사는 전부 안 맞는다고 보고해. 그래도 카탈로그는 여전히 한 번만 읽혔고, 세트의 발행 맵은 그대로야. 소울의 레퍼런스 프로필도 여전히 원본으로 풀려. 망가진 사본이 바꾸는 건 보고 하나뿐이야.
일부러 열어 둔 문 하나
등록된 사람들은 같은 날 밤 늦게야 아바타 경계에 들어왔어. 본 입양이 이미 닫힌 뒤였지. 그래서 그 사람들의 기존 폴더는 입양에서 빠져 있었어. 가져오기를 다시 열면 모두에게 규칙이 깨져. 그 대신 backend/adoption.py가 있어. 전환 때 빠진 등록 주체 하나만 대상으로, 명시적으로 돌리고 중간에 끊겨도 이어 갈 수 있는 입양이야. scripts/adopt-subject.py <subject>는 미리보기만 찍고, --apply를 줘야 써. Avatars 컬렉션이 하나라도 있는 주체는 거절해. 레거시 사본으로 그걸 대신하거나 늘릴 수 없다는 거야. 원본 해시는 자기 intent에 얼려 둬. 돌 때마다 쓰기 전에 얼린 원본을 전부 다시 확인하고, 마무리는 똑같이 finish_adoption이 해. 이건 마이그레이션 입구지, 평소에 누르는 버튼이 아니야.
원칙: 권한은 한 번만 옮겨. 지나간 기록을 스스로 남기고 그 뒤로 잠기는 문을 통해서. 그 뒤로 주인과 사본의 차이는 보고할 거리지, 합칠 거리가 아니야.
Pippa Quest의 소울 트랙은 같은 얘기를 반대편에서 다뤄. Avatars가 발행한 것과 다른 프레임이 피파 폴더에 있으면 그건 어긋남이고, 고치는 길은 Avatars에서 다시 발행하는 거라고.
async def import_existing(self):
"""One-time transfer of authority; subsequent calls only audit copies."""
ownership = await self.io("list", "ownership")
if ownership:
if ownership[0]["state"] == "canonical":
return {"imported": [], "ownership": ownership[0], "copies": await self.copy_status()}
return await self.finish_adoption(ownership[0])
registry = await self.upstream.legacy_catalog()
imported = []
async with self.intent_lock:
current_sets = await self.io("list", "set")
for source in registry["sets"]:
current = next((s for s in current_sets if (s["soul_pk"], s["channel"], s["name"]) == (source["soul_pk"], source["channel"], source["name"])), None)
if current:
continue # importing never resets Dad's current selections
frames = {}
for frame, item in source["frames"].items():
path = Path(item["path"]).resolve()
path.relative_to((Path(__file__).resolve().parents[2] / "cwkPippa/avatars").resolve())
data = await asyncio.to_thread(path.read_bytes)
if hashlib.sha256(data).hexdigest() != item["sha256"]:
raise Conflict("An existing avatar changed during import. Retry the import.")
asset = await self.io("save_asset", data, label=f"{source['name']} · {frame}", subjects=[source["soul_pk"]], origin="imported", source_path=str(path))
frames[frame] = asset["id"]
record = await self.io("add", "set", soul_pk=source["soul_pk"], channel=source["channel"], name=source["name"], frames=list(frames), published=frames, selection=frames, live_revision=source["revision"], archived=False)
imported.append(record)
collections = await self.io("list", "set")
legacy = [s["id"] for s in collections if s.get("published") and not await self.io("list", "publication", s["id"])]
ownership = await self.io("create_intent", "legacy-avatar-authority-v1", {"schema": 1}, "ownership", {"state": "adopting", "owner": "cwkAvatars", "legacy_sets": legacy, "started_at": now()})
result = await self.finish_adoption(ownership)
return {**result, "imported": imported}
cwkAvatars, backend/studio.py: finish_adoption과 copy_status, 세트마다 입양 모드로 한 번 발행하고, 그 뒤엔 비교만 해 (원문 그대로, 187-216행)·python
async def finish_adoption(self, ownership):
for id in ownership["legacy_sets"]:
request_id = f"adopt-{id}"
records = await self.io("list", "publication", id)
existing = next((p for p in records if p.get("payload", {}).get("request_id") == request_id), None)
if existing:
if existing["state"] != "applied":
receipt = await self.upstream.publish(existing["payload"])
await self.io("complete_publication", existing["id"], receipt)
continue
collection = await self.io("get", id, "set")
await self.publish(id, request_id, collection["version"], collection["live_revision"], mode="adopt")
ownership = await self.io("update", ownership["id"], state="canonical", completed_at=now())
return {"ownership": ownership, "imported": []}
async def copy_status(self):
"""Compare installed copies without ever importing their changes."""
collections = [c for c in await self.io("list", "set") if c.get("published")]
ids = list(dict.fromkeys(id for c in collections for id in c["published"].values()))
lineage = await self.io("asset_lineages", ids) if ids else {}
# One registry round trip covers every published subject; the policy
# verdict is still the registry's, taken now.
await self.subjects(sorted({s for a in lineage.values() for s in a["subjects"]}))
answer = []
for collection in collections:
hashes = {frame: lineage[id]["sha256"] for frame, id in sorted(collection["published"].items())}
revision = hashlib.sha256(json.dumps(hashes, sort_keys=True).encode()).hexdigest()
actual = await self.upstream.revision(collection["soul_pk"], collection["channel"], collection["name"])
answer.append({"set_id": collection["id"], "soul_pk": collection["soul_pk"], "channel": collection["channel"], "name": collection["name"], "expected_revision": revision, "actual_revision": actual, "matches": actual == revision})
return answer
cwkPippa, backend/services/avatar_studio.py: 입양 모드 발행, 사본이 이미 맞아야 하고 영수증만 준비해 (원문 그대로, 237-252행)·python
if body.get("mode") == "adopt":
actual = {frame: digest(path) for frame, path in frame_files(target).items()}
if actual != {frame: item["sha256"] for frame, item in files.items()}:
raise ValueError("Legacy copies do not match the Avatars originals")
backup = REPO / "avatar-backups" / "studio" / request_id
backup.mkdir(parents=True, exist_ok=True)
if target.exists():
if not (backup / "previous").exists():
shutil.copytree(target, backup / "previous")
if revision(frame_files(backup / "previous")) != previous:
raise ValueError("Backup verification failed")
if body.get("mode") == "adopt":
manifest["revision"] = previous
receipt = {"request_id": request_id, "fingerprint": fingerprint, "status": "prepared", "mode": "adopt", "provenance": "legacy-adoption", "soul_pk": body["soul_pk"], "channel": body["channel"], "name": body["name"], "previous_revision": previous, "revision": previous, "backup": str(backup), "target": str(target), "manifest": manifest}
atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
return finish_publication(receipt, receipt_path)
네가 아는 시스템에서 권한을 한 번 넘기는 계획을 세워 봐. 레거시 저장소가 쥔 데이터를 새 서비스가 가져가야 하는 경우야. 가져오기는 이렇게 써. 목록을 만들 때 뜬 해시와 항목을 하나하나 비교해. 고정 id로 소유권 intent를 adopting 상태로 하나 기록하고, 끝나면 canonical로 표시해. 그 뒤엔 같은 입구가 차이를 읽어 들이지 않고 보고만 하는 감사가 되게 해. 가져오기를 중간에 실패시키고, 다시 돌리고, 목록을 딱 한 번만 읽었는지 확인하는 테스트를 써.
Hint
넘기기를 한 번으로 만드는 건 고정 id야. 두 번째로 돌면 intent를 찾아서 처음부터가 아니라 이어서 해. 전환 때 빠뜨린 건 따로 처리해. 명시적으로 부르고 미리보기부터 보여 주는 길로. 새 서비스가 이미 가진 대상은 거절하게 하고.
Progress
Progress is local-only — sign in to sync across devices.