@legionworks/facet 1.9.0 → 1.10.1

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 (77) hide show
  1. package/README.md +50 -21
  2. package/dist/gallery/{chunk-c6s9k9ty.js → chunk-0by8qn4x.js} +86 -3
  3. package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
  4. package/dist/gallery/frame/artifact.css +3 -0
  5. package/dist/gallery/frame/chunks/{markdown-fvfncadf.js → markdown-g3asgaw3.js} +8 -0
  6. package/dist/gallery/frame/frame.css +11 -24
  7. package/dist/gallery/frame/runtime/chart.js +24 -2
  8. package/dist/gallery/frame/runtime/html.js +1 -1
  9. package/dist/gallery/frame/runtime/markdown.js +1 -1
  10. package/dist/gallery/frame/runtime/mermaid.js +1 -1
  11. package/dist/gallery/frame/runtime/svg.js +1 -1
  12. package/dist/gallery/frame/runtime/tsx.js +1 -1
  13. package/dist/gallery/index.html +4 -2
  14. package/docs/reference/cli.md +30 -2
  15. package/docs/reference/export.md +4 -0
  16. package/docs/reference/html.md +3 -3
  17. package/docs/reference/mcp.md +29 -8
  18. package/docs/reference/security.md +7 -0
  19. package/docs/reference/storage.md +6 -1
  20. package/docs/reference/tsx.md +16 -0
  21. package/docs/reference/validation.md +34 -6
  22. package/package.json +4 -4
  23. package/skills/facet/SKILL.md +24 -2
  24. package/src/cli/commands/promote.ts +1 -0
  25. package/src/cli/commands/templates.ts +25 -0
  26. package/src/cli/main.ts +4 -0
  27. package/src/cli/parser.ts +4 -0
  28. package/src/cli/presenter.ts +48 -0
  29. package/src/gallery-web/app.ts +65 -2
  30. package/src/gallery-web/favicon.ts +1 -0
  31. package/src/gallery-web/frame/renderers/chart.ts +31 -1
  32. package/src/gallery-web/frame/runtime.ts +7 -1
  33. package/src/gallery-web/frame/styles/artifact.css +3 -0
  34. package/src/gallery-web/frame/styles/frame.css +11 -24
  35. package/src/gallery-web/frame-html.ts +7 -2
  36. package/src/gallery-web/index.html +3 -1
  37. package/src/gallery-web/styles/verdict.css +19 -4
  38. package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
  39. package/src/harness-adapters/mcp/main.ts +2 -0
  40. package/src/harness-adapters/mcp/server.ts +92 -69
  41. package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
  42. package/src/service/dispatcher.ts +22 -0
  43. package/src/service/router-guards.ts +2 -0
  44. package/src/service/store/migrations.ts +12 -2
  45. package/src/service/store/repository-lifecycle.ts +91 -19
  46. package/src/service/store/repository.ts +60 -2
  47. package/src/service/store/schema.ts +4 -0
  48. package/src/service/stored-verdict.ts +3 -0
  49. package/src/shared/contracts/artifact.ts +1 -0
  50. package/src/shared/contracts/commands/index.ts +10 -0
  51. package/src/shared/contracts/commands/names.ts +2 -0
  52. package/src/shared/contracts/commands/requests.ts +7 -0
  53. package/src/shared/contracts/commands/results.ts +20 -1
  54. package/src/shared/contracts/promotion.ts +16 -0
  55. package/src/shared/contracts/validation.ts +7 -0
  56. package/src/shared/errors/facet-error.ts +2 -0
  57. package/src/shared/errors/store-error.ts +4 -0
  58. package/src/shared/html/artifact-main.ts +3 -0
  59. package/src/shared/html/style-vocabulary.ts +1 -0
  60. package/src/shared/storage-version.ts +1 -1
  61. package/src/validation/tier0/dom-shim.ts +3 -18
  62. package/src/validation/tier0/markdown.ts +28 -5
  63. package/src/validation/tier0/mermaid.ts +17 -8
  64. package/src/validation/tier0/worker-dispatch.ts +1 -1
  65. package/src/validation/tier1/entries/tsx.ts +1 -0
  66. package/src/validation/tier1/harness.ts +2 -1
  67. package/src/validation/tier1/isolated-probe.ts +4 -0
  68. package/src/validation/tier1/protocol-probe.ts +37 -0
  69. package/src/validation/tier1/runner.ts +21 -4
  70. package/src/validation/tier1/verdict.ts +24 -39
  71. package/templates/capacity-report.tsx +1 -1
  72. package/templates/fleet-dashboard.html +1 -1
  73. package/templates/html-release-ledger.html +1 -1
  74. package/templates/html-status-report.html +1 -1
  75. package/templates/incident-console.tsx +1 -1
  76. package/templates/tsx-interactive-counter.tsx +1 -1
  77. package/templates/tsx-status-report.tsx +1 -1
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { Tier1ResultSchema, VerdictSchema } from "../validation";
3
+ import { Tier1ResultSchema, VerdictSchema, RenderStatusSchema } from "../validation";
4
4
  import { ArtifactTypeSchema, RendererSchema } from "../artifact";
