/* RFC-0508: Participant data model migrator — transforms existing Person records in people//*.md by adding participantType: human, status, visibility, relationshipType, and consent placeholder fields if absent. Idempotent: running twice produces the same result. Do not remove existing Person fields — only add new Participant fields. Do not set real consent data — the consent placeholder uses the person's own slug as profileReviewer and a fixed consentDate. Operators must review and update after migration. RFC-0508: initial migrator — add participantType, status, visibility, relationshipType, consent to Person records. */ import path from "node:path"; import type { Migrator, SternsystemData, MigrationContext } from "./types.ts"; import { MigrationError } from "./types.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export const RFC_0508_MIGRATOR_ID = "rfc-0508"; const AFFILIATION_TO_RELATIONSHIP: Record = { founder: "founder", board: "board", team: "team", patron: "patron", author: "author", }; function addFieldToFrontmatter(content: string, fieldName: string, fieldValue: string): string { const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content); if (!frontmatterMatch) return content; const frontmatter = frontmatterMatch[1]!; if (new RegExp(`^${fieldName}:`, "m").test(frontmatter)) { return content; } const insertion = `${fieldName}: ${fieldValue}\n`; return content.replace(/^---\n([\s\S]*?)\n---/, `---\n$1\n${insertion}---`); } function extractAffiliations(content: string): string[] { const match = /^affiliations:\s*\[([^\]]*)\]/m.exec(content); if (!match) return []; return match[1]! .split(",") .map((s) => s.trim().replace(/["']/g, "")) .filter(Boolean); } function extractSlug(content: string): string { const match = /^slug:\s*"?([^"\n]+)"?/m.exec(content); return match ? match[1]!.trim() : ""; } function extractPageEnabled(content: string): boolean { const match = /^page:\s*\n\s*enabled:\s*(\w+)/m.exec(content); if (!match) return false; return match[1] === "true"; } export const rfc0508Migrator: Migrator = { id: RFC_0508_MIGRATOR_ID, fromVersion: "4.15.0", toVersion: "4.16.0", description: "Add participantType: human, status, visibility, relationshipType, and consent placeholder to Person records in people//*.md. Advances migratorCursor for RFC-0508 (Participant data model).", transform: async ( data: SternsystemData, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ) => { const peopleBaseDir = path.join(data.rootPath, "src", "content", "people"); let langDirs: string[]; try { langDirs = (await io.readdir(peopleBaseDir)).map((e) => e.name); } catch { ctx.logger.info(`[rfc-0508] no people directory at ${peopleBaseDir} — skipping`); langDirs = []; } for (const lang of langDirs) { const langPath = path.join(peopleBaseDir, lang); const stat = await io.stat(langPath).catch(() => null); if (!stat?.isDirectory) continue; const peopleFiles = (await io.readdir(langPath)).filter((f) => f.name.endsWith(".md")); for (const peopleFile of peopleFiles) { const filePath = path.join(langPath, peopleFile.name); let content: string; try { content = await io.readFile(filePath); } catch { continue; } let modified = false; let updated = content; // Add participantType: human if absent if (!/^participantType:/m.test(updated)) { updated = addFieldToFrontmatter(updated, "participantType", "human"); modified = true; } // Add status based on page.enabled if (!/^status:/m.test(updated)) { const pageEnabled = extractPageEnabled(updated); updated = addFieldToFrontmatter(updated, "status", pageEnabled ? "active" : "draft"); modified = true; } // Add visibility based on page.enabled if (!/^visibility:/m.test(updated)) { const pageEnabled = extractPageEnabled(updated); updated = addFieldToFrontmatter( updated, "visibility", pageEnabled ? "public" : "private", ); modified = true; } // Add relationshipType derived from affiliations if (!/^relationshipType:/m.test(updated)) { const affiliations = extractAffiliations(updated); const primaryAffiliation = affiliations[0] ?? "team"; const relationship = AFFILIATION_TO_RELATIONSHIP[primaryAffiliation] ?? "team"; updated = addFieldToFrontmatter(updated, "relationshipType", relationship); modified = true; } // Add consent placeholder for public humans if (!/^consent:/m.test(updated)) { const slug = extractSlug(updated); const pageEnabled = extractPageEnabled(updated); if (pageEnabled && slug) { const consentYaml = `consent:\n consentRecordId: "consent-${slug}"\n approvedFields: ["lifespan.born", "location", "bio", "photo", "sameAs"]\n consentDate: "2026-07-24"\n profileReviewer: "${slug}"`; const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(updated); if (frontmatterMatch) { updated = updated.replace(/^---\n([\s\S]*?)\n---/, `---\n$1\n${consentYaml}\n---`); modified = true; } } } if (modified) { try { await io.writeFile(filePath, updated); ctx.logger.info(`[rfc-0508] updated participant record: ${lang}/${peopleFile}`); } catch (err) { throw new MigrationError( RFC_0508_MIGRATOR_ID, filePath, "", `Failed to update participant record: ${(err as Error).message}`, ); } } } } return data; }, };