Skip to content
C.W.K.
Lesson 02 of 04 · published

Lineage Is Contagious

~14 min · lineage, content-addressing, graph-closure, sha-256

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Three ways a subject lands on a picture

The visibility check from the last lesson asks about subjects. This lesson is about where those subjects come from, because a check is only as good as the list it is handed. Every asset row in Avatars carries a subjects list and a references list, and three paths write them.

  1. Declared at upload. Studio.upload validates the subjects the uploader names, with the acting soul attached, and binds them to the new asset.
  2. Inherited at birth. When a generation is planned, each task's subjects are the declared subjects plus every subject in every input's lineage. When the worker saves the output, it writes those subjects and records each input's id under references.
  3. Inherited from identical bytes. save_asset hashes the new bytes and looks up every stored asset with the same SHA-256. Their subjects join the new row's subjects, and their ids join its references.

That third path has a sharp edge. When an acting soul uploads an exact copy of a protected picture, the row is written first, carrying the protected subject it inherited. Then save_upload checks the new asset's subjects against that actor and answers 403. The copy is stored, it is protected, and the soul who brought it cannot see it.

Walked again on every read

Stamping subjects at birth is cheap, and it makes the record honest when you open it. Avatars still does not rely on the stamp. asset_lineages rebuilds the answer from one snapshot of the store whenever a lineage is asked for. It indexes every asset by hash, then walks from the requested id: collect this asset's subjects, follow its references, follow every asset that shares its bytes, and repeat until nothing new turns up. The result replaces the row's own subject list for that read.

The walk catches what the stamp cannot. Suppose a copy was uploaded with no subjects at all, and weeks later the same bytes arrive again tagged with a protected soul. The first row's stamp is still empty. The walk from it reaches the second row through the shared hash and comes back with the protected soul. Contagion runs both ways along an identical-bytes edge.

The walk only gathers subjects. Whether any of them is protected is asked of Pippa's registry at that moment. So when a soul becomes Super, every picture already descended from it is covered on the next read, with no migration and no backfill. The backend test checks exactly that. Its last two lines flip a soul that was public to protected, then assert that an image made of that soul may no longer leave.

What a rename cannot do

Track 2 showed that an original's filename on disk is its own SHA-256. The label you see in the studio is free text, cut to 200 characters, and plays no part in identity. Renaming changes the label. Copying a file to another folder changes nothing at all. Archiving does not help either: the export check reads the lineage and ignores the archive flag on purpose. The architecture states the rule in one line: renaming, preset changes and copies never clear lineage.

What the identical-bytes rule does not see

The comment above the hash lookup in save_asset promises more than the line below it can keep. It says an upload cannot launder a protected avatar by stripping its filename or embedded metadata. The filename half is true. The metadata half is not, and the code says why: the lookup compares the SHA-256 of the whole byte string. Stripping EXIF writes different bytes. So does re-encoding, resizing, cropping or taking a screenshot. Each of those produces a digest that no longer matches the original, so the original cannot protect the copy. Unless those exact new bytes were stored before, as an earlier upload or a studio output, the copy arrives as a stranger, bound only to the subjects its uploader declares. If they were, it matches that row and inherits whatever that row carries.

Nothing in this path looks at a face. The guarantee is exactly two things: identical bytes share their subjects, and anything the studio generates carries the lineage of what it was made from. A visually similar copy is outside both. Teach the rule at that size, and when you need more, the honest next tool is a perceptual hash, which Avatars does not have. The comment is a good reminder of a habit worth keeping: read the line, not the sentence above it.

Two more places the closure is enforced

A protected image cannot become a public soul's reference. save_profile resolves every asset in the profile and its saved looks, asks for all of their subjects, and refuses with a 403 if the target soul is public while any subject is Super. On the publishing side, Pippa's publish collects every frame's subjects and refuses a set that carries a protected subject under a public soul. A protected soul's own set still publishes to Pippa normally; only a public soul's copies are closed to it.

Principle: Store the edges, compute the closure when you ask, and ask the current policy. A stamped answer ages; a walked one cannot.

Code

cwkAvatars, backend/store.py: save_asset inherits every subject of identical bytes (verbatim, lines 164-169)·python
        digest = hashlib.sha256(data).hexdigest()
        # Reimports retain every known subject; an upload cannot launder a
        # protected avatar by stripping its filename or embedded metadata.
        known = [a for a in self.list("asset") if a["sha256"] == digest]
        subjects = sorted(set(subjects).union(*(set(a["subjects"]) for a in known)))
        refs = list(dict.fromkeys([*(references or []), *(a["id"] for a in known)]))
cwkAvatars, backend/store.py: asset_lineages, the closure over references and identical bytes (verbatim, lines 219-241)·python
    def asset_lineages(self, ids: list[str] | None = None) -> dict[str, dict]:
        """Resolve current ancestry from one snapshot, never a stale policy cache."""
        assets = {a["id"]: a for a in self.list("asset")}
        requested = list(dict.fromkeys(ids)) if ids is not None else list(assets)
        if any(id not in assets for id in requested):
            raise StudioError("Record not found", 404)
        hashes = {}
        for a in assets.values():
            hashes.setdefault(a["sha256"], []).append(a["id"])
        resolved = {}
        for id in requested:
            subjects, seen, queue = set(), set(), [id]
            while queue:
                current = queue.pop()
                if current in seen or current not in assets:
                    continue
                seen.add(current)
                a = assets[current]
                subjects.update(a["subjects"])
                queue.extend(a.get("references", []))
                queue.extend(hashes[a["sha256"]])
            resolved[id] = {**assets[id], "subjects": sorted(subjects)}
        return resolved
cwkAvatars, backend/tests/test_studio.py: a protected upload, then the same bytes renamed with no subjects (verbatim, lines 120-127)·python
    protected = await studio.upload(image("black"), "Private reference", ["secret"])
    with pytest.raises(StudioError):
        await studio.save_profile("pippa", ReferenceProfile(version=profile["version"], mode="override", assets=[protected["id"]]))
    reupload = await studio.upload(image("black"), "Renamed reference", [])
    assert "secret" in reupload["subjects"]
    with pytest.raises(StudioError):
        await studio.asset(reupload["id"], "pippa")
    assert not (await studio.export_policy(reupload["id"]))["allowed"]

External links

Exercise

Model a small media store with two edge types: "made from" (references) and "same bytes as" (equal SHA-256). Write a closure function that returns every label reachable from one item through either edge, and compute it at read time from the stored rows. Then write three tests: a derived item inherits its source's label, an identical re-upload with no labels inherits it, and a re-encoded copy does not. Keep that third test; it documents the boundary of your promise.
Hint
Use an explicit stack and a seen set, as Avatars does, so a cycle through the same-bytes edge cannot loop forever. For the re-encoded copy, open the image and save it again with a different quality setting, then assert the digests differ before you assert anything about labels.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.