5
5
  import { EvidenceImageFormatSchema } from "../../evidence-image";
6
6
  import { ExportFormatSchema } from "./requests";
@@ -64,6 +64,25 @@ export const ListResultSchema = BaseResultSchema.extend({
64
64
  });
65
65
  export type ListResult = z.infer<typeof ListResultSchema>;
66
66
 
67
+ export const TemplatesResultSchema = BaseResultSchema.extend({
68
+ command: z.literal("templates"),
69
+ templates: z.array(
70
+ z.object({
71
+ name: z.string().min(1),
72
+ artifactId: z.string().min(1),
73
+ revisionId: z.string().min(1),
74
+ revisionSha: z.string().regex(/^[a-f0-9]{64}$/),
75
+ promotedBy: z.string().min(1),
76
+ promotedAt: z.string().datetime({ offset: true }),
77
+ promotionOverride: z.string().nullable(),
78
+ sourceVerdict: z
79
+ .object({ status: RenderStatusSchema, tier: z.union([z.literal(0), z.literal(1)]) })
80
+ .nullable(),
81
+ }),
82
+ ),
83
+ });
84
+ export type TemplatesResult = z.infer<typeof TemplatesResultSchema>;
85
+
67
86
  /**
68
87
  * Read-back result embeds the canonical `VerdictSchema` from
69
88
  * `validation.ts`. The two definitions are the SAME object so a
@@ -0,0 +1,16 @@
1
+ import type { RenderStatus } from "./validation";
2
+
3
+ export const PROMOTION_GATE = {
4
+ ok: "allow",
5
+ error: "refuse",
6
+ "partial:layout_unverified": "allow",
7
+ "partial:opaque_content": "allow",
8
+ "partial:external_resources": "allow",
9
+ "partial:unstable": "allow",
10
+ "partial:empty_render": "refuse",
11
+ tampered: "refuse",
12
+ timeout: "refuse",
13
+ shim_only: "refuse",
14
+ probe_only: "refuse",
15
+ "insecure:unvalidated": "refuse",
16
+ } as const satisfies Record<RenderStatus, "allow" | "refuse">;
@@ -36,6 +36,7 @@ export const RenderStatusSchema = z.enum([
36
36
  "partial:opaque_content",
37
37
  "partial:external_resources",
38
38
  "partial:unstable",
39
+ "partial:empty_render",
39
40
  "tampered",
40
41
  "timeout",
41
42
  "shim_only",
@@ -134,9 +135,14 @@ export type LexicalCounters = z.infer<typeof LexicalCountersSchema>;
134
135
  const ObservedCountShape = Object.fromEntries(
135
136
  OBSERVED_COUNT_KEYS.map((key) => [key, z.number().int().nonnegative()]),
136
137
  ) as Record<(typeof OBSERVED_COUNT_KEYS)[number], z.ZodNumber>;
138
+ const RenderedContentShape = {
139
+ // Legacy stored observations lack this TSX-only probe; absence never proves an empty render.
140
+ emptyRendererRoot: z.boolean().optional(),
141
+ };
137
142
 
138
143
  export const VerdictObservedSchema = z.object({
139
144
  ...ObservedCountShape,
145
+ ...RenderedContentShape,
140
146
  html: HtmlStructureCountsSchema.optional(),
141
147
  viewBoxes: z.array(z.string()).optional(),
142
148
  errorCount: z.number().int().nonnegative(),
@@ -288,6 +294,7 @@ export type Tier1Result = z.infer<typeof Tier1ResultSchema>;
288
294
  */
