Skip to content
C.W.K.
Lesson 05 of 05 · published

Archive, Never Delete

~13 min · archival, lineage, data-retention, api-design

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

Deletion is recoverable archival

The architecture's durability section has a sentence that reads like a slogan and works like a rule: deletion is recoverable archival. Assets, batches and preset collections carry an archived flag. Setting it moves the record out of the default views. The row stays, the original stays, and clearing the flag brings it back. Assets could be archived from the birth night, 2026-09-29, already with a refusal for anything still in use. Batches and preset collections gained the flag the next day.

What archived means for each kind of read

  • Listing hides it. The image library shows unarchived assets unless the request asks for archived=true, and the Batches page hides archived batches until the Archived box is ticked. The one exception is the batch you have open: archive it, or untick the box while it is selected, and it stays in the list, marked (archived), so it does not vanish under your cursor.
  • Use refuses it. Studio.asset is the door every reference, every choice and every publication frame goes through, and it refuses an archived image with This image is archived. Restore it before use. An archived picture cannot quietly come back as a reference.
  • Policy ignores it. The comment above export_policy says it: an archived copy of a protected image is still a protected image. Archiving is housekeeping, never a way out of protection. Protection Follows the Pixels picks up that thread.

What keeps an original alive

Archiving an asset is a PATCH with archived: true and the version the client read. Before the flag changes, the route asks four questions. Each yes is a 409 with a sentence that tells Dad what to do instead.

Still used byWhat the refusal tells Dad
the frames of any publication, past or presentit is retained by publication history and must stay available for recovery
a set's published map or its current selectionreplace that use in the set before archiving
a reference profile or a saved lookreplace that use in the profile or look before archiving
the references of an unfinished task: queued, preparing, submitting, running, copying or uncertainfinish or cancel that generation first

Publication history is the strictest row, because it covers the past. A publication is a frozen snapshot, and restoring an earlier one sends those exact originals back to Pippa. The publish preview reads every frame through Studio.asset, so one archived original would turn an old publication into a restore that fails.

Principle: Before you let anything disappear, list everything that points at it, including the past. A record that history still names is not yours to remove.

Batches and collections

A batch can be archived once nothing in it is still working. A task that is queued, preparing, submitting, running or copying blocks it with Finish or cancel this batch's running images before archiving it. One detail is worth noticing: the asset rule counts an uncertain task as unfinished, and the batch rule does not. A batch whose only open question is an uncertain task can be archived, while the references that task used stay pinned.

A preset collection, such as a chef set, can be archived. The standard/default collection never can: The default collection anchors this soul's references and cannot be archived. In default mode, a soul's reference profile resolves to the default frame of that collection, so archiving it would pull the floor out from under every new picture of that soul. That refusal comes back as a plain 422 rather than a 409: nobody else's write is in the way, the rule simply forbids it.

The one delete

Store.delete exists, and its docstring draws the line: only records that carry no lineage, the editable settings, are ever deleted. It has exactly one caller. Resetting a workflow template removes Dad's saved text, and the workflow falls back to its seed. No picture names a template as an ancestor, so nothing is lost but a setting Dad chose to drop. Nothing in the engine removes an original either. The only file removal in the backend is the family kit's atomic writer cleaning up its own temporary file after a failed write.

Why the studio is this stubborn

Avatars exists to hold a second durable copy of every picture that matters, so that cleaning up Ember's history can never take a chosen avatar with it. A delete button would reopen that hole from the inside. Archival gives Dad a tidy library without making anything unrecoverable, and the refusals make sure a tidy-up never breaks a set, a profile, a running job or a restore.

The test for all of this reads like a short story. It uploads a reference and a stray image, puts the reference into a default set, and fails to archive it. It renames the stray and archives it, and watches it leave the library and show up under archived=true. It fails to archive a batch with a queued task, cancels the task, and then succeeds. Last, it tries to archive the default set and gets the 422.

Code

cwkAvatars, backend/app.py: the asset PATCH route, four questions before an archive (verbatim, lines 299-322)·python
    @app.patch("/api/assets/{id}")
    async def patch_asset(id: str, body: AssetPatch):
        asset = await studio.io("get", id, "asset")
        await studio.subjects(asset["subjects"])
        changes = {}
        if body.label is not None:
            changes["label"] = body.label.strip()
        if body.archived is not None:
            changes["archived"] = body.archived
        if body.archived:
            sets = await studio.io("list", "set")
            profiles = await studio.io("list", "profile")
            publications = await studio.io("list", "publication")
            if any(id in p["frames"].values() for p in publications):
                raise StudioError("This original is retained by publication history and must stay available for recovery.", 409)
            if any(id in s.get("published", {}).values() or id in s.get("selection", {}).values() for s in sets):
                raise StudioError("This image is used by a set. Replace that use before archiving.", 409)
            if any(id in p["assets"] or any(id in look["assets"] for look in p.get("variants", {}).values()) for p in profiles):
                raise StudioError("This image is used by a reference profile or a saved look. Replace that use before archiving.", 409)
            if any(id in [r["id"] for r in t["references"]] for t in await studio.io("list", "task", states=("queued", "preparing", "submitting", "running", "copying", "uncertain"))):
                raise StudioError("This original is used by an unfinished generation. Finish or cancel it before archiving.", 409)
        if not changes:
            raise StudioError("Nothing to change.")
        return with_exports(await studio.io("update", id, expected=body.version, **changes))
