@legionworks/facet 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +50 -21
  2. package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
  3. package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
  4. package/dist/gallery/frame/frame.css +8 -1
  5. package/dist/gallery/frame/runtime/chart.js +23 -1
  6. package/dist/gallery/index.html +4 -2
  7. package/docs/reference/cli.md +29 -1
  8. package/docs/reference/export.md +4 -0
  9. package/docs/reference/mcp.md +16 -8
  10. package/docs/reference/security.md +7 -0
  11. package/docs/reference/storage.md +6 -1
  12. package/docs/reference/tsx.md +14 -0
  13. package/docs/reference/validation.md +34 -6
  14. package/package.json +4 -4
  15. package/skills/facet/SKILL.md +24 -1
  16. package/src/cli/commands/promote.ts +1 -0
  17. package/src/cli/commands/templates.ts +25 -0
  18. package/src/cli/main.ts +4 -0
  19. package/src/cli/parser.ts +4 -0
  20. package/src/cli/presenter.ts +48 -0
  21. package/src/gallery-web/app.ts +45 -1
  22. package/src/gallery-web/favicon.ts +1 -0
  23. package/src/gallery-web/frame/renderers/chart.ts +31 -1
  24. package/src/gallery-web/frame/styles/frame.css +8 -1
  25. package/src/gallery-web/frame-html.ts +2 -1
  26. package/src/gallery-web/index.html +3 -1
  27. package/src/gallery-web/styles/verdict.css +19 -4
  28. package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
  29. package/src/harness-adapters/mcp/main.ts +2 -0
  30. package/src/harness-adapters/mcp/server.ts +92 -69
  31. package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
  32. package/src/service/dispatcher.ts +10 -0
  33. package/src/service/router-guards.ts +2 -0
  34. package/src/service/store/migrations.ts +12 -2
  35. package/src/service/store/repository-lifecycle.ts +91 -19
  36. package/src/service/store/repository.ts +60 -2
  37. package/src/service/store/schema.ts +4 -0
  38. package/src/service/stored-verdict.ts +3 -0
  39. package/src/shared/contracts/artifact.ts +1 -0
  40. package/src/shared/contracts/commands/index.ts +10 -0
  41. package/src/shared/contracts/commands/names.ts +2 -0
  42. package/src/shared/contracts/commands/requests.ts +7 -0
  43. package/src/shared/contracts/commands/results.ts +20 -1
  44. package/src/shared/contracts/promotion.ts +16 -0
  45. package/src/shared/contracts/validation.ts +7 -0
  46. package/src/shared/errors/facet-error.ts +2 -0
  47. package/src/shared/errors/store-error.ts +4 -0
  48. package/src/shared/html/artifact-main.ts +3 -0
  49. package/src/shared/storage-version.ts +1 -1
  50. package/src/validation/tier0/dom-shim.ts +3 -18
  51. package/src/validation/tier0/markdown.ts +28 -5
  52. package/src/validation/tier0/mermaid.ts +17 -8
  53. package/src/validation/tier0/worker-dispatch.ts +1 -1
  54. package/src/validation/tier1/entries/tsx.ts +1 -0
  55. package/src/validation/tier1/harness.ts +2 -1
  56. package/src/validation/tier1/isolated-probe.ts +4 -0
  57. package/src/validation/tier1/protocol-probe.ts +37 -0
  58. package/src/validation/tier1/runner.ts +21 -4
  59. package/src/validation/tier1/verdict.ts +24 -39