289
295
  export const ProtocolObservationSchema = z.object({
290
296
  ...ObservedCountShape,
297
+ ...RenderedContentShape,
291
298
  html: HtmlStructureCountsSchema.optional(),
292
299
  viewBoxes: z.array(z.string()),
293
300
  errorCount: z.number().int().nonnegative(),
@@ -12,6 +12,7 @@ export const FacetErrorCodes = {
12
12
  database_busy: true,
13
13
  disk_full: true,
14
14
  duplicate_revision: true,
15
+ template_name_taken: true,
15
16
  foreign_key: true,
16
17
  immutable_revision: true,
17
18
  migration_failed: true,
@@ -27,6 +28,7 @@ export const FacetErrorCodes = {
27
28
  artifact_not_found: true,
28
29
  revision_not_found: true,
29
30
  template_not_found: true,
31
+ promotion_refused: true,
30
32
  evidence_unavailable: true,
31
33
  output_unwritable: true,
32
34
  revision_capacity_pinned: true,
@@ -18,6 +18,7 @@ export type StoreErrorCode = Extract<
18
18
  | "database_busy"
19
19
  | "disk_full"
20
20
  | "duplicate_revision"
21
+ | "template_name_taken"
21
22
  | "foreign_key"
22
23
  | "immutable_revision"
23
24
  | "migration_failed"
@@ -62,6 +63,9 @@ export function asStoreError(error: unknown): FacetStoreError {
62
63
  return new FacetStoreError("foreign_key", message, { cause: error });
63
64
  }
64
65
  if (lower.includes("unique constraint")) {
66
+ if (lower.includes("templates.name")) {
67
+ return new FacetStoreError("template_name_taken", message, { cause: error });
68
+ }
65
69
  return new FacetStoreError("duplicate_revision", message, { cause: error });
66
70
  }
67
71
  return new FacetStoreError("constraint", message, { cause: error });
@@ -0,0 +1,3 @@
1
+ export function artifactMainAttributes(artifactType: string): string {
2
+ return `id="artifact" data-facet-artifact-type="${artifactType}"`;
3
+ }
@@ -28,6 +28,7 @@ export const HTML_TAILWIND_CLASSES = [
28
28
  "mt-4",
29
29
  "mb-2",
30
30
  "mb-4",
31
+ "mx-auto",
31
32
  "w-full",
32
33
  "max-w-prose",
33
34
  "max-w-2xl",
@@ -1,2 +1,2 @@
1
1
  /** Latest schema migration applied by the service. */
2
- export const CURRENT_STORAGE_VERSION = 9 as const;
2
+ export const CURRENT_STORAGE_VERSION = 10 as const;
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * linkedom-based DOM shim for the Tier 0 worker.
3
3
  *
4
- * Mermaid and other browser-shaped libraries perform import-time
5
- * initialization against `globalThis.document`, `window`, and the
6
- * `implementation.createHTMLDocument` API. Bun's worker process has
7
- * none of these by default, so a naive `import mermaid from "mermaid"`
8
- * throws `DOMPurify.addHook is not a function` (or its equivalent)
9
- * before the parser ever sees source.
4
+ * Mermaid needs a minimal DOM for import and parsing in the worker.
5
+ * Tier 0 does not render labels; their sanitization is a render concern
6
+ * verified by Tier 1.
10
7
  *
11
8
  * This module installs the shim at IMPORT TIME (top-level) so any
12
9
  * subsequent `import "mermaid"` sees a DOM. It is structural only —
@@ -24,17 +21,6 @@ function installDomShim(): void {
24
21
  installed = true;
25
22
  const { document, window } = parseHTML("<!DOCTYPE html><html><body></body></html>");
26
23
  domShimDocument = document as unknown as Document;
27
- // DOMPurify's isSupported check looks for `implementation.createHTMLDocument`.
28
- // linkedom ships without an implementation object; we attach a minimal shim
29
- // that returns a freshly-parsed document. This is structural only —
30
- // we never serialize against this DOM.
31
- const fakeImpl = {
32
- createHTMLDocument: (html: string): Document => {
33
- const r = parseHTML(html);
34
- return r.document as unknown as Document;
35
- },
36
- };
37
- Object.defineProperty(document, "implementation", { value: fakeImpl, configurable: true });
38
24
  const g = globalThis as unknown as Record<string, unknown>;
39
25
  g["document"] = document;
40
26
  g["window"] = window;
@@ -43,7 +29,6 @@ function installDomShim(): void {
43
29
  g["Node"] = window.Node;
44
30
  g["DocumentFragment"] = window.DocumentFragment;
45
31
  g["HTMLTemplateElement"] = window.HTMLTemplateElement;
46
- g["NodeFilter"] = window.NodeFilter;
47
32
  }
48
33
 
49
34
  // Top-level install — runs synchronously when this module is first
@@ -30,6 +30,7 @@ import { Lexer, type Token, type Tokens } from "marked";
30
30
 
31
31
  import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
32
32
  import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
33
+ import { parseMermaidText } from "./mermaid";
33
34
 
34
35
  export interface MarkdownParseOk {
35
36
  readonly status: "ok";
@@ -54,6 +55,7 @@ interface MarkdownCounts {
54
55
  hasScript: boolean;
55
56
  hasOnHandler: boolean;
56
57
  hasExternalRef: boolean;
58
+ mermaidBodies: string[];
57
59
  }
58
60
 
59
61
  function isExternalHttpsUrl(value: string): boolean {
@@ -102,6 +104,7 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
102
104
  counts.totalFenced += 1;
103
105
  const lang = (code.lang ?? "").trim().toLowerCase();
104
106
  if (lang === "mermaid") {
107
+ counts.mermaidBodies.push(code.text);
105
108
  counts.mermaidFenced += 1;
106
109
  counts.rendererRoots += 1;
107
110
  const nodes = countMermaidNodeDeclarations(code.text);
@@ -161,12 +164,11 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
161
164
  }
162
165
 
163
166
  /**
164
- * Parse the source bytes as markdown. Pure tokenization — no DOM, no
165
- * render, no script execution. Raw HTML is counted (it must NOT
166
- * execute, and we surface structural red flags if it tries to smuggle
167
- * one) but never interpreted.
167
+ * Tokenize Markdown and grammar-check Mermaid fences without rendering
168
+ * or executing source. Raw HTML is counted and checked for structural
169
+ * red flags, but never interpreted.
168
170
  */
169
- export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
171
+ export async function parseMarkdown(bytes: Uint8Array): Promise<MarkdownParseResult> {
170
172
  const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
171
173
  const counts: MarkdownCounts = {
172
174
  totalFenced: 0,
@@ -178,6 +180,7 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
178
180
  hasScript: false,
179
181
  hasOnHandler: false,
180
182
  hasExternalRef: false,
183
+ mermaidBodies: [],
181
184
  };
182
185
  let lexError: unknown = null;
183
186
  let tokens: Token[] = [];
@@ -189,6 +192,26 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
189
192
  lexError = error;
190
193
  }
191
194
 
195
+ for (let i = 0; i < counts.mermaidBodies.length; i += 1) {
196
+ const message = await parseMermaidText(counts.mermaidBodies[i]!);
197
+ if (message !== null) {
198
+ // Fence grammar errors take precedence over lexer and hostile-HTML errors.
199
+ return {
200
+ status: "error",
201
+ observed: {
202
+ rendererRootSvgCount: counts.rendererRoots,
203
+ graphCount: counts.mermaidFenced,
204
+ mermaidNodeCount: counts.mermaidNodeCount ?? 0,
205
+ visibleSvgCount: 0,
206
+ externalImageCount: counts.externalImageCount,
207
+ errorCount: 1,
208
+ opaqueRegionCount: 0,
209
+ },
210
+ errors: [{ code: "mermaid_parse_error", message, location: `mermaid fence ${i}` }],
211
+ };
212
+ }
213
+ }
214
+
192
215
  if (lexError !== null) {
193
216
  const message = lexError instanceof Error ? lexError.message : String(lexError);
194
217
  return {