cwkAvatars, backend/studio.py: archiving a set and a batch (verbatim, lines 227-238)·python
    async def archive_set(self, set_id, body):
        collection = await self.io("get", set_id, "set")
        await self.subjects([collection["soul_pk"]])
        if body.archived and collection["channel"] == "standard" and collection["name"] == "default":
            raise StudioError("The default collection anchors this soul's references and cannot be archived.")
        return await self.io("update", set_id, expected=body.version, archived=body.archived)

    async def archive_batch(self, batch_id, body):
        record = await self.io("get", batch_id, "batch")
        if body.archived and await self.io("list", "task", batch_id, states=("queued", "preparing", "submitting", "running", "copying")):
            raise StudioError("Finish or cancel this batch's running images before archiving it.", 409)
        return await self.io("update", batch_id, expected=body.version, archived=body.archived)
cwkAvatars, backend/tests/test_content_workflows.py: the archive rules, end to end through the API (verbatim, lines 153-176)·python
@pytest.mark.asyncio
async def test_archive_routes_hide_records_and_refuse_referenced_originals(tmp_path):
    app = create_app(Settings(data=tmp_path, run_workers=False), Authority(), auth_check=lambda request, upstream: asyncio.sleep(0))
    with TestClient(app, client=("127.0.0.1", 1)) as client:
        upload = client.post("/api/assets", files={"file": ("ref.png", image(), "image/png")}, data={"subjects": '["pippa"]'}).json()
        stray = client.post("/api/assets", files={"file": ("stray.png", image("green"), "image/png")}, data={"subjects": "[]"}).json()
        created = client.post("/api/sets", json={"soul_pk": "pippa", "name": "default", "frames": ["default"]}).json()
        client.patch(f"/api/sets/{created['id']}/selection", json={"version": created["version"], "choices": {"default": upload["id"]}})
        assert client.patch(f"/api/assets/{upload['id']}", json={"version": upload["version"], "archived": True}).status_code == 409
        renamed = client.patch(f"/api/assets/{stray['id']}", json={"version": stray["version"], "label": "Fireside thumbnail"}).json()
        assert renamed["label"] == "Fireside thumbnail" and renamed["exports"][0]["filename"] == "fireside-thumbnail-youtube.jpg"
        archived = client.patch(f"/api/assets/{stray['id']}", json={"version": renamed["version"], "archived": True}).json()
        assert archived["archived"]
        assert [a["id"] for a in client.get("/api/assets").json()["items"]] == [upload["id"]]
        assert [a["id"] for a in client.get("/api/assets?archived=true").json()["items"]] == [stray["id"]]
        assert client.get(f"/api/assets?origin=uploaded&q=fireside").json()["total"] == 0
        # Library search reaches prompts, not only labels.
        app.state.studio.store.update(upload["id"], prompt="A cozy fireside talk")
        assert client.get("/api/assets?q=cozy%20fireside").json()["total"] == 1
        batch = client.post("/api/generation", json={"request_id": "b1", "prompt": "Scene", "providers": [{"id": "first"}]}).json()
        assert client.patch(f"/api/batches/{batch['id']}", json={"version": batch["version"], "archived": True}).status_code == 409  # queued work
        app.state.studio.store.update((app.state.studio.store.list("task", batch["id"]))[0]["id"], state="cancelled")
        assert client.patch(f"/api/batches/{batch['id']}", json={"version": batch["version"], "archived": True}).json()["archived"]
        assert client.patch(f"/api/sets/{created['id']}", json={"version": created["version"] + 1, "archived": True}).status_code == 422

External links

Exercise

Choose a record type in your app that other records point at: an uploaded file, a customer, a product. Replace its delete with an archived flag. List every relation that must block archiving, including history such as past orders or past releases, and write one refusal sentence per relation that tells the user what to do next. Then write a test that tries to archive a record in each blocking state, and finally one that nothing points at.
Hint
Decide separately what archived means for listing, for use, and for permission or policy checks. Avatars hides on listing, refuses on use, and ignores the flag entirely for protection. Keep a true delete only for records nothing descends from, such as settings with a default to fall back to.

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.