@@ -1,11 +1,15 @@
1
1
  import { mkdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
2
3
 
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { z, ZodError } from "zod";
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
6
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
+ import { type ZodType, ZodError } from "zod";
5
8
  import { FACET_VERSION } from "../../shared/version";
6
9
 
7
10
  import { buildFacetArgs, FacetBridgeError, invokeFacet } from "./cli-bridge";
8
11
  import {
12
+ CreateToolSchema,
9
13
  ExportToolSchema,
10
14
  OpenUrlToolSchema,
11
15
  PublishToolSchema,
@@ -41,12 +45,10 @@ function errorEnvelope(cause: unknown): FacetEnvelope<never> {
41
45
  return errEnvelope(requestId(), body);
42
46
  }
43
47
 
44
- const LooseToolInputSchema = z.record(z.unknown());
45
-
46
48
  function toolResult(envelope: FacetEnvelope<unknown>) {
47
49
  return {
48
50
  content: [{ type: "text" as const, text: JSON.stringify(envelope) }],
49
- ...(envelope.ok ? {} : { isError: true }),
51
+ isError: !envelope.ok,
50
52
  };
51
53
  }
52
54
 
@@ -90,75 +92,96 @@ function publishSource(input: PublishToolInput): {
90
92
  });
91
93
  }
92
94
 
93
- export function createFacetMcpServer(): McpServer {
94
- const server = new McpServer({ name: "facet", version: FACET_VERSION });
95
-
96
- server.registerTool(
97
- "facet_export",
98
- {
99
- description:
100
- "Export source or stored render evidence into outDir. Check envelope.ok for transport success; a successful publish verdict remains a separate data.verdict.status decision.",
101
- inputSchema: LooseToolInputSchema,
102
- },
103
- async (args) =>
104
- withInput(args, ExportToolSchema.parse, async (input) => {
105
- mkdirSync(input.outDir, { recursive: true });
106
- return invoke(buildFacetArgs("export", input), { cwd: input.outDir });
107
- }),
108
- );
95
+ function defineTool<T>(
96
+ schema: ZodType<T>,
97
+ description: string,
98
+ run: (input: T) => Promise<ReturnType<typeof toolResult>>,
99
+ ) {
100
+ return {
101
+ schema,
102
+ description,
103
+ execute: (args: unknown) => withInput(args, (input) => schema.parse(input), run),
104
+ };
105
+ }
109
106
 
110
- server.registerTool(
111
- "facet_open_url",
112
- {
113
- description:
114
- "Return a Facet gallery frameUrl without launching a browser. This always invokes facet open --no-launch; agents must not launch desktop display.",
115
- inputSchema: LooseToolInputSchema,
116
- },
117
- async (args) =>
118
- withInput(args, OpenUrlToolSchema.parse, async (input) =>
119
- invoke(buildFacetArgs("open", input)),
120
- ),
107
+ export function createFacetMcpServer(): Server {
108
+ const server = new Server(
109
+ { name: "facet", version: FACET_VERSION },
110
+ { capabilities: { tools: {} } },
121
111
  );
122
-
123
- server.registerTool(
124
- "facet_publish",
125
- {
126
- description:
127
- "Publish exactly one sourceText or local file. Check envelope.ok separately from data.verdict.status: stored verdict status error is not a transport failure.",
128
- inputSchema: LooseToolInputSchema,
129
- },
130
- async (args) =>
131
- withInput(args, PublishToolSchema.parse, async (input) => {
112
+ // The high-level SDK validates arguments before the handler and emits plain-text errors.
113
+ // Wire schemas and handler parsing are separate so invalid calls retain Facet envelopes.
114
+ const tools = {
115
+ facet_create: defineTool(
116
+ CreateToolSchema,
117
+ "Create an artifact from projectId, slug, and title before publishing source.",
118
+ async (input) => invoke(buildFacetArgs("create", input)),
119
+ ),
120
+ facet_export: defineTool(
121
+ ExportToolSchema,
122
+ "Export source or stored render evidence into outDir. Check envelope.ok for transport success; a successful publish verdict remains a separate data.verdict.status decision.",
123
+ async (input) => {
124
+ const outDir = resolve(input.outDir);
125
+ try {
126
+ mkdirSync(outDir, { recursive: true });
127
+ } catch (cause) {
128
+ throw new FacetBridgeError(
129
+ {
130
+ code: "output_unwritable",
131
+ message: `Cannot write export output: ${outDir}`,
132
+ retryable: false,
133
+ details: { out: outDir },
134
+ },
135
+ cause,
136
+ );
137
+ }
138
+ return invoke(buildFacetArgs("export", input), { cwd: outDir });
139
+ },
140
+ ),
141
+ facet_open_url: defineTool(
142
+ OpenUrlToolSchema,
143
+ "Return a Facet gallery frameUrl without launching a browser. This always invokes facet open --no-launch; agents must not launch desktop display.",
144
+ async (input) => invoke(buildFacetArgs("open", input)),
145
+ ),
146
+ facet_publish: defineTool(
147
+ PublishToolSchema,
148
+ "Publish exactly one sourceText or local file. Check envelope.ok separately from data.verdict.status: stored verdict status error is not a transport failure.",
149
+ async (input) => {
132
150
  const source = publishSource(input);
133
151
  return invoke(buildFacetArgs("publish", { ...input, ...source }), source);
152
+ },
153
+ ),
154
+ facet_read_back: defineTool(
155
+ ReadBackToolSchema,
156
+ "Read back the latest or revision-bound stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence; inspect envelope.ok before verdict status.",
157
+ async (input) => invoke(buildFacetArgs("read_back", input)),
158
+ ),
159
+ facet_status: defineTool(
160
+ StatusToolSchema,
161
+ "Read Facet status. Set start only when activation is intended; envelope.ok reports command transport and never replaces verdict.status inspection.",
162
+ async (input) => invoke(buildFacetArgs("status", input)),
163
+ ),
164
+ };
165
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
166
+ tools: Object.entries(tools).map(([name, tool]) => ({
167
+ name,
168
+ description: tool.description,
169
+ inputSchema: toJsonSchemaCompat(tool.schema, { pipeStrategy: "input" }),
170
+ })),
171
+ }));
172
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
173
+ const tool = Object.hasOwn(tools, params.name)
174
+ ? tools[params.name as keyof typeof tools]
175
+ : null;
176
+ if (tool) return tool.execute(params.arguments);
177
+ return toolResult(
178
+ errEnvelope(requestId(), {
179
+ code: "invalid_request",
180
+ message: `Unknown MCP tool '${params.name}'`,
181
+ retryable: false,
134
182
  }),
135
- );
136
-
137
- server.registerTool(
138
- "facet_read_back",
139
- {
140
- description:
141
- "Read back the latest or revision-bound stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence; inspect envelope.ok before verdict status.",
142
- inputSchema: LooseToolInputSchema,
143
- },
144
- async (args) =>
145
- withInput(args, ReadBackToolSchema.parse, async (input) =>
146
- invoke(buildFacetArgs("read_back", input)),
147
- ),
148
- );
149
-
150
- server.registerTool(
151
- "facet_status",
152
- {
153
- description:
154
- "Read Facet status. Set start only when activation is intended; envelope.ok reports command transport and never replaces verdict.status inspection.",
155
- inputSchema: LooseToolInputSchema,
156
- },
157
- async (args) =>
158
- withInput(args, StatusToolSchema.parse, async (input) =>
159
- invoke(buildFacetArgs("status", input)),
160
- ),
161
- );
183
+ );
184
+ });
162
185
 
