Policy Template Platform — Implementation Plan
Status: Planning
Scope: Immediate bug fixes → Phase 1 (seed catalog + import wizard) → Phase 2 (AI-assisted creation)
Related: PLATFORM_ADMIN.md · RBAC_Matrix.md
Diagnosis
Why tenant Policy Management never shows templates
The template and recommendation engines are fully built but the tenant-facing entry point was never wired up. Two disconnects cause the entire chain to fail silently:
Disconnect 1 — /policies/new bypasses TemplatePicker entirely
src/app/policies/new/page.tsx renders PolicyForm directly. The TemplatePicker component exists and works, but nothing ever calls it. The tenant sees only a blank form — no template suggestions, no recommendations panel, no indication that templates exist.
Disconnect 2 — createPolicy has no template fields
src/lib/services/policy-service.ts::createPolicy accepts title, category, department, purpose, policyStatement, and scope — none of which relate to templates. Even if a tenant selected a template, there is nowhere to store sourceTemplateId, sourceVersion, or sections[]. The policy would be created as a blank document with no link back to the template.
Downstream effect — tenant sees no templates anywhere
Because neither disconnect has been resolved, every tenant's /policies/new page shows a blank form. The recommendation engine (rule-engine.ts) is correct and functional; the rule created for Remote Work Security Policy is stored and active. The template won't surface until the TemplatePicker is wired in and the policy creation flow uses it.
Data model gap
Templates store content in sections[] (each section has title, body rich-text, isOptional, order). Tenant policies store content in flat fields: purpose (short text) and policyStatement (long text). These are structurally incompatible.
Resolution: When a policy is created from a template, populate sections[] on the policy document alongside the flat fields. The PolicyEditor already renders sections if present (confirmed by src/components/policies/PolicyEditor.tsx). Policies created from scratch continue to use the flat model.
Genkit status
genkit and genkit-cli are listed in package.json and in two dead scripts (genkit:dev, genkit:watch). No source files import either package — removal is safe and clean.
Part 0 — Immediate Fixes
These are blocking. Phase 1 cannot work until all four are resolved.
Fix 0.1 — Wire TemplatePicker as Step 0 of /policies/new
File: src/app/policies/new/page.tsx
Replace the current single-step page (renders PolicyForm immediately) with a two-step wizard:
- Step 0 — Template Discovery: Render
TemplatePicker. It calls/api/policies/recommendand shows matched templates ranked by weight. The user picks a template or clicks "Create from scratch" to skip. - Step 1 — Policy Form: Render
PolicyForm, pre-populated with sections from the chosen template (or blank if skipped).
The step state is local to the page component: type Step = 'pick-template' | 'fill-form' and selectedTemplateId: string | null.
Fix 0.2 — Add template adoption fields to createPolicy
Files: src/app/api/policies/route.ts, src/lib/services/policy-service.ts
Extend the create schema and service signature:
// New optional fields in createSchema (route.ts)
sourceTemplateId: z.string().optional(),
sourceVersion: z.number().int().optional(),
sections: z.array(PolicySectionSchema).optional(),
When sourceTemplateId is supplied, createPolicy stores it alongside the standard fields. The policy document gains:
sourceTemplateId: "tmpl-abc123"
sourceVersion: 1
sections: [ { id, title, body, isOptional, order } ... ]
Fix 0.3 — Pre-fill sections when adopting a template
File: src/app/policies/new/page.tsx (in the transition between Step 0 and Step 1)
After the tenant selects a template, fetch it: GET /api/platform-admin/policy-templates/:id — but this route is platform-admin only. Add a new tenant-accessible route:
GET /api/policies/templates/:id
Protected by withAuth (any authenticated tenant user), returns the template's sections[], title, category, and placeholders[]. Does not expose internal fields (authorId, changelog, etc.). This is the only cross-boundary read that is safe: published template content is public by design.
The pre-fill step substitutes known placeholder values:
- {{COMPANY_NAME}} → tenant's companyName from Firestore
- {{EFFECTIVE_DATE}} → today's date formatted
- {{JURISDICTION}} → tenant's jurisdiction (if set)
Unknown placeholders remain as {{TOKEN}} and are shown to the user as editable fields.
Fix 0.4 — Fallback when no recommendations exist
File: src/components/policies/TemplatePicker.tsx and src/app/api/policies/recommend/route.ts
When getRecommendationsForTenant returns [], the current UI shows "No template recommendations for your profile" with only a "Create from Scratch" button. This is a dead end.
New behaviour:
- Add query param: GET /api/policies/recommend?fallback=true
- When fallback=true and recommendations are empty, the endpoint returns all published templates ordered by updatedAt descending, with isRequired: false, weight: 0, and reasonText: 'General template — no specific recommendation for your profile.'
- TemplatePicker automatically adds ?fallback=true, so the tenant always sees something
- The component distinguishes "matched" vs "fallback" items with a subtle UI indicator
A separate concern: the platform admin should be notified when a tenant's profile matches zero rules. This belongs in a future health dashboard, not in this plan.
Part 1 — Seed Catalog and Import Wizard
1.1 Canonical source file
New file: db/policy-templates-catalog.ts
This is the single source of truth for the pre-built template library. It exports a typed array of CatalogEntry objects where each entry bundles a complete template definition with its associated rules. The file is linked from PLATFORM_ADMIN.md so anyone editing sample content knows where to look.
export interface CatalogEntry {
template: {
title: string;
category: PolicyCategory;
sections: PolicySection[]; // full content with {{PLACEHOLDER}} tokens
placeholders: string[]; // list of token names used
};
rules: Array<{
industryCode: string | null; // 2-digit NAICS, or null = any
jurisdiction: string | null; // ISO 3166-2, or null = any
businessFlags: string[];
weight: number;
isRequired: boolean;
reasonText: string;
}>;
}
export const POLICY_CATALOG: CatalogEntry[] = [ ... ];
12 templates with sections and pre-wired rules:
| # | Template | Category | Default rules |
|---|---|---|---|
| 1 | Code of Conduct | HR | Any industry, any state · w:30 · not required |
| 2 | Payroll Policy | HR | Any industry, any state · w:20 |
| 3 | Equal Employment Opportunity | HR | NAICS 62 · required · w:80; NAICS 62 · required · w:70; any · w:20 |
| 4 | BYOD Policy | IT Security | remote_workers · w:40; byod_devices · w:60 |
| 5 | Remote Work Security Policy | IT Security | remote_workers · w:30; NAICS 54 + US-CA + processes_pii · required · w:70 |
| 6 | AI Acceptable Use Policy | IT Security | Any · w:25 |
| 7 | IT Security Policy | IT Security | Any · w:40 |
| 8 | HR Policy | HR | Any · w:20 |
| 9 | Privacy and Data Protection Policy | Data Privacy | processes_pii · w:50; processes_pii + US-CA · required · w:80 |
| 10 | Physical Security Policy | Physical Security | NAICS 32 · required · w:60; NAICS 33 · required · w:60 |
| 11 | Health and Safety Policy | Health & Safety | NAICS 32 · required · w:90; NAICS 33 · required · w:90; NAICS 62 · w:50 |
| 12 | Drug and Alcohol Policy | HR | NAICS 32 · w:70; NAICS 33 · w:70; NAICS 62 · w:40 |
NAICS coverage across the five target sectors:
| NAICS | Sector | Required templates | Recommended |
|---|---|---|---|
| 621 | Ambulatory Health Care | EEO, Privacy | Code of Conduct, IT Security, HR, AI Use |
| 624 | Social Assistance | EEO, Health & Safety | Code of Conduct, HR, Privacy, AI Use, Drug & Alcohol |
| 3253 | Agricultural Chemicals | Health & Safety, Physical Security | Drug & Alcohol, IT Security |
| 3261 | Plastics Manufacturing | Health & Safety, Physical Security | Drug & Alcohol, Code of Conduct |
| 3312 | Steel Manufacturing | Health & Safety, Physical Security | Drug & Alcohol, Code of Conduct |
Sections, placeholder lists, and full rule
reasonTextvalues are written indb/policy-templates-catalog.ts.PLATFORM_ADMIN.mdlinks to this file as the canonical content reference.
1.2 Seed script
New file: scripts/seed-policy-templates.ts
Behaviour:
1. Reads POLICY_CATALOG from the catalog file
2. For each entry: checks policy_templates collection for an existing document with the same title — skips if found (idempotent)
3. Creates the template document (status: draft, version: 0)
4. Immediately publishes it (status: published, version: 1, changelog entry: "Initial release from AssetGuard catalog")
5. Creates each rule document in template_rules
6. Calls invalidateRuleCache() once at the end
7. Prints a summary: ✓ Created 10 templates (2 already existed). Created 28 rules.
New script entry in package.json:
"seed:templates": "tsx read-env.ts && tsx scripts/seed-policy-templates.ts"
Usage after a fresh environment setup:
pnpm seed:templates
1.3 Platform-admin import wizard
New route: src/app/platform-admin/wizard/page.tsx
Hub card: Add "Import Catalog" card to src/app/platform-admin/page.tsx (claim: platform_admin)
The wizard is the browser-based counterpart to the seed script — it lets a platform admin review and import catalog templates without CLI access. Three steps:
Step 1 — Browse Catalog
- Fetches catalog entries from a new internal endpoint: GET /api/platform-admin/catalog
- This route reads POLICY_CATALOG at runtime and cross-references with Firestore to mark which entries already exist
- Returns each entry with alreadyImported: boolean
- Grid of cards, grouped by category
- Each card shows: title, category, rule count ("3 rules pre-wired"), NAICS tags
- Cards for already-imported templates are greyed out with a "Imported" badge
- Multi-select checkboxes; "Select All New" shortcut button
Step 2 — Review - For each selected template: expandable section list (read-only preview) and rule list - Admin can edit title or category, toggle optional sections on/off, or adjust rule weights before import - "Back" returns to Step 1 without losing selections
Step 3 — Import
- "Import N templates" button
- Progress indicator: calls POST /api/platform-admin/catalog/import with the modified entries
- Server creates templates (draft), publishes them, creates rules, invalidates cache
- Results: "12 templates imported · 28 rules created"
- "View Templates →" link to /platform-admin/policy-templates
Part 2 — AI-Assisted Template Creation
2.1 Remove Genkit
genkit and genkit-cli are in package.json but no source file imports them. Removal is clean.
pnpm remove genkit genkit-cli
Remove from package.json scripts:
"genkit:dev": "genkit start -- tsx src/ai/dev.ts",
"genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
2.2 Add Anthropic SDK
pnpm add @anthropic-ai/sdk
Add to .env and .env.example:
ANTHROPIC_API_KEY=sk-ant-...
New file: src/lib/ai/client.ts
Exports a singleton Anthropic client. Throws at import time if ANTHROPIC_API_KEY is missing (catches misconfiguration early).
2.3 Template generation API
New file: src/app/api/platform-admin/ai/generate-template/route.ts
Protected by withPlatformAdmin.
Request:
{ "description": "A policy for employees using personal devices to access company data", "category": "IT Security" }
Response: Server-Sent Events stream. Each event is a partial JSON patch. The client assembles the final object:
{ "title": "...", "sections": [...], "suggestedRules": [...] }
Model: claude-sonnet-4-6
Strategy: Tool use (tool_use content block) with a single generate_template tool that enforces the output schema. This avoids brittle JSON-in-prose parsing.
System prompt outline:
You are a compliance policy writer for AssetGuard, a multi-tenant risk management platform.
Output format:
- title: concise policy name
- sections[]: each has title, body (markdown, second-person, use {{PLACEHOLDER}} for company-specific values), isOptional
Supported placeholders: {{COMPANY_NAME}}, {{EFFECTIVE_DATE}}, {{JURISDICTION}}, {{CONTACT_EMAIL}}, {{DEPARTMENT}}
- suggestedRules[]: each has industryCode (2-digit NAICS or null), jurisdiction (ISO 3166-2 or null),
businessFlags (from the allowed list), weight (1–100), isRequired, reasonText
Allowed business flags: remote_workers, processes_pii, fleet_vehicles, byod_devices, drug_testing,
handles_cash, operates_heavy_equipment, multi_site, public_facing
Write for a US-based audience. Be specific and actionable. Avoid legal disclaimers.
Rule inference in the prompt: The prompt explicitly instructs Claude to infer rules from the description. Examples given in the system prompt:
- "HIPAA", "healthcare", "patient data" → NAICS 62, processes_pii, isRequired: true
- "remote work", "home office" → remote_workers, weight 30
- "fleet", "driver", "vehicle" → fleet_vehicles, weight 50
- "personal device", "BYOD" → byod_devices, weight 60
- "California", "CCPA" → jurisdiction US-CA, processes_pii
- "chemical", "manufacturing", "factory" → NAICS 32, safety-related flags
2.4 AI generation UI
Modified file: src/app/platform-admin/policy-templates/new/page.tsx
Add a tab bar at the top: "From Scratch" (current blank form) | "Generate with AI" (new)
"Generate with AI" tab — three-panel layout:
Left panel — Input (narrow): - Textarea: "Describe the policy…" (placeholder: e.g. "A policy for remote employees accessing company systems from personal devices") - Category dropdown (pre-filled from description if AI detects it) - "Generate" button
Centre panel — Sections preview:
- Sections appear as they stream in — each section title appears first, then body fills in character by character
- Each section has an inline edit button (opens the TipTap editor from TemplateEditorForm)
- Optional sections have a toggle to include/exclude
Right panel — Suggested Rules:
- Each suggested rule rendered as a card: industry badge, state badge, flag badges, weight slider, Required toggle, reason text
- Accept / Reject buttons per rule
- "Add custom rule" opens the RuleBuilderForm dialog
Footer:
- "Save as Draft" → calls existing POST /api/platform-admin/policy-templates then creates accepted rules
- "Start Over" → clears all state
File Inventory
New files
| File | Purpose |
|---|---|
db/policy-templates-catalog.ts |
Canonical template + rule definitions (12 templates) |
scripts/seed-policy-templates.ts |
CLI seed script — idempotent bulk import |
src/app/api/policies/templates/[id]/route.ts |
Tenant-accessible published template read (GET only) |
src/app/api/platform-admin/catalog/route.ts |
GET catalog with already-imported flags |
src/app/api/platform-admin/catalog/import/route.ts |
POST bulk import from wizard Step 3 |
src/app/api/platform-admin/ai/generate-template/route.ts |
AI generation endpoint (SSE streaming) |
src/app/platform-admin/wizard/page.tsx |
Import wizard (3-step) |
src/lib/ai/client.ts |
Anthropic client singleton |
Modified files
| File | Change |
|---|---|
src/app/policies/new/page.tsx |
Add Step 0: TemplatePicker; pass template adoption data to PolicyForm |
src/app/api/policies/route.ts |
Add sourceTemplateId, sourceVersion, sections[] to createSchema |
src/lib/services/policy-service.ts |
Accept and persist template adoption fields in createPolicy |
src/components/policies/TemplatePicker.tsx |
Add ?fallback=true mode; distinguish matched vs fallback items |
src/app/api/policies/recommend/route.ts |
Support ?fallback=true query param |
src/app/platform-admin/policy-templates/new/page.tsx |
Add "Generate with AI" tab |
src/app/platform-admin/page.tsx |
Add "Import Catalog" hub card |
package.json |
Remove genkit/genkit-cli; add @anthropic-ai/sdk; add seed:templates script |
.env.example |
Add ANTHROPIC_API_KEY |
docs/developer-guide/PLATFORM_ADMIN.md |
Link to db/policy-templates-catalog.ts as canonical content source; add Phase 1 and Phase 2 operating procedures |
Implementation Sequence
The following order avoids building on top of broken foundations:
0.1 Wire TemplatePicker into /policies/new
0.2 Add sourceTemplateId + sections to createPolicy (API + service)
0.3 Add GET /api/policies/templates/:id (tenant-accessible template read)
0.4 TemplatePicker fallback when recommendations are empty
↓ at this point a tenant can pick a template and create a policy from it
1.1 Write db/policy-templates-catalog.ts (12 templates + rules, full section content)
1.2 Write scripts/seed-policy-templates.ts
1.3 Build platform-admin import wizard
↓ the catalog is seeded; tenants see relevant recommendations
2.1 Remove Genkit; add @anthropic-ai/sdk
2.2 Write src/lib/ai/client.ts
2.3 Write generate-template API route (tool use, SSE)
2.4 Add "Generate with AI" tab to /platform-admin/policy-templates/new
Future Enhancements
Content-as-files: Markdown directory structure for template maintenance
The current approach embeds section HTML directly in db/policy-templates-catalog.ts as TypeScript template literals. This is acceptable for the initial catalog but becomes a maintenance burden as template text evolves — HTML in TS strings produces noisy diffs, requires a developer to edit, and is opaque to compliance reviewers.
Recommended future structure: one Markdown file per section, organized in named directories.
db/
policy-templates/
remote-work-security-policy/
meta.json ← title, category, placeholders, rules[]
01-purpose.md
02-scope.md
03-network-security.md
04-data-handling.md
05-incident-reporting.md
06-compliance.md ← isOptional: true (frontmatter)
code-of-conduct/
meta.json
01-purpose.md
...
Each .md file carries its metadata in YAML frontmatter:
---
title: Network Security
isOptional: false
order: 3
---
Employees must use the company-provided VPN when accessing internal systems.
Use of public or unsecured Wi-Fi without VPN is prohibited.
Home routers must use WPA2 or WPA3 encryption. Default router credentials
must be changed. Contact {{CONTACT_EMAIL}} with questions.
The seed script would replace the static array with a directory scanner that uses gray-matter for frontmatter parsing and marked (or remark) to convert Markdown to TipTap-compatible HTML at seed time. db/policy-templates-catalog.ts either becomes a thin index of directory paths or is eliminated entirely.
What this enables:
- A compliance officer or lawyer can open any .md file in VS Code, Obsidian, or the GitHub web editor and edit it with a Markdown preview. {{PLACEHOLDER}} tokens are fully visible.
- PR diffs show exactly what changed in the policy text — not a wall of escaped HTML strings.
- Adding a new template is "create a directory with a meta.json and some .md files" — no TypeScript changes needed.
- The db/policy-templates-catalog.ts TypeScript catalog can be retired entirely or repurposed as a validation schema.
Trade-off:
Requires adding gray-matter and marked (or remark) as dev dependencies, and the seed script becomes a directory scanner. If the HTML produced by marked diverges from TipTap's conventions (e.g. heading levels, list nesting), a post-processing step may be needed. Starting with .html files instead of .md is a valid alternative if the editing audience is technical.
Migration path from current approach:
The existing POLICY_CATALOG entries can be exported as Markdown files by a one-time conversion script. The section HTML can be reverse-converted to Markdown using turndown. After the conversion, seed:templates would be updated to read from db/policy-templates/ directories and the TypeScript catalog file removed.
Open Questions
Before starting Phase 2 implementation:
-
Streaming in Next.js App Router: The
generate-templateroute uses Server-Sent Events. Verify that the platform-admin layout'swithPlatformAdminHOF is compatible with streaming responses (it should be, as it verifies the token synchronously before yielding to the handler, but worth confirming). -
Section body format: Template sections use TipTap rich-text (HTML). The AI will produce Markdown. A
markdownToHtmlconversion step is needed at the boundary before storing. -
Placeholder injection UX: When a tenant adopts a template and the sections contain
{{COMPANY_NAME}}etc., should the form auto-replace known tokens before showing the editor, or show them as editable highlighted fields? The latter gives more control and is recommended. -
NAICS prefix matching in rules: The current
matchTemplatesfunction checkstenant.naicsCode.startsWith(rule.industryCode). The catalog uses 2-digit codes (e.g.62). Confirm that tenants'naicsCodefield is stored in a format that satisfies this check (e.g.6211starts with62✓).