fix(storage): preserve agent and skill files during online conversion (#19656)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent files and installed skills need durable storage before a
legacy volume can be removed.
> - Their maintenance commands currently require every writer to stop.
> - Normal file edits already use agent row locks and skill publication
locks.
> - Shared agent heads also remain durable while their agents run.
> - This pull request uses those owners to copy settled files online and
keeps unresolved recovery states intact.
> - The benefit is useful migration progress while unrelated work
continues.

## Linked Issues or Issue Description

Related: #19642.

**What happened?**

Agent-file adoption and skill installation conversion reject apply
requests without a global stopped-writer acknowledgement. Agent-file
maintenance also reports an active agent as blocked when its shared head
is already durable. Live enrollment omits the existing
unresolved-local-instruction-copy guard.

**Expected behavior**

Copy settled agent files and skill installations under their existing
writer locks. Keep old skill directories, versions, and inline bytes.
Keep unresolved local copies and unknown historical pin obligations
visible to their owners.

**Steps to reproduce**

Run agent-file maintenance with `--apply` on the original host with
durable storage enabled. A managed agent without active execution is
rejected without `--writers-stopped`. The same gate rejects skill
installation conversion. An agent that already has a shared head is also
reported as blocked while it runs.

## What Changed

- Adopt settled agent files under the existing agent row lock. Recognize
an existing shared head before checking active execution, without
rewriting its projection.
- Reuse the local working-copy preservation predicate for first adoption
and live enrollment. Keep its original public export.
- Prepare skill uploads outside the shared run-pinning lock. Verify the
exact local tree and source identity under the cache, slug, and row
publication locks. Publish catalog origin history in that same
transaction.
- Retain old skill directories, versions, and inline bytes. Keep pin
retention and volume-removal guards unchanged.
- Add PostgreSQL and filesystem coverage for active durable heads,
unresolved copies, concurrent source edits, and linked files. Update the
maintenance docs.

## Verification

- Agent-file durability and live enrollment suites: 16 tests passed with
real PostgreSQL and filesystem fixtures.
- Managed skill suite: 22 tests passed. Seven final controls also
passed: upload-time file edits, source relocation, unknown historical
pins, hard-linked files, pinning during a blocked upload, and both
rejected/successful catalog-origin publication.
- Server source typecheck and focused changed-test typecheck passed.
- Module boundaries, diff whitespace, and added-line
credential/internal-reference scans passed.
- Baseline production rejected both online paths at their stopped-writer
guards. Review regressions also reproduced a busy pinning lock during
upload and extra catalog history after rejection; their fixes passed
real PostgreSQL controls. All expected red results are preserved.
- One final-control attempt failed the existing 30-second fixture
startup hook before executing tests. A sequential retry passed all four
controls without changing guards or timeouts; the failed attempt remains
recorded.
- Exact head `001a5beed9d0e55c7c178906fbad1f9a47f87788`: [required CI
passed](https://github.com/paperclipai/paperclip/actions/runs/42309446426),
including typecheck, build, tests, Runner and end-to-end gates. Greptile
completed successfully at 5/5 with zero unresolved review threads;
GitHub reports mergeable/clean.

## Risks

Changed or unsafe sources remain blocked. Storage failures keep the
original files and recovery state. This conversion does not settle
retained provider copies or historical execution. Unknown pin
obligations still block retention and volume removal; this PR alone does
not qualify removal of the original volume.

## Model Used

OpenAI GPT-6 (Codex). The runtime did not expose a more specific backend
model ID or context-window size. Used reasoning, code editing, and tool
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-06 17:31:04 -06:00
1 parent bee0f0e2fa
commit bb621880e6
10 files changed
+356 -97

No files matched your search

@@ -6,7 +6,7 @@ import { Readable } from "node:stream";
import { setTimeout as delay } from "node:timers/promises";
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, agentFileRoots, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db";
import { agents, agentFileRoots, agentInstructionWorkingCopies, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js";
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "../services/agent-file-durability.js";
@@ -193,17 +193,42 @@ describe("durable agent file authority", () => {
expect(await store().read(companyId, agentId, "failed.txt", actor())).toBeNull();
});
it("dry-runs migration without adopting files and requires stopped writers for publication", async () => {
it("dry-runs without adopting files and publishes settled legacy files online", async () => {
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Legacy"));
const input = { companyId, apply: false, writersStopped: false };
expect(await migrateAgentFiles(db, input)).toMatchObject({ results: [{ agentId, state: "needs_import" }], pageReady: false });
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
await expect(migrateAgentFiles(db, { ...input, apply: true })).rejects.toThrow("stopped writers");
expect(await migrateAgentFiles(db, { ...input, apply: true, writersStopped: true })).toMatchObject({ pageReady: true });
expect(await migrateAgentFiles(db, { ...input, apply: true })).toMatchObject({ pageReady: true });
await replaceFilesystem();
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Legacy"));
});
it("recognizes an active agent's existing durable head without restoring its filesystem projection", async () => {
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Durable"), baseHash: null }, actor());
const [active] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", runtimeMode: "native" }).returning();
const before = await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId));
await fs.rm(root, { recursive: true });
expect(await migrateAgentFiles(db, { companyId, apply: true, writersStopped: false }))
.toMatchObject({ results: [{ agentId, state: "durable" }], pageReady: true });
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual(before);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, active!.id)))[0]).toEqual(active);
await expect(fs.stat(root)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["pending_collection", "unavailable"])("preserves a %s working copy and keeps first adoption blocked", async state => {
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Canonical files"));
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", finishedAt: new Date() }).returning();
const [copy] = await db.insert(agentInstructionWorkingCopies).values({ companyId, agentId, runId: run!.id,
responsibleUserId: userId, entryFile: "AGENTS.md", baseHash: "a".repeat(64), localRoot: path.join(home, "retained-copy"),
executionRoot: "/retained/agent-files", location: "local", state,
errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", receipt: { schema: "paperclip.agent-files.v1" } }).returning();
expect(await migrateAgentFiles(db, { companyId, apply: true, writersStopped: false }))
.toMatchObject({ results: [{ agentId, state: "working_copy_unsettled" }], pageReady: false });
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
expect((await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.runId, run!.id)))[0]).toEqual(copy);
expect(await fs.readFile(path.join(root, "AGENTS.md"), "utf8")).toBe("Canonical files");
});
it("refuses migration while the agent has queued or running execution", async () => {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
for (const status of ["running", "queued"]) {
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, afterEach, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentFileRoots, agents, companies, companySkills, companySkillVersions, executionWorkspaces, heartbeatRuns, providerTraceRecords, createDb } from "@paperclipai/db";
import { agentFileRoots, agentInstructionWorkingCopies, agents, companies, companySkills, companySkillVersions, executionWorkspaces, heartbeatRuns, providerTraceRecords, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { assertEphemeralServerData, readEphemeralServerDataEligibility } from "../services/ephemeral-server-contract.js";
import { initializeDurableAgentFiles } from "../services/agent-file-durability.js";
@@ -66,3 +66,22 @@ it("keeps replacement readiness independent of a serving controller's unfinished
await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, skillId));
await expect(assertEphemeralServerData(db)).rejects.toThrow("skills_require_migration");
});
it("keeps an unsettled local instruction copy visible after its canonical files become durable", async () => {
vi.stubEnv("PAPERCLIP_DURABLE_AGENT_FILES", "true");
const companyId = randomUUID(), agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Private copy preservation", issuePrefix: "COPY" });
await db.insert(agents).values({ id: agentId, companyId, name: "File owner" });
await db.transaction(tx => initializeDurableAgentFiles(tx as unknown as Db, { id: agentId, companyId }));
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", finishedAt: new Date() }).returning();
const [copy] = await db.insert(agentInstructionWorkingCopies).values({ runId: run.id, companyId, agentId,
responsibleUserId: "preservation-fixture", entryFile: "AGENTS.md", baseHash: "a".repeat(64),
localRoot: "/private/unsaved-copy", executionRoot: "/private/unsaved-copy", location: "local", state: "pending_collection" }).returning();
expect((await readEphemeralServerDataEligibility(db, { purpose: "startup" })).blockers)
.toContain("local_instruction_copies_require_settlement");
expect((await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.runId, run.id)))[0]).toEqual(copy);
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date(),
receipt: { schema: "paperclip.agent-files.v1", cleanupPending: false } }).where(eq(agentInstructionWorkingCopies.runId, run.id));
expect((await readEphemeralServerDataEligibility(db, { purpose: "startup" })).blockers)
.not.toContain("local_instruction_copies_require_settlement");
});
@@ -1,6 +1,8 @@
import { createServer } from "node:http";
import { createS3StorageProvider } from "../storage/s3-provider.js";
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
import { skillPackageStore } from "../services/skill-package-store.js";
import { removeRuntimeAssetTree } from "../services/native-runtime/runtime-asset-retention.js";
import { eq } from "drizzle-orm";
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
@@ -384,8 +386,7 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
await db.update(companySkillVersions).set({ packageFileMap: null }).where(eq(companySkillVersions.id, version.id));
const maintenance = skillInstallationMaintenance(db);
expect((await maintenance.installations({ id: legacy!.id })).results[0]!.status).toBe("conversion_required");
await expect(maintenance.installations({ companyId, apply: true })).rejects.toMatchObject({ status: 409 });
expect((await maintenance.installations({ id: legacy!.id, apply: true, writersStopped: true })).results)
expect((await maintenance.installations({ id: legacy!.id, apply: true })).results)
.toEqual([expect.objectContaining({ skillId: legacy!.id, versionId: version.id, status: "converted" })]);
await fs.rm(directory, { recursive: true });
expect(await service.getById(companyId, legacy!.id)).toMatchObject({ sourceType: "managed", sourceLocator: null, currentVersionId: version.id });
@@ -393,6 +394,163 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
expect((await maintenance.eligibility({ id: legacy!.id })).results[0]!.status).toBe("version_backed");
});
it("copies an installation online while preserving unknown old pins, old inline bytes, files, and active work", async () => {
const companyId = await company(), directory = path.join(root, `copy-only-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Preserved skill");
const [skill] = await db.insert(companySkills).values({ companyId, key: "copy-only", slug: "copy-only", name: "Copy only",
markdown: "# Preserved skill", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "# Preserved skill" }];
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill.id,
revisionNumber: 1, fileInventory: files }).returning();
await db.update(companySkills).set({ currentVersionId: version.id }).where(eq(companySkills.id, skill.id));
const [agent] = await db.insert(agents).values({ companyId, name: "Unrelated active worker" }).returning();
const [active] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, runtimeMode: "native",
status: "running", contextSnapshot: {} }).returning();
const maintenance = skillInstallationMaintenance(db);
expect((await maintenance.installations({ id: skill.id, apply: true })).results)
.toEqual([expect.objectContaining({ skillId: skill.id, status: "converted" })]);
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0].fileInventory).toEqual(files);
expect(await fs.readFile(path.join(directory, "SKILL.md"), "utf8")).toBe("# Preserved skill");
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, active.id)))[0]).toEqual(active);
// Copy-only publication does not release retention or volume-removal guards.
expect((await maintenance.eligibility({ id: skill.id })).results[0].status).toBe("unsupported_active_run");
});
it.each(["files", "source_locator"])("keeps a concurrent %s change instead of publishing the earlier conversion", async change => {
const companyId = await company(), directory = path.join(root, `conversion-race-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Race");
await fs.writeFile(path.join(directory, "support.txt"), "old version");
const [skill] = await db.insert(companySkills).values({ companyId, key: "conversion-race", slug: "conversion-race", name: "Race",
markdown: "# Race", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }],
metadata: { sourceKind: "managed_local" } }).returning();
const version = await companySkillService(db).createVersion(companyId, skill.id);
await fs.writeFile(path.join(directory, "support.txt"), "captured unsaved edit");
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
let changed = false;
const nextDirectory = path.join(root, `new-source-${randomUUID()}`);
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
const saved = await put(input);
if (!changed) {
changed = true;
if (change === "files") await fs.writeFile(path.join(directory, "support.txt"), "newer unsaved edit");
else {
await fs.mkdir(nextDirectory);
await fs.writeFile(path.join(nextDirectory, "SKILL.md"), "# New source");
await db.transaction(async tx => {
await tx.select().from(companySkills).where(eq(companySkills.id, skill.id)).for("update");
await tx.update(companySkills).set({ sourceLocator: nextDirectory }).where(eq(companySkills.id, skill.id));
});
}
}
return saved;
});
try {
const result = await skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true });
expect(changed).toBe(true);
expect(result.results).toEqual([expect.objectContaining({ status: "blocked" })]);
const [current] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
expect(current).toMatchObject({ sourceType: "local_path", currentVersionId: version.id,
sourceLocator: change === "files" ? directory : nextDirectory });
expect(await fs.readFile(path.join(directory, "support.txt"), "utf8"))
.toBe(change === "files" ? "newer unsaved edit" : "captured unsaved edit");
} finally { upload.mockRestore(); }
});
it("holds a hard-linked local skill instead of adopting another file owner's mutable bytes", async () => {
const companyId = await company(), directory = path.join(root, `linked-conversion-${randomUUID()}`);
await fs.mkdir(directory);
const original = path.join(root, `shared-content-${randomUUID()}.md`);
await fs.writeFile(original, "# Shared source");
await fs.link(original, path.join(directory, "SKILL.md"));
const [skill] = await db.insert(companySkills).values({ companyId, key: "linked-conversion", slug: "linked-conversion", name: "Linked",
markdown: "# Shared source", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
expect((await skillInstallationMaintenance(db).installations({ id: skill.id, apply: true })).results)
.toEqual([expect.objectContaining({ status: "blocked" })]);
expect((await db.select().from(companySkills).where(eq(companySkills.id, skill.id)))[0]).toEqual(skill);
expect(await fs.readFile(original, "utf8")).toBe("# Shared source");
});
it("allows new durable skill pinning while an installation upload is blocked", async () => {
const companyId = await company(), directory = path.join(root, `pinning-during-copy-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Old selection");
const [skill] = await db.insert(companySkills).values({ companyId, key: "online-pinning", slug: "online-pinning", name: "Pinning",
markdown: "# Old selection", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" } }).returning();
const version = await companySkillService(db).createVersion(companyId, skill.id);
await fs.writeFile(path.join(directory, "SKILL.md"), `# Current local files ${randomUUID()}`);
const [agent] = await db.insert(agents).values({ companyId, name: "New work" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, status: "queued" }).returning();
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
let release!: () => void, ready!: () => void, blocked = false;
const barrier = new Promise<void>(resolve => { release = resolve; }), uploading = new Promise<void>(resolve => { ready = resolve; });
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
if (!blocked) { blocked = true; ready(); await barrier; }
return put(input);
});
const conversion = skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true });
try {
await uploading;
// This uses the real nonblocking PostgreSQL owner, not a timing assertion.
await withSkillVersionCacheLock(db, companyId, async () => {
await db.update(heartbeatRuns).set({ status: "running", contextSnapshot: { skillVersions: { [skill.key]: version.id } } })
.where(eq(heartbeatRuns.id, run.id));
}, true);
} finally { release(); await conversion; upload.mockRestore(); }
expect((await conversion).results).toEqual([expect.objectContaining({ status: "converted" })]);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0])
.toMatchObject({ status: "running", contextSnapshot: { skillVersions: { [skill.key]: version.id } } });
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0]).toBeDefined();
});
it.each([true, false])("keeps catalog history unchanged on rejection and preserves its reset version (concurrent edit: %s)", async concurrentEdit => {
const companyId = await company(), directory = path.join(root, `catalog-copy-${randomUUID()}`), origin = `${directory}-original`;
await fs.mkdir(directory); await fs.mkdir(origin);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Installed edit");
await fs.writeFile(path.join(origin, "SKILL.md"), "# Original package");
const [skill] = await db.insert(companySkills).values({ companyId, key: "catalog-copy", slug: "catalog-copy", name: "Catalog",
markdown: "# Installed edit", sourceType: "catalog", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: {
sourceKind: "catalog", originSnapshotLocator: origin, userModifiedAt: new Date().toISOString(),
} }).returning();
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill.id, revisionNumber: 1,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Installed edit" }] }).returning();
await db.update(companySkills).set({ currentVersionId: version.id }).where(eq(companySkills.id, skill.id));
const storage = storageModule.getStorageService(), put = storage.putFile.bind(storage);
let changed = false;
const upload = vi.spyOn(storage, "putFile").mockImplementation(async input => {
const saved = await put(input);
if (!changed) {
changed = true;
if (concurrentEdit) await fs.writeFile(path.join(origin, "SKILL.md"), "# Concurrent original edit");
}
return saved;
});
try {
expect((await skillInstallationMaintenance(db, storage).installations({ id: skill.id, apply: true })).results)
.toEqual([expect.objectContaining({ status: concurrentEdit ? "blocked" : "converted" })]);
expect(changed).toBe(true);
const history = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill.id));
expect(history).toHaveLength(concurrentEdit ? 1 : 2);
expect(history.find(row => row.id === version.id)).toMatchObject({ fileInventory: version.fileInventory });
const [current] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
if (concurrentEdit) expect(current.metadata?.originVersionId).toBeUndefined();
else {
const original = history.find(row => row.id !== version.id)!;
expect(current.metadata?.originVersionId).toBe(original.id);
const store = skillPackageStore(db, storage), saved = await store.version(companyId, skill.id, original.id);
expect((await store.versionFiles(saved!)).find(file => file.path === "SKILL.md")?.content).toBe("# Original package");
}
expect(await fs.readFile(path.join(origin, "SKILL.md"), "utf8"))
.toBe(concurrentEdit ? "# Concurrent original edit" : "# Original package");
} finally { upload.mockRestore(); }
});
it("reports an unrecoverable legacy installation and leaves linked sources alone", async () => {
const companyId = await company();
const [legacy] = await db.insert(companySkills).values({ companyId, key: "legacy-missing", slug: "legacy-missing", name: "Missing",