163
186
  return server;
164
187
  }
@@ -6,6 +6,13 @@ const RevisionShaSchema = z.string().regex(/^[a-f0-9]{64}$/);
6
6
  const ExecutionSchema = z.enum(["static", "interactive"]);
7
7
  const ReadBackTierSchema = z.union([z.literal(0), z.literal(1), z.literal("visual")]);
8
8
 
9
+ export const CreateToolShape = {
10
+ projectId: z.string().min(1),
11
+ slug: z.string().min(1),
12
+ title: z.string().min(1),
13
+ };
14
+ export const CreateToolSchema = z.object(CreateToolShape).strict();
15
+
9
16
  export const PublishToolShape = {
10
17
  artifactId: z.string().min(1),
11
18
  type: ArtifactTypeSchema,
@@ -513,6 +513,13 @@ export async function dispatch(
513
513
  nextCursor: null,
514
514
  };
515
515
  }
516
+ case "templates": {
517
+ return {
518
+ command: "templates",
519
+ requestId,
520
+ templates: deps.repository.listTemplates(command.limit),
521
+ };
522
+ }
516
523
  case "readBack": {
517
524
  const revision =
518
525
  command.revisionSha === undefined
@@ -642,6 +649,9 @@ export async function dispatch(
642
649
  revisionId: command.revisionId,
643
650
  name: command.name,
644
651
  promotedBy: command.promotedBy,
652
+ ...(command.allowUnverified !== undefined
653
+ ? { allowUnverified: command.allowUnverified }
654
+ : {}),
645
655
  ...(command.description !== undefined ? { description: command.description } : {}),
646
656
  });
647
657
  return { command: "promote", requestId, template };
@@ -222,6 +222,8 @@ export function statusFor(error: FacetError): number {
222
222
  case "tier0_unavailable":
223
223
  return 503;
224
224
  case "duplicate_revision":
225
+ case "template_name_taken":
226
+ case "promotion_refused":
225
227
  case "constraint":
226
228
  case "foreign_key":
227
229
  case "immutable_revision":
@@ -12,8 +12,8 @@ import {
12
12
  V7_SCHEMA_FRAGMENT,
13
13
  V8_SCHEMA_FRAGMENT,
14
14
  V9_SCHEMA_FRAGMENT,
15
+ V10_SCHEMA_FRAGMENT,
15
16
  } from "./schema";
16
- import { CURRENT_STORAGE_VERSION } from "../../shared/storage-version";
17
17
 
18
18
  export interface MigrationOptions {
19
19
  readonly beforeRecordVersion?: (version: number) => void;
@@ -106,13 +106,23 @@ const MIGRATION_STEPS: readonly MigrationStep[] = [
106
106
  requiresForeignKeyDisable: true,
107
107
  },
108
108
  {
109
- version: CURRENT_STORAGE_VERSION,
109
+ version: 9,
110
110
  apply: (db) => {
111
111
  db.exec(V9_SCHEMA_FRAGMENT);
112
112
  },
113
113
  },
114
+ {
115
+ version: 10,
116
+ apply: (db) => {
117
+ db.exec(V10_SCHEMA_FRAGMENT);
118
+ },
119
+ },
114
120
  ];
115
121
 
122
+ export function latestMigrationVersion(): number {
123
+ return MIGRATION_STEPS.at(-1)?.version ?? 0;
124
+ }
125
+
116
126
  export function runMigrations(db: Database, options: MigrationOptions = {}): void {
117
127
  try {
118
128
  db.exec(
@@ -2,6 +2,11 @@ import type { Database } from "bun:sqlite";
2
2
 
3
3
  import { type Template, TemplateSchema } from "../../shared/contracts/artifact";
4
4
  import { now } from "../../shared/util/time";
5
+ import { FacetError } from "../../shared/errors/facet-error";
6
+ import { PROMOTION_GATE } from "../../shared/contracts/promotion";
7
+ import type { RenderStatus } from "../../shared/contracts/validation";
8
+ import { verdictFromStoredRun } from "../stored-verdict";
9
+ import type { ArtifactRepository } from "./repository";
5
10
  import { asStoreError, FacetStoreError } from "./database";
6
11
 
7
12
  export interface TemplateInput {
@@ -11,6 +16,7 @@ export interface TemplateInput {
11
16
  readonly description?: string | null;
12
17
  readonly promotedBy: string;
13
18
  readonly promotedAt?: string;
19
+ readonly promotionOverride?: string | null;
14
20
  }
15
21
 
16
22
  export interface PromoteRevisionInput {
@@ -20,6 +26,7 @@ export interface PromoteRevisionInput {
20
26
  readonly description?: string | null;
21
27
  readonly promotedBy: string;
22
28
  readonly promotedAt?: string;
29
+ readonly allowUnverified?: boolean;
23
30
  }
24
31
 
25
32
  export function evictRevisions(
@@ -59,23 +66,75 @@ export function evictRevisions(
59
66
  }
60
67
  }
61
68
 
62
- export function promoteRevision(db: Database, input: PromoteRevisionInput): Template {
63
- const owner = db
64
- .query("SELECT artifact_id FROM revisions WHERE id = ?")
65
- .get(input.revisionId) as { artifact_id: string } | null;
66
- if (!owner) throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
67
- // The template table has independent foreign keys on artifact_id and
68
- // revision_id, not a composite ownership constraint, so an explicit
69
- // artifactId that disagrees with the revision's real owner would
70
- // otherwise insert silently — instantiation would then copy the wrong
71
- // artifact's bytes under the caller-supplied artifact's identity.
72
- if (input.artifactId !== undefined && input.artifactId !== owner.artifact_id) {
73
- throw new FacetStoreError(
74
- "foreign_key",
75
- `Revision ${input.revisionId} belongs to artifact ${owner.artifact_id}, not ${input.artifactId}`,
76
- );
77
- }
78
- return createTemplate(db, { ...input, artifactId: owner.artifact_id });
69
+ export function promoteRevision(
70
+ db: Database,
71
+ repository: ArtifactRepository,
72
+ input: PromoteRevisionInput,
73
+ ): Template {
74
+ return db
75
+ .transaction(() => {
76
+ const owner = db
77
+ .query("SELECT artifact_id FROM revisions WHERE id = ?")
78
+ .get(input.revisionId) as { artifact_id: string } | null;
79
+ if (!owner)
80
+ throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
81
+ // The template table has independent foreign keys on artifact_id and
82
+ // revision_id, not a composite ownership constraint, so an explicit
83
+ // artifactId that disagrees with the revision's real owner would
84
+ // otherwise insert silently — instantiation would then copy the wrong
85
+ // artifact's bytes under the caller-supplied artifact's identity.
86
+ if (input.artifactId !== undefined && input.artifactId !== owner.artifact_id) {
87
+ throw new FacetStoreError(
88
+ "foreign_key",
89
+ `Revision ${input.revisionId} belongs to artifact ${owner.artifact_id}, not ${input.artifactId}`,
90
+ );
91
+ }
92
+ const revision = repository.getRevisionById(input.revisionId);
93
+ if (revision === null)
94
+ throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
95
+ const tier1 = repository.listRenderRuns({ revisionId: revision.id, tier: 1 })[0];
96
+ const tier0 = repository.listRenderRuns({ revisionId: revision.id, tier: 0 })[0];
97
+ // Read the marker from the reconstructed verdict object rather than
98
+ // the raw `insecure_json` column — `verdictFromStoredRun` is the single
99
+ // source of truth for cross-boundary reconstruction and keeps the
100
+ // precedence between status, tier, and marker consistent with the
101
+ // dispatcher's wire form.
102
+ const tier1Verdict = tier1 === undefined ? null : verdictFromStoredRun(revision, tier1);
103
+ const tier0Verdict = tier0 === undefined ? null : verdictFromStoredRun(revision, tier0);
104
+ const tier1Status = tier1Verdict === null ? null : tier1Verdict.status;
105
+ const tier0Status = tier0Verdict === null ? null : tier0Verdict.status;
106
+ // Level 3 opts out of the validation contract (publish skips Tier 0). A
107
+ // visual read-back still runs the browser, but a result carrying
108
+ // `insecure.level === 3` is not trusted verification for a template, so
109
+ // refuse even when the status alone would allow. Levels 1 and 2 keep the
110
+ // status decision: they run real validators under relaxed sandboxing.
111
+ const insecureLevel3 = tier1Verdict !== null && tier1Verdict.insecure?.level === 3;
112
+ const reason =
113
+ tier1Status === null
114
+ ? "no_visual_verification"
115
+ : tier0Status === "error"
116
+ ? "error"
117
+ : insecureLevel3
118
+ ? "insecure:unvalidated"
119
+ : PROMOTION_GATE[tier1Status as RenderStatus] === "allow"
120
+ ? null
121
+ : tier1Status;
122
+ if (reason !== null && input.allowUnverified !== true) {
123
+ throw new FacetError(
124
+ "promotion_refused",
125
+ `Promotion refused (${reason}). Run facet read-back --artifact-id ${owner.artifact_id} --tier visual on revision ${input.revisionId}, or pass --allow-unverified to record an override.`,
126
+ {
127
+ details: { revisionId: input.revisionId, reason, tier1Status, tier0Status },
128
+ },
129
+ );
130
+ }
131
+ return createTemplate(db, {
132
+ ...input,
133
+ artifactId: owner.artifact_id,
134
+ promotionOverride: reason,
135
+ });
136
+ })
137
+ .immediate();
79
138
  }
80
139
 
81
140
  export function createTemplate(db: Database, input: TemplateInput): Template {
@@ -88,10 +147,11 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
88
147
  description: input.description ?? null,
89
148
  promotedBy: input.promotedBy,
90
149
  promotedAt,
150
+ promotionOverride: input.promotionOverride ?? null,
91
151
  };
92
152
  try {
93
153
  db.query(
94
- "INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
154
+ "INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
95
155
  ).run(
96
156
  value.id,
97
157
  value.artifactId,
@@ -100,10 +160,22 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
100
160
  value.description,
101
161
  value.promotedBy,
102
162
  value.promotedAt,
163
+ value.promotionOverride,
103
164
  );
104
165
  return TemplateSchema.parse(value);
105
166
  } catch (error) {
106
- throw asStoreError(error);
167
+ const mapped = asStoreError(error);
168
+ if (mapped.code === "template_name_taken") {
169
+ throw new FacetStoreError(
170
+ "template_name_taken",
171
+ `Template name already exists: ${input.name}`,
172
+ {
173
+ cause: error,
174
+ details: { name: input.name },
175
+ },
176
+ );
177
+ }
178
+ throw mapped;
107
179
  }
108
180
  }
109
181
 
@@ -32,6 +32,7 @@ import {
32
32
  type TemplateInput,
33
33
  } from "./repository-lifecycle";
34
34
  import { enforceEvidenceRetention, removeUnreferencedEvidence } from "./evidence-retention";
35
+ import { latestStoredVerdict } from "../stored-verdict";
35
36
 
36
37
  interface ProjectInput {
37
38
  readonly projectRoot: string;
@@ -407,7 +408,7 @@ export class ArtifactRepository {
407
408
  try {
408
409
  const row = this.db
409
410
  .query(
410
- "SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
411
+ "SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
411
412
  )
412
413
  .get(name) as {
413
414
  id: string;
@@ -417,6 +418,7 @@ export class ArtifactRepository {
417
418
  description: string | null;
418
419
  promoted_by: string;
419
420
  promoted_at: string;
421
+ promotion_override: string | null;
420
422
  } | null;
421
423
  if (!row) return null;
422
424
  return TemplateSchema.parse({
@@ -427,12 +429,68 @@ export class ArtifactRepository {
427
429
  description: row.description,
428
430
  promotedBy: row.promoted_by,
429
431
  promotedAt: row.promoted_at,
432
+ promotionOverride: row.promotion_override,
430
433
  });
431
434
  } catch (error) {
432
435
  throw asStoreError(error);
433
436
  }
434
437
  }
435
438
 
439
+ listTemplates(limit = DEFAULT_LIST_LIMIT): Array<{
440
+ name: string;
441
+ artifactId: string;
442
+ revisionId: string;
443
+ revisionSha: string;
444
+ promotedBy: string;
445
+ promotedAt: string;
446
+ promotionOverride: string | null;
447
+ sourceVerdict: {
448
+ status: import("../../shared/contracts/validation").RenderStatus;
449
+ tier: 0 | 1;
450
+ } | null;
451
+ }> {
452
+ const rows = this.db
453
+ .query(
454
+ "SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates ORDER BY promoted_at DESC, id DESC LIMIT ?",
455
+ )
456
+ .all(limit) as Array<{
457
+ id: string;
458
+ artifact_id: string;
459
+ revision_id: string;
460
+ name: string;
461
+ description: string | null;
462
+ promoted_by: string;
463
+ promoted_at: string;
464
+ promotion_override: string | null;
465
+ }>;
466
+ return rows.map((row) => {
467
+ const revision = this.getRevisionById(row.revision_id);
468
+ if (revision === null)
469
+ throw new FacetStoreError("foreign_key", `Template revision not found: ${row.revision_id}`);
470
+ const template = TemplateSchema.parse({
471
+ id: row.id,
472
+ artifactId: row.artifact_id,
473
+ revisionId: row.revision_id,
474
+ name: row.name,
475
+ description: row.description,
476
+ promotedBy: row.promoted_by,
477
+ promotedAt: row.promoted_at,
478
+ promotionOverride: row.promotion_override,
479
+ });
480
+ const verdict = latestStoredVerdict(this, revision);
481
+ return {
482
+ name: template.name,
483
+ artifactId: template.artifactId,
484
+ revisionId: template.revisionId,
485
+ revisionSha: revision.sha256,
486
+ promotedBy: template.promotedBy,
487
+ promotedAt: template.promotedAt,
488
+ promotionOverride: template.promotionOverride,
489
+ sourceVerdict: verdict === null ? null : { status: verdict.status, tier: verdict.tier },
490
+ };
491
+ });
492
+ }
493
+
436
494
  createArtifact(input: ArtifactInput): Artifact {
437
495
  const createdAt = now();
438
496
  const value = { id: crypto.randomUUID(), ...input, createdAt, updatedAt: createdAt };
@@ -715,7 +773,7 @@ export class ArtifactRepository {
715
773
  }
716
774
 
717
775
  promoteRevision(input: PromoteRevisionInput): Template {
718
- return promoteLifecycleRevision(this.db, input);
776
+ return promoteLifecycleRevision(this.db, this, input);
719
777
  }
720
778
 
721
779
  instantiateTemplate(input: TemplateInput): Template {
@@ -216,3 +216,7 @@ export const V9_SCHEMA_FRAGMENT = `
216
216
  ALTER TABLE render_runs ADD COLUMN screenshot_format TEXT
217
217
  CHECK(screenshot_format IN ('png','webp'));
218
218
  `;
219
+
220
+ export const V10_SCHEMA_FRAGMENT = `
221
+ ALTER TABLE templates ADD COLUMN promotion_override TEXT;
222
+ `;
@@ -39,6 +39,9 @@ export function withTolerantObserved(parsed: unknown): VerdictObserved {
39
39
  visibleSvgCount: num("visibleSvgCount", 0),
40
40
  opaqueRegionCount: num("opaqueRegionCount", 0),
41
41
  externalImageCount: num("externalImageCount", 0),
42
+ ...(typeof observed.emptyRendererRoot === "boolean"
43
+ ? { emptyRendererRoot: observed.emptyRendererRoot }
44
+ : {}),
42
45
  // errorCount is required on the schema; pre-arc rows that never
43
46
  // recorded it default to 0.
44
47
  errorCount: num("errorCount", 0),
@@ -94,5 +94,6 @@ export const TemplateSchema = z.object({
94
94
  description: z.string().nullable(),
95
95
  promotedBy: z.string().min(1),
96
96
  promotedAt: IsoTimestampSchema,
97
+ promotionOverride: z.string().nullable(),
97
98
  });
98
99
  export type Template = z.infer<typeof TemplateSchema>;
@@ -33,6 +33,7 @@ import {
33
33
  CreateRequestSchema,
34
34
  InstantiateRequestSchema,
35
35
  ListRequestSchema,
36
+ TemplatesRequestSchema,
36
37
  OpenRequestSchema,
37
38
  PinRequestSchema,
38
39
  PromoteRequestSchema,
@@ -45,6 +46,7 @@ import {
45
46
  type CreateRequest,
46
47
  type InstantiateRequest,
47
48
  type ListRequest,
49
+ type TemplatesRequest,
48
50
  type OpenRequest,
49
51
  type PinRequest,
50
52
  type PromoteRequest,
@@ -58,6 +60,7 @@ import {
58
60
  CreateResultSchema,
59
61
  InstantiateResultSchema,
60
62
  ListResultSchema,
63
+ TemplatesResultSchema,
61
64
  OpenResultSchema,
62
65
  PinResultSchema,
63
66
  PromoteResultSchema,
@@ -72,6 +75,7 @@ import {
72
75
  type CreateResult,
73
76
  type InstantiateResult,
74
77
  type ListResult,
78
+ type TemplatesResult,
75
79
  type OpenResult,
76
80
  type PinResult,
77
81
  type PromoteResult,
@@ -98,6 +102,7 @@ export const CommandRequestSchema = z.discriminatedUnion("command", [
98
102
  CreateRequestSchema,
99
103
  PublishRequestSchema,
100
104
  ListRequestSchema,
105
+ TemplatesRequestSchema,
101
106
  ReadBackRequestSchema,
102
107
  StatusRequestSchema,
103
108
  OpenRequestSchema,
@@ -112,6 +117,7 @@ export const CommandResultSchema = z.discriminatedUnion("command", [
112
117
  CreateResultSchema,
113
118
  PublishResultSchema,
114
119
  ListResultSchema,
120
+ TemplatesResultSchema,
115
121
  ReadBackResultSchema,
116
122
  StatusResultSchema,
117
123
  OpenResultSchema,
@@ -145,6 +151,7 @@ export {
145
151
  PublishRequestSchema,
146
152
  PublishArtifactTypeSchema,
147
153
  ListRequestSchema,
154
+ TemplatesRequestSchema,
148
155
  ReadBackRequestSchema,
149
156
  StatusRequestSchema,
150
157
  OpenRequestSchema,
@@ -157,6 +164,7 @@ export {
157
164
  CreateResultSchema,
158
165
  PublishResultSchema,
159
166
  ListResultSchema,
167
+ TemplatesResultSchema,
160
168
  ReadBackResultSchema,
161
169
  StatusResultSchema,
162
170
  OpenResultSchema,
@@ -180,6 +188,7 @@ export type {
180
188
  CreateRequest,
181
189
  PublishRequest,
182
190
  ListRequest,
191
+ TemplatesRequest,
183
192
  ReadBackRequest,
184
193
  StatusRequest,
185
194
  OpenRequest,
@@ -191,6 +200,7 @@ export type {
191
200
  CreateResult,
192
201
  PublishResult,
193
202
  ListResult,
203
+ TemplatesResult,
194
204
  ReadBackResult,
195
205
  StatusResult,
196
206
  DoctorResult,
@@ -9,6 +9,7 @@ export const CommandNameSchema = z.enum([
9
9
  "create",
10
10
  "publish",
11
11
  "list",
12
+ "templates",
12
13
  "readBack",
13
14
  "status",
14
15
  "open",
@@ -23,6 +24,7 @@ export const IMPLEMENTED_COMMANDS: readonly CommandName[] = [
23
24
  "create",
24
25
  "publish",
25
26
  "list",
27
+ "templates",
26
28
  "readBack",
27
29
  "status",
28
30
  "open",
@@ -82,6 +82,12 @@ export const ListRequestSchema = BaseRequestSchema.extend({
82
82
  });
83
83
  export type ListRequest = z.infer<typeof ListRequestSchema>;
84
84
 
85
+ export const TemplatesRequestSchema = BaseRequestSchema.extend({
86
+ command: z.literal("templates"),
87
+ limit: z.number().int().positive().max(MAX_LIST_LIMIT).optional(),
88
+ });
89
+ export type TemplatesRequest = z.infer<typeof TemplatesRequestSchema>;
90
+
85
91
  export const ReadBackRequestSchema = BaseRequestSchema.extend({
86
92
  command: z.literal("readBack"),
87
93
  artifactId: z.string().min(1),
@@ -116,6 +122,7 @@ export const PromoteRequestSchema = BaseRequestSchema.extend({
116
122
  name: z.string().min(1),
117
123
  description: z.string().nullable().optional(),
118
124
  promotedBy: z.string().min(1),
125
+ allowUnverified: z.boolean().optional(),
119
126
  });
120
127
  export type PromoteRequest = z.infer<typeof PromoteRequestSchema>;
121
128