diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 398707c..f86c7c0 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -79,7 +79,7 @@ import { } from "./providers"; import { planGoal } from "./planning"; import { loadExperimental, saveExperimental, type ExperimentalPreferences } from "./experimental"; -import { loadBlockProvenance, saveBlockProvenance } from "./block-provenance"; +import { loadBlockProvenance } from "./block-provenance"; import { bindStepSurface, emittedActionsBySurface, @@ -589,9 +589,13 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const delta = examplesDelta(base, live); const p = createProject({ name: `My ${ref.label} project`, description: "", source: { kind: "reference", referenceId: refId } }); if (delta.length > 0) saveExamplesDelta(p.id, delta); - // Interface blocks (experimental): the session's provenance comes along too. - if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" && Object.keys(sessionProvenance.current).length > 0) { - saveBlockProvenance(p.id, sessionProvenance.current); + // Interface blocks (experimental): the session's provenance comes along + // too, once every write already queued for it has landed, pruned against + // the examples the duplicate keeps. + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + void import("../experimental/blocks-runtime") + .then((runtime) => runtime.copyProvenance(runtime.memoryProvenanceStore(sessionProvenance), runtime.projectProvenanceStore(p.id), live)) + .catch(() => undefined); } setProjects(listProjects()); openProject(p.id); // clears the example workspace, merges the delta @@ -626,9 +630,10 @@ export function ComposerProvider({ children }: { children: ReactNode }) { if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { try { const raw = (JSON.parse(text) as { blockProvenance?: unknown }).blockProvenance; + const examples = ((parsed.vocab.contract.examples as ExampleEntry[] | undefined) ?? []) as ExampleEntry[]; if (raw && typeof raw === "object") { void import("../experimental/blocks-runtime") - .then((runtime) => runtime.importProvenance(runtime.projectProvenanceStore(p.id), raw)) + .then((runtime) => runtime.importProvenance(runtime.projectProvenanceStore(p.id), raw, examples)) .catch(() => undefined); } } catch { @@ -860,6 +865,14 @@ export function ComposerProvider({ children }: { children: ReactNode }) { .then((runtime) => runtime.gcProvenance(runtime.projectProvenanceStore(p.id), live)) .catch(() => undefined); } + } else if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + // No stored project: an Example session, which keeps its provenance + // in memory. A save prunes it the same way, so "duplicate to keep" + // carries nothing stale; outside a session the map is empty. + const live = ((doc.examples as ExampleEntry[] | undefined) ?? []) as ExampleEntry[]; + void import("../experimental/blocks-runtime") + .then((runtime) => runtime.gcProvenance(runtime.memoryProvenanceStore(sessionProvenance), live)) + .catch(() => undefined); } return { savedInMemory: true as const }; } diff --git a/apps/composer/app/views/inventory-view.tsx b/apps/composer/app/views/inventory-view.tsx index 890c808..79d4811 100644 --- a/apps/composer/app/views/inventory-view.tsx +++ b/apps/composer/app/views/inventory-view.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { useComposer } from "../state"; import { ViewHeader } from "../ui"; @@ -26,7 +27,7 @@ export function InventoryView({ onOpen }: { onOpen: () => void }) { ); - return ( + const catalog = ( <> void }) { ); + // Interface blocks (experimental): the read-only Blocks catalog follows the + // components. The default build folds this branch away and returns the + // catalog exactly as before. + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + return ( + <> + {catalog} + + + ); + } + return catalog; +} + +/** + * Interface blocks (experimental; docs/blocks/PHASE-2-RELEASE-PLAN.md, §7 H3): + * every block this build bundles, read-only. Rendered only in an experimental + * build: the literal flag check above folds this away in the default build, + * and the catalog is read through the guarded import. Nothing in it can be + * pressed: a block's actions are host-owned, and Composer executes none. + */ +function BlocksCatalogSection() { + const { activeProject } = useComposer(); + const projectReferenceId = activeProject?.source.kind === "reference" ? activeProject.source.referenceId : null; + type Catalog = Array<{ + referenceId: string; + label: string; + reference: { name: string; dspack: string; version: string } | null; + binding: string; + sidecarStatus: string | null; + withheld?: string; + issues: string[]; + blocks: Array<{ id: string; name: string; version: number; status: string; disabled: string[]; intents: string[]; actions: string[]; streamingText: string }>; + }>; + const [catalog, setCatalog] = useState(null); + useEffect(() => { + let cancelled = false; + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + void import("../../experimental/blocks-runtime") + .then(async (runtime) => { + const next = await runtime.blocksCatalog(); + if (!cancelled) setCatalog(next); + }) + .catch(() => undefined); + } + return () => { + cancelled = true; + }; + }, []); + const cell = { padding: "6px 8px", verticalAlign: "top" } as const; + return ( +
+

+ Interface blocks +

+

+ Every block this experimental build bundles, read-only. A block’s declared actions are host-owned: Composer never executes them, + and a host binds and authorizes each one on its own server. +

+ {catalog === null &&

Reading the blocks…

} + {(catalog ?? []).map((ref) => { + const headingId = `blocks-catalog-${ref.referenceId}-h`; + return ( +
+

+ {ref.reference ? `${ref.reference.name} ${ref.reference.version}` : ref.label} + {ref.referenceId === projectReferenceId && · this project’s reference} +

+

+ Sidecar {ref.sidecarStatus ?? "unreadable"} · binding {ref.binding} + {ref.withheld && <> · {ref.withheld}} +

+ {ref.issues.map((issue) => ( +

+ {issue} +

+ ))} + {ref.blocks.length > 0 && ( + + + + + + + + + + + + + {ref.blocks.map((b) => ( + + + + + + + + + ))} + +
BlockVersionStatusIntentsDeclared actions (host-owned)Streaming policy
+ {b.id} + {b.name} + v{b.version} + {b.status} + {b.disabled.length > 0 && disabled at load: {b.disabled.join("; ")}} + {b.intents.join(", ")}{b.actions.join(", ")}{b.streamingText}
+ )} +
+ ); + })} +
+ ); } diff --git a/apps/composer/app/views/preview-view.tsx b/apps/composer/app/views/preview-view.tsx index 35fd1ac..2acc8da 100644 --- a/apps/composer/app/views/preview-view.tsx +++ b/apps/composer/app/views/preview-view.tsx @@ -318,33 +318,47 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) { /** The step's surface through the IDENTICAL single-surface path (same * canvas, same key pattern, same registry scope) — used by both modes. */ - const renderSurface = (surf: NonNullable) => ( - <> - {!isExample && referenceExampleIds?.has(surf.name) && ( -

- Reference surface from {manifest?.name ?? "the design system"} — teaching material, not part of your project’s work. -

- )} -
- -
- - ); + const renderSurface = (surf: NonNullable) => { + const rendered = ( + <> + {!isExample && referenceExampleIds?.has(surf.name) && ( +

+ Reference surface from {manifest?.name ?? "the design system"} — teaching material, not part of your project’s work. +

+ )} +
+ +
+ + ); + // Interface blocks (experimental): what the surface's block provenance + // means, above it. The default build folds this branch away and renders + // the surface exactly as before. + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + return ( + <> + + {rendered} + + ); + } + return rendered; + }; return (
@@ -693,3 +707,85 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) {
); } + +/** + * Interface blocks (experimental; docs/blocks/PHASE-2-RELEASE-PLAN.md, §7 H3 + * and §8): what this surface's block provenance means here, and, for a block + * surface, that its actions are host-owned. Rendered only in an experimental + * build: the literal flag check above folds it away in the default build, and + * the verdict is read through the guarded import. Only exact expansion + * identity earns "made with"; a verdict grants no action anything, and + * Preview still only records what a surface dispatches. + */ +function BlockProvenanceNote({ surfaceName }: { surfaceName: string }) { + const { activeProject, contract, blockProvenanceFor } = useComposer(); + type Note = { block: string; version: number; madeWith: boolean; status: string; reasonText?: string }; + const [note, setNote] = useState(null); + useEffect(() => { + let cancelled = false; + let unsubscribe = () => {}; + setNote(null); + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" && activeProject) { + const project = activeProject; + const source = project.source; + const examples = (contract?.examples as Array<{ id?: unknown; surface?: unknown }> | undefined) ?? []; + void import("../../experimental/blocks-runtime") + .then((runtime) => { + // Read now, and again after every provenance write (an accept, a + // prune, an import or a duplicate's copy can land after this mounts); + // only the latest read may set the note. + let reads = 0; + const read = async () => { + const seq = ++reads; + const provenance = blockProvenanceFor(project.id); + const verdicts = Object.hasOwn(provenance, surfaceName) + ? await runtime.provenanceVerdicts({ + provenance: { [surfaceName]: provenance[surfaceName] }, + examples, + project: source.kind === "reference" ? { kind: "reference", referenceId: source.referenceId } : { kind: source.kind }, + }) + : {}; + if (cancelled || seq !== reads) return; + const verdict = verdicts[surfaceName]; + setNote(verdict ? { ...verdict, ...(verdict.reason ? { reasonText: runtime.PROVENANCE_REASON_TEXT[verdict.reason] } : {}) } : null); + }; + if (cancelled) return; + unsubscribe = runtime.onProvenanceChange(() => void read().catch(() => undefined)); + return read(); + }) + .catch(() => undefined); + } + return () => { + cancelled = true; + unsubscribe(); + }; + }, [activeProject, contract, surfaceName, blockProvenanceFor]); + if (!note) return null; + const label = `block ${note.block} v${note.version}`; + return ( +
+

+ {note.madeWith ? ( + <> + Made with {label}.{" "} + {note.status === "refinable" + ? "Refinable: its record verifies against this build's approved block." + : note.status === "unavailable" + ? `Unavailable: ${note.reasonText}.` + : `Inert: ${note.reasonText}.`} + + ) : ( + <> + Block provenance inert: {note.reasonText} (the record names {note.block} v{note.version}). This surface previews as an ordinary one. + + )} +

+ {note.madeWith && ( +

+ Block actions are host-owned and not executed here: Composer records each one it dispatches, and a host binds and authorizes it on its + own server. +

+ )} +
+ ); +} diff --git a/apps/composer/experimental/blocks-runtime.test.ts b/apps/composer/experimental/blocks-runtime.test.ts index 3c922aa..22941e7 100644 --- a/apps/composer/experimental/blocks-runtime.test.ts +++ b/apps/composer/experimental/blocks-runtime.test.ts @@ -16,8 +16,15 @@ import { blockBinding, blockContext, blockRunFor, + blocksCatalog, chooseBlock, + copyProvenance, + gcProvenance, + importProvenance, + memoryProvenanceStore, + onProvenanceChange, planGoalWithBlocks, + provenanceVerdicts, readBlocksBeta, runHostedBlockBuild, withActionManifest, @@ -515,3 +522,157 @@ describe("the beta's Catalog export carries the action manifest (docs/blocks/HOS for (const s of surfaces) expect(s.block).toBeNull(); }); }); + +describe("the Blocks catalog (Phase 2 plan, §7, H3): every bundled block, read-only", () => { + it("each packaged reference lists its blocks with version, status, intents, declared actions and streaming policy", async () => { + const catalog = await blocksCatalog(); + expect(catalog.map((r) => r.referenceId)).toEqual(["shadcn", "astryx"]); + const astryxRef = catalog.find((r) => r.referenceId === "astryx")!; + expect(astryxRef).toMatchObject({ label: "Astryx", reference: { name: "Astryx", dspack: "0.4", version: "0.1.4" }, binding: "ok", sidecarStatus: "approved", issues: [] }); + expect(astryxRef.withheld).toBeUndefined(); + expect(astryxRef.blocks.map((b) => b.id)).toEqual(["records-table", "auth-form", "guide-steps"]); + expect(astryxRef.blocks.find((b) => b.id === "auth-form")).toEqual({ + id: "auth-form", + name: "Account access", + version: 1, + status: "approved", + offered: true, + disabled: [], + intents: ["data-collection"], + actions: ["auth_submit", "auth_forgot", "auth_provider_google", "auth_provider_github", "auth_provider_microsoft", "auth_provider_apple"], + streaming: "disable-actions-until-complete", + streamingText: "disable actions until complete: a host keeps them off until the surface finishes streaming", + }); + expect(astryxRef.blocks.find((b) => b.id === "guide-steps")).toMatchObject({ actions: ["guide_start", "guide_save"], streaming: "static-safe", streamingText: "static-safe: actions may be enabled once the surface renders" }); + const shadcnRef = catalog.find((r) => r.referenceId === "shadcn")!; + expect(shadcnRef.reference).toEqual({ name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }); + expect(shadcnRef.withheld).toMatch(/withheld from this beta/); + }); + + it("it is data only: no handler, no function, nothing that could run an action", async () => { + const catalog = await blocksCatalog(); + expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog); + }); +}); + +describe("provenance verdicts for a project's surfaces (Phase 2 plan, §8)", () => { + async function accepted() { + const base = (await blockBinding(reference))!; + const block = base.sidecar.blocks.find((b) => b.id === "auth-form")!; + const example = block.examples[0]!; + const expanded = expandBlock(block, example.call, { reference: base.sidecar.reference, intent: example.intent }); + if (!expanded.ok) throw new Error(expanded.message); + const surface = expanded.surface as unknown as Json; + const entry = { + v: 1, + reference: base.sidecar.reference, + baseSha256: base.sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: await expansionDigest(surface), + }; + const edited = structuredClone(surface); + edited.root.children[0].text = "Edited by hand"; + return { entry, surface, edited }; + } + + it("a reference project: the accepted block surface is made with auth-form v1 and refinable; an edited one is inert and unlabelled", async () => { + const { entry, surface, edited } = await accepted(); + const verdicts = await provenanceVerdicts({ + provenance: { "ex.chat-1": entry, "ex.chat-2": entry, "ex.gone": entry }, + examples: [ + { id: "ex.chat-1", surface }, + { id: "ex.chat-2", surface: edited }, + ], + project: { kind: "reference", referenceId: "astryx" }, + }); + expect(verdicts["ex.chat-1"]).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "refinable" }); + expect(verdicts["ex.chat-2"]).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "edited" }); + // A record whose surface is gone has no surface to describe; the next save drops it. + expect(verdicts["ex.gone"]).toBeUndefined(); + }); + + it("a shadcn/ui project trusts only shadcn/ui provenance", async () => { + const { entry, surface } = await accepted(); + const verdicts = await provenanceVerdicts({ provenance: { a: entry }, examples: [{ id: "a", surface }], project: { kind: "reference", referenceId: "shadcn" } }); + expect(verdicts.a).toMatchObject({ madeWith: false, status: "inert", reason: "reference" }); + }); + + it("a project on a reference this build does not bundle trusts no provenance", async () => { + const { entry, surface } = await accepted(); + const verdicts = await provenanceVerdicts({ provenance: { a: entry }, examples: [{ id: "a", surface }], project: { kind: "reference", referenceId: "retired-reference" } }); + expect(verdicts.a).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "reference" }); + }); + + it("an imported project's provenance is unavailable; a repository project's too", async () => { + const { entry, surface } = await accepted(); + for (const [kind, reason] of [["imported", "imported"], ["agent", "repository"]] as const) { + const verdicts = await provenanceVerdicts({ provenance: { a: entry }, examples: [{ id: "a", surface }], project: { kind } }); + expect(verdicts.a).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason }); + } + }); + + it("an import keeps the reason each record was reduced to inert for", async () => { + const { entry, surface } = await accepted(); + const holder = { current: {} as Record }; + const examples = ["a", "b", "c"].map((id) => ({ id, surface })); + await importProvenance(memoryProvenanceStore(holder, "import-test"), { a: entry, b: { ...entry, version: 4 }, c: { ...entry, baseSha256: "1".repeat(64) } }, examples); + expect(holder.current.a).toEqual(entry); + expect(holder.current.b).toEqual({ block: "auth-form", version: 4, status: "inert", reason: "version" }); + expect(holder.current.c).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "base" }); + }); + + it("an import prunes against the imported project's examples: a record for a missing or edited surface is dropped", async () => { + const { entry, surface, edited } = await accepted(); + const holder = { current: {} as Record }; + await importProvenance(memoryProvenanceStore(holder, "import-prune"), { kept: entry, gone: entry, changed: entry, stale: { ...entry, version: 4 } }, [ + { id: "kept", surface }, + { id: "changed", surface: edited }, + { id: "stale", surface }, + ]); + expect(holder.current).toEqual({ kept: entry, stale: { block: "auth-form", version: 4, status: "inert", reason: "version" } }); + }); + + it("duplicating an Example session copies its provenance once every pending write has landed, pruned against the examples the duplicate keeps", async () => { + const { entry, surface, edited } = await accepted(); + const session = { current: { stale: entry } as Record }; + const from = memoryProvenanceStore(session, "copy-test-session"); + const saved: Array> = []; + const to = { key: "copy-test-project", load: () => ({}), save: (map: Record) => void saved.push(map) }; + // A write still on its way to the session's store: the copy waits for it. + void importProvenance(from, { stale: entry, late: entry }, [ + { id: "stale", surface }, + { id: "late", surface }, + ]); + await copyProvenance(from, to, [ + { id: "stale", surface: edited }, + { id: "late", surface }, + ]); + expect(saved).toEqual([{ late: entry }]); + }); + + it("a session with nothing that still verifies carries nothing", async () => { + const { entry, edited } = await accepted(); + const saved: unknown[] = []; + const to = { key: "copy-empty-project", load: () => ({}), save: (map: Record) => void saved.push(map) }; + await copyProvenance(memoryProvenanceStore({ current: {} }, "copy-empty"), to, []); + await copyProvenance(memoryProvenanceStore({ current: { a: entry } }, "copy-stale"), to, [{ id: "a", surface: edited }]); + expect(saved).toEqual([]); + }); + + it("every provenance write tells its listeners, so an open Preview re-reads; unsubscribing stops it", async () => { + const { entry, surface } = await accepted(); + const seen: string[] = []; + const off = onProvenanceChange((key) => seen.push(key)); + await gcProvenance(memoryProvenanceStore({ current: { a: entry } }, "notify-1"), [{ id: "a", surface }]); + await importProvenance(memoryProvenanceStore({ current: {} }, "notify-2"), { a: entry }, [{ id: "a", surface }]); + expect(seen.filter((key) => key.startsWith("notify-"))).toEqual(["notify-1", "notify-2"]); + off(); + await gcProvenance(memoryProvenanceStore({ current: { a: entry } }, "notify-3"), [{ id: "a", surface }]); + expect(seen.filter((key) => key.startsWith("notify-"))).toEqual(["notify-1", "notify-2"]); + }); +}); diff --git a/apps/composer/experimental/blocks-runtime.ts b/apps/composer/experimental/blocks-runtime.ts index 0fb052f..40a7209 100644 --- a/apps/composer/experimental/blocks-runtime.ts +++ b/apps/composer/experimental/blocks-runtime.ts @@ -47,6 +47,7 @@ import { parseBlocksBeta, parseProvenance, preloadedAdapter, + provenanceVerdict, pruneProvenance, recheckBlockChoice, reconcileBlockChoice, @@ -66,6 +67,7 @@ import { type IneligibleReason, type LoadedBlocks, type ManifestReference, + type ProvenanceVerdict, type StructuralAdapter, type SwitchReason, } from "@dspack-studio/composer-core/blocks"; @@ -454,13 +456,28 @@ export function memoryProvenanceStore(holder: { current: Record } const chains = new Map>(); +const listeners = new Set<(key: string) => void>(); function serialized(key: string, work: () => Promise): Promise { const prev = chains.get(key) ?? Promise.resolve(); - const next = prev.then(work, work); + const next = prev.then(work, work).finally(() => { + for (const listener of listeners) { + try { + listener(key); + } catch { + /* a listener never breaks the chain */ + } + } + }); chains.set(key, next); return next; } +/** Called with the store's key after every provenance write, so an open view can re-read. Returns the unsubscribe. */ +export function onProvenanceChange(listener: (key: string) => void): () => void { + listeners.add(listener); + return () => void listeners.delete(listener); +} + async function sidecarsLoaded(): Promise { const loaded = await Promise.all(Object.keys(SIDECARS).map((id) => loadedFor(id))); return loaded.map((l) => l.sidecar).filter((s): s is BlocksSidecar => !!s); @@ -492,11 +509,24 @@ export function gcProvenance(store: ProvenanceStore, examples: ReadonlyArray<{ i }); } -/** On import: keep only what the bundled sidecars can vouch for. */ -export function importProvenance(store: ProvenanceStore, raw: unknown): Promise { +/** On import: keep only what the bundled sidecars can vouch for, pruned against the imported project's examples as a save would. */ +export function importProvenance(store: ProvenanceStore, raw: unknown, examples: ReadonlyArray<{ id: string; surface?: unknown }>): Promise { return serialized(store.key, async () => { const sidecars = await sidecarsLoaded(); - store.save(parseProvenance(raw, sidecars)); + store.save(await pruneProvenance(parseProvenance(raw, sidecars), examples)); + }); +} + +/** + * Duplicating an Example session: its provenance comes along once every + * write already queued for it has landed, pruned against the examples the + * duplicate keeps, so nothing stale travels whatever the timing. + */ +export function copyProvenance(from: ProvenanceStore, to: ProvenanceStore, examples: ReadonlyArray<{ id: string; surface?: unknown }>): Promise { + return serialized(from.key, async () => { + const map = await pruneProvenance({ ...from.load() }, examples); + if (Object.keys(map).length === 0) return; + await serialized(to.key, async () => to.save(map)); }); } @@ -538,6 +568,102 @@ export async function withActionManifest( return { ...rest, actionManifest: manifest, exportedAt }; } +// --------------------------------------------------------------------------- +// Step 4 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7 H3 and §8): the read-only +// Blocks catalog, and what each surface's provenance means. Data only: nothing +// here can run an action, and a verdict grants nothing. + +/** The words Preview shows for each inert or unavailable reason. */ +export { PROVENANCE_REASON_TEXT } from "@dspack-studio/composer-core/blocks"; + +/** How a streaming policy reads in the catalog. */ +const STREAMING_TEXT: Record = { + "disable-actions-until-complete": "disable actions until complete: a host keeps them off until the surface finishes streaming", + "static-safe": "static-safe: actions may be enabled once the surface renders", +}; + +export interface BlocksCatalogBlock { + id: string; + name: string; + version: number; + status: BlockDefinition["status"]; + /** Whether this build offers it: approved in an approved sidecar that binds, and not disabled at load. */ + offered: boolean; + /** Why it is disabled at load (B0, with H1), when it is. */ + disabled: string[]; + intents: string[]; + /** The action names it declares: host-owned; Composer never executes them. */ + actions: string[]; + streaming: BlockDefinition["streaming"]; + streamingText: string; +} + +export interface BlocksCatalogReference { + referenceId: string; + label: string; + reference: { name: string; dspack: string; version: string } | null; + binding: LoadedBlocks["binding"]; + sidecarStatus: BlocksSidecar["status"] | null; + /** Why the beta holds this reference out, when it does. */ + withheld?: string; + issues: string[]; + blocks: BlocksCatalogBlock[]; +} + +/** Every bundled block, by packaged reference, in sidecar order: the Blocks catalog (H3). */ +export async function blocksCatalog(): Promise { + const out: BlocksCatalogReference[] = []; + for (const referenceId of Object.keys(SIDECARS)) { + const loaded = await loadedFor(referenceId); + const sidecar = loaded.sidecar; + out.push({ + referenceId, + label: String((REFERENCES[referenceId] as Json).label ?? REFERENCES[referenceId]!.contract.name ?? referenceId), + reference: sidecar ? { ...sidecar.reference } : null, + binding: loaded.binding, + sidecarStatus: sidecar?.status ?? null, + ...(WITHHELD[referenceId] ? { withheld: WITHHELD[referenceId] } : {}), + issues: [...loaded.issues], + blocks: (sidecar?.blocks ?? []).map((block) => ({ + id: block.id, + name: block.name, + version: block.version, + status: block.status, + offered: loaded.binding === "ok" && sidecar?.status === "approved" && block.status === "approved" && loaded.usable.some((b) => b.id === block.id && b.version === block.version), + disabled: loaded.disabled.find((d) => d.block === block.id)?.issues ?? [], + intents: [...block.intents], + actions: [...block.actions], + streaming: block.streaming, + streamingText: STREAMING_TEXT[block.streaming], + })), + }); + } + return out; +} + +/** + * The verdict for each of a project's surfaces that its provenance names + * (composer-core provenanceVerdict). A record whose surface is gone has + * nothing to describe, and the next save drops it. + */ +export async function provenanceVerdicts(input: { + provenance: Record; + examples: ReadonlyArray<{ id?: unknown; surface?: unknown }>; + project: { kind: "reference" | "imported" | "agent"; referenceId?: string }; +}): Promise> { + const loaded = await Promise.all(Object.keys(SIDECARS).map((id) => loadedFor(id))); + const own = input.project.kind === "reference" && input.project.referenceId && SIDECARS[input.project.referenceId] ? (await loadedFor(input.project.referenceId)).sidecar : undefined; + const project = { kind: input.project.kind, ...(own ? { reference: own.reference } : {}) }; + const out: Record = {}; + for (const [id, record] of Object.entries(input.provenance)) { + const example = input.examples.find((e) => e.id === id); + if (!example) continue; + const verdict = await provenanceVerdict({ record, surface: example.surface, loaded, project }); + if (verdict) out[id] = verdict; + } + return out; +} + // --------------------------------------------------------------------------- // Status for Settings. diff --git a/apps/composer/scripts/check-default-artifact.mjs b/apps/composer/scripts/check-default-artifact.mjs index cddfd5c..76414be 100644 --- a/apps/composer/scripts/check-default-artifact.mjs +++ b/apps/composer/scripts/check-default-artifact.mjs @@ -16,9 +16,19 @@ const composer = fileURLToPath(new URL("..", import.meta.url)); const out = join(composer, "out"); const sidecars = join(composer, "experimental", "blocks"); -// The sidecar envelope key, a template directive, and the beta switch's reasons -// (packages/composer-core/src/blocks/beta.ts). -const markers = new Set(["blocksSidecar", "disable-actions-until-complete", "reference-not-in-beta", "beta-switch-"]); +// The sidecar envelope key, a template directive, the beta switch's reasons +// (packages/composer-core/src/blocks/beta.ts), and step 4's Blocks catalog and +// Preview provenance line with the runtime call behind them. +const markers = new Set([ + "blocksSidecar", + "disable-actions-until-complete", + "reference-not-in-beta", + "beta-switch-", + "blocks-catalog", + "preview-provenance", + "preview-host-owned", + "provenanceVerdicts", +]); for (const file of readdirSync(sidecars).filter((f) => f.endsWith(".blocks.json"))) { const doc = JSON.parse(readFileSync(join(sidecars, file), "utf8")); for (const block of doc.blocks ?? []) markers.add(block.id); diff --git a/docs/BLOCKS.md b/docs/BLOCKS.md index 548502a..4e2c1fa 100644 --- a/docs/BLOCKS.md +++ b/docs/BLOCKS.md @@ -193,6 +193,11 @@ Two rules apply in every experimental build. A block is offered only when it and - **H2.** In an experimental build, Preview's "export catalog" adds an `actionManifest` that binds each surface's actions by surface and name. Only a surface that verifiably is its block's expansion gets a block binding (`manifest.ts`; `withActionManifest` in the runtime). The default build's export is byte-identical. - **H4.** How a host binds and authorizes these actions is [blocks/HOST-ACTIONS.md](blocks/HOST-ACTIONS.md). +**The Blocks catalog and provenance UI (step 4; [blocks/PHASE-2-RELEASE-PLAN.md](blocks/PHASE-2-RELEASE-PLAN.md), §7 H3 and §8).** Read-only, and only in an experimental build. +- **Catalog.** A Blocks section lists every bundled block: its reference, version, status, intents, declared actions and streaming policy. +- **Preview.** A surface's provenance reads "made with block X vN" only when its trusted call re-expands to exactly that surface (`provenanceVerdict` in `packages/composer-core/src/blocks/provenance.ts`). It is refinable, unavailable (an imported or repository project) or inert, and an inert record names its reason. Refinable says what the record is: it verifies against an approved block this build loaded. It does not say a build will use the block; the beta switch, Settings and the project's eligibility decide that, as the Build view states. A record that names no block id is dropped, so an imported name is never shown. A block surface also says its actions are host-owned and not executed here. A verdict grants nothing. +- **Lifecycle.** Provenance follows a project through export, import, duplicate and delete as before. A save drops stale records, now in an Example session too, and so does an import. Duplicating an Example session copies its provenance once every pending write has landed, pruned against the examples the duplicate keeps. + ## The live evaluation (Phase 1B) The harness lives in `packages/block-bench/live/` and is run by the owner; nothing in it runs in CI except its own tests and a dry run on the scripted provider. It measures the real code paths, never a re-implementation: composer-core's block phase, budget and refinement decision; dspack-gen's `runPipeline`; the request bytes the app posts (`proposeBody`); the app's fold (`foldBuildEvents`). The hosted client is held to the app's gateway adapter by a parity test over every reply shape. diff --git a/docs/blocks/HOST-ACTIONS.md b/docs/blocks/HOST-ACTIONS.md index 88ac481..fbdc9b9 100644 --- a/docs/blocks/HOST-ACTIONS.md +++ b/docs/blocks/HOST-ACTIONS.md @@ -18,7 +18,7 @@ Composer composes and governs interface surfaces. It never executes an action ([ The emitter (dspack-emit 0.7.0) gives a node an action only where the reference profile says so. On both packaged references, that is a `button` or an `alert-dialog`. The action is `{"event": {"name": , "context": {}}}`: the id lowercased, with every run of other characters turned into `_` and any `_` at either end removed; an id with no letter or digit becomes `surface`. When a user activates it, the A2UI client reports `{name, surfaceId, sourceComponentId, timestamp, context}` to the host. -Without an id, the emitter would name the action from an alert dialog's confirm label, the node's text or its component, and a model can write the first two. So H1 refuses any action node whose id is missing or depends on anything but literals and enum props. An id may be a literal, an enum prop's value, a literal that an enum or boolean prop picks through `$case`, or several of these joined with `$concat`. It may not hold a string the model fills, a position (`$index`), or an optional enum where it may be absent. The model fills content; it never names an action. Nor may a template carry an action event in another way: H1 refuses a template value shaped like one, which a passthrough prop such as Astryx's table `data` would carry into the messages verbatim, and a signature field named `event`, which a call could fill. Function calls and data bindings are a limit (§7). +Without an id, the emitter would name the action from an alert dialog's confirm label, the node's text or its component, and a model can write the first two. So H1 refuses any action node whose id is missing or depends on anything but literals and enum props. An id may be a string literal, an enum prop's value, a string literal that an enum or boolean prop picks through `$case`, or several of these joined with `$concat`. An `$each` loop variable over an enum array counts as an enum prop: the shipped records-table, guide-steps and auth-form provider ids read one. A number may appear only as a part of a `$concat`, and a `$case` result or a `$prop` default follows the same rule. It may not hold a string the model fills, a position (`$index`), or an optional enum where it may be absent. The model fills content; it never names an action. Nor may a template carry an action event in another way: H1 refuses a template value shaped like one, which a passthrough prop such as Astryx's table `data` would carry into the messages verbatim, and a signature field named `event`, which a call could fill. Function calls and data bindings are a limit (§7). H1 then counts the names a call can make each id produce, at most 256 per id; an id whose props have more than 4096 combinations is refused before any is evaluated. It evaluates the id as the expander does, over every combination of the enum and boolean props it reads, along the branches each `$if` allows: a test of a prop's truthiness, or of its equality with a literal, narrows that prop, and a branch no call can take counts nothing. Those names must equal the block's declared `actions`. Where a condition or a cross-field constraint is beyond that reading, H1 counts the name anyway, so it may count a name no call reaches, but never misses one a call can emit. @@ -78,9 +78,10 @@ H1 then counts the names a call can make each id produce, at most 256 per id; an **Recomputing `messagesSha256`.** - **The bytes.** Hash the UTF-8 bytes of the surface's `messages` serialized as `JSON.stringify` writes them: no whitespace, and non-ASCII text left as it is, unescaped. -- **From the download.** It is pretty-printed, so parse it first. In JavaScript, `JSON.stringify(JSON.parse(file).surfaces[i].messages)` gives the bytes. +- **From the download.** It is pretty-printed, so parse it first. In JavaScript, `JSON.stringify(JSON.parse(file).surfaces[i].messages)` gives the string, where `surfaces[i]` is the surface whose `name` is the entry's `surface`. Its UTF-8 encoding is the bytes. - **In another language,** match that serializer exactly. In Python, `json.dumps(messages, separators=(",", ":"), ensure_ascii=False)` matches for ordinary text and integers; the default, which escapes non-ASCII text, does not. - **When.** Check the hash before you render the messages under a surfaceId of your own (§4, rule 2): substituting it changes the bytes. +- **If it does not match.** A host that cannot reproduce the bytes refuses to render the surface. **How a surface earns a block binding.** The manifest is recomputed from the surface's messages at every export, so an edited surface reports what it dispatches now. A surface binds a block only when all of these hold: - Its provenance names an approved block of an approved sidecar that the build bundles, and B0 (with H1) accepted that block at load. @@ -105,7 +106,7 @@ H1 then counts the names a call can make each id produce, at most 256 per id; an Bind actions by the surface you rendered, not by what the client reports. Look an entry up by `surface`, never by `surfaceId`, which surfaces of one intent share. Check the surface's messages, exactly as exported, against the entry's `messagesSha256` (§3), and render nothing that does not match. Then render them under a surfaceId of your own, and keep on your server which entry it is. Every field of an action event (`name`, `surfaceId`, `sourceComponentId`, `context`) is client-supplied and untrusted. 3. **Authorize on the server, per effect.** An action event is a request, never a grant. For each effect, check the session, the user's permission for that effect and the resource it touches, on your own server. Never trust a client-side check, the manifest or the presence of a button. -4. **Make effects idempotent.** Replays and double submits happen: retries, double clicks, a client that resends. A2UI action events carry no idempotency key of their own, and anything a client sends can be replayed or forged, so the key is your server's. It must stay the same for a retry and change for each new operation: a key made only of the session, the effect and its target would answer a second, intended save with the first one's result. So key each effect by an operation id your server issued, bound to the session, the effect, its target and its parameters. The surfaceId you render a surface under (rule 2) can serve: issue a fresh one each time an action should be possible again, and treat a repeat under the same one as a retry. Keep the store bounded, and answer a repeat with the first result. The exhibit's agent shows that shape for honest retries only: it keeps the answer for each client-chosen `actionId` and returns it again for a repeat (`answeredActions` in `apps/agent/src/server.ts`). A host must not trust a key the client chose. Rate-limit what can be repeated. +4. **Make effects idempotent.** Replays and double submits happen: retries, double clicks, a client that resends. A2UI action events carry no idempotency key of their own, and anything a client sends can be replayed or forged, so the key is your server's. It must stay the same for a retry and change for each new operation: a key made only of the session, the effect and its target would answer a second, intended save with the first one's result. So key each effect by an operation id your server issued, bound to the session, the effect, its target and its parameters. The surfaceId you render a surface under (rule 2) can serve: issue a fresh one each time an action should be possible again, and treat a repeat under the same one as a retry. Accept an event only under a surfaceId your server issued to that session and has not retired, and refuse any other: a client that minted its own ids would make every replay look new. A repeat under the same operation id with other parameters is refused, never answered with the first result. Keep the store bounded, and answer a repeat with the first result. The exhibit's agent shows that shape for honest retries only: it keeps the answer for each client-chosen `actionId` and returns it again for a repeat (`answeredActions` in `apps/agent/src/server.ts`). A host must not trust a key the client chose. Rate-limit what can be repeated. 5. **Honour the streaming policy.** For `disable-actions-until-complete`, keep the block's actions disabled in the client, and refuse them on the server, until the surface's message stream has completed. For `static-safe`, they may be enabled once rendered. A host that cannot tell when a stream completes treats every block as `disable-actions-until-complete`. 6. **Treat model content as untrusted data.** Every string in a call can be model-written: titles, captions, descriptions, column headings, cells, steps, items and their heading, meta labels and details, summaries and intro text. - Render them as text: never as HTML or markdown, never as a link or URL. diff --git a/docs/blocks/PHASE-2-RELEASE-PLAN.md b/docs/blocks/PHASE-2-RELEASE-PLAN.md index 8148bde..bc82480 100644 --- a/docs/blocks/PHASE-2-RELEASE-PLAN.md +++ b/docs/blocks/PHASE-2-RELEASE-PLAN.md @@ -5,6 +5,7 @@ > - G1 has landed: the records on main that §3 lists. So the plan's statements about what main holds describe `7e260c3`, before G1. For example, `docs/blocks/baseline.json` is now committed ([BASELINE.md](BASELINE.md)). > - The owner authorized step 2 on 2026-10-16, and it is built: the beta's delivery path, guards and switches ([../BLOCKS.md](../BLOCKS.md), "The beta (Phase 2)"; [../deployment.md](../deployment.md), "The beta composer"). No beta is deployed. Each later Phase 2 change still needs the owner's authorization. > - The owner authorized step 3 on 2026-10-16, limited to H1, H2 and H4 (§7), and it is built: the B0 action-id proof, the action manifest in the beta's Catalog export, and the host guide ([HOST-ACTIONS.md](HOST-ACTIONS.md)). The Catalog export that §7 locates at `exportBundle` in `preview-view.tsx` is now built by `catalogBundle` in `apps/composer/app/catalog-export.ts`. The manifest reads each surface's actions with `actionEvents` (`packages/composer-core/src/blocks/actions.ts`), which also reads each context, rather than `emittedActionNames`. H3, the Blocks catalog view and Preview's label, belongs to step 4. +> - The owner authorized step 4 on 2026-10-16: the read-only Blocks catalog and provenance UI (§7 H3, §8), and the first three host-guide clarifications from step 3's review. It is built: a Blocks section in the Catalog view; in Preview, a surface's provenance ("made with block X vN", only by exact expansion identity) with its status and specific reason, and the note that block actions are host-owned and not executed here; and §8's lifecycle, with an Example session's save, its duplicate and every import now pruning stale provenance too. Refinable states what a record is, not that a build will use the block. Nothing grants an action anything, and the default build is unchanged. Final version, 2026-10-16. The owner accepted the plan on 2026-10-15 as the conditional working plan for the next AF Composer release, and this version carries the owner's three clarifications (below) and the shadcn/ui v3 result. It is not authorization to begin Phase 2: nothing in it starts until the owner authorizes it. No code. diff --git a/e2e/composer-blocks-provenance.spec.ts b/e2e/composer-blocks-provenance.spec.ts new file mode 100644 index 0000000..b89e9ec --- /dev/null +++ b/e2e/composer-blocks-provenance.spec.ts @@ -0,0 +1,292 @@ +/** + * Phase 2, step 4 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7 H3 and §8) in the + * EXPERIMENTAL composer artifact: the read-only Blocks catalog, Preview's + * provenance line and host-owned wording, and provenance through save, + * export, import, duplicate and delete. Agent-free: /api/models is stubbed, + * every build is scripted, and no model is called. + */ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +type Json = Record; + +/** WCAG 2 A/AA over what this step adds, and nothing else on the page. */ +const scanOf = (page: Page, testId: string) => + new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["region"]).include(`[data-testid="${testId}"]`).analyze(); + +const SWITCH_ON = { enabled: true, references: ["shadcn", "astryx"], blocks: ["records-table", "auth-form", "guide-steps"] }; + +async function setUp(page: Page) { + await page.addInitScript(() => localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true }))); + await page.route("**/api/models", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ models: ["scripted"], blocks: SWITCH_ON }) })); + await page.goto("/"); +} + +async function newProject(page: Page, name: string) { + await page.getByTestId("nav-projects").click(); + await page.getByTestId("new-project-name").fill(name); + await page.getByTestId("new-source-shadcn").click(); + await page.getByTestId("new-project-create").click(); + await expect(page.getByTestId("build-prompt")).toBeVisible(); +} + +/** A scripted sign-in build on shadcn/ui expands auth-form; accept it (turn `turn` of the thread) and return its example id. */ +async function acceptAuthForm(page: Page, turn = 1): Promise { + await page.getByTestId("build-model").selectOption("scripted"); + await page.getByTestId("build-intent").selectOption("structured-input"); + await page.getByTestId("build-prompt").fill("a sign-in form with email and password"); + await page.getByTestId("build-run").click(); + await expect(page.getByTestId(`build-gate-summary-${turn}`)).toContainText("Follows your design-system rules", { timeout: 30_000 }); + await expect(page.getByTestId(`build-block-${turn}`)).toContainText("block auth-form v1"); + await page.getByTestId(`build-accept-${turn}`).click(); + await expect(page.getByTestId(`build-accepted-${turn}`)).toContainText(/ex\.chat-\d+/); + return (await page.getByTestId(`build-accepted-${turn}`).innerText()).match(/ex\.chat-\d+/)![0]; +} + +/** Every provenance map in this browser, by storage key. */ +const storedProvenance = (page: Page) => + page.evaluate(() => { + const out: Record> = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i)!; + if (key.startsWith("composer.project.blocks.")) out[key] = JSON.parse(localStorage.getItem(key)!); + } + return out; + }); + +/** Wait until provenance for `id` is stored (it is written after the accept, off the critical path). */ +async function provenanceStoredFor(page: Page, id: string) { + await expect.poll(async () => Object.values(await storedProvenance(page)).some((map) => id in map)).toBe(true); +} + +/** Edit the accepted auth-form surface's "Email address" label in Surfaces, and save. */ +async function editSurface(page: Page, id: string) { + await page.getByTestId("nav-surfaces").click(); + await page.getByTestId(`edit-${id}`).click(); + const texts = page.locator('[data-testid^="node-text-"]'); + const n = await texts.count(); + let edited = false; + for (let i = 0; i < n && !edited; i++) { + if ((await texts.nth(i).inputValue()) === "Email address") { + await texts.nth(i).fill("Work email address"); + edited = true; + } + } + expect(edited).toBe(true); + await expect(page.getByTestId("save-scenario")).toBeEnabled(); + await page.getByTestId("save-scenario").click(); +} + +/** Export the open project from the hub and return the file's JSON. */ +async function exportProjectFile(page: Page): Promise { + await page.getByTestId("nav-projects").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.locator('[data-testid^="project-export-"]').first().click()]); + const path = await download.path(); + const { readFileSync } = await import("node:fs"); + return JSON.parse(readFileSync(path!, "utf8")) as Json; +} + +/** Import a project file through the hub. */ +async function importProjectFile(page: Page, file: Json, name: string) { + await page.getByTestId("nav-projects").click(); + await page.getByTestId("import-project-input").setInputFiles({ name: `${name}.composerproject.json`, mimeType: "application/json", buffer: Buffer.from(JSON.stringify(file)) }); + await expect(page.getByTestId("build-prompt")).toBeVisible(); +} + +test("the Catalog lists every bundled block, read-only: reference, version, status, intents, declared actions and streaming policy", async ({ page }) => { + await setUp(page); + await newProject(page, "Catalog"); + await page.getByTestId("nav-inventory").click(); + const catalog = page.getByTestId("blocks-catalog"); + await expect(catalog).toBeVisible(); + await expect(catalog).toContainText("Interface blocks"); + + const shadcn = page.getByTestId("blocks-catalog-ref-shadcn"); + await expect(shadcn).toContainText("shadcn/ui 3.2.0"); + await expect(shadcn).toContainText("this project"); + await expect(shadcn).toContainText(/withheld from this beta/); + const astryx = page.getByTestId("blocks-catalog-ref-astryx"); + await expect(astryx).toContainText("Astryx 0.1.4"); + await expect(astryx).not.toContainText("this project"); + + const auth = page.getByTestId("blocks-catalog-shadcn-auth-form"); + for (const text of ["auth-form", "Account access", "v1", "approved", "structured-input", "auth_submit", "auth_forgot", "auth_provider_apple", "disable actions until complete"]) { + await expect(auth).toContainText(text); + } + const guide = page.getByTestId("blocks-catalog-astryx-guide-steps"); + for (const text of ["guide-steps", "structured-editing", "guide_start", "guide_save", "static-safe"]) await expect(guide).toContainText(text); + await expect(page.getByTestId("blocks-catalog-astryx-records-table")).toContainText("records_refresh"); + + // Read-only: nothing in it can be pressed, typed into or chosen. + await expect(catalog.locator("button, input, select, textarea, a[href]")).toHaveCount(0); + await expect(catalog).toContainText(/host-owned/); + expect((await scanOf(page, "blocks-catalog")).violations).toEqual([]); +}); + +test("trusted: an accepted block surface is made with its block and refinable; its actions are host-owned and Composer sends nothing", async ({ page }) => { + await setUp(page); + await newProject(page, "Trusted"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + const line = page.getByTestId("preview-provenance"); + await expect(line).toContainText("Made with block auth-form v1"); + // Refinable says what the record is, never that a build will use the block: + // the beta switch, Settings and eligibility decide that. + await expect(line).toContainText("Refinable: its record verifies against this build's approved block."); + await expect(line).not.toContainText("offers"); + const note = page.getByTestId("preview-host-owned"); + await expect(note).toContainText("host-owned"); + await expect(note).toContainText(/not executed here/); + + // Every action the surface dispatches is recorded, and none leaves the page. + const requests: string[] = []; + page.on("request", (r) => requests.push(`${r.method()} ${r.url()}`)); + const buttons = page.locator("[data-project-canvas] button:not([disabled])"); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) await buttons.nth(i).click(); + await expect(page.getByTestId("action-log")).toContainText("auth_submit"); + await page.waitForTimeout(500); + expect(requests).toEqual([]); + + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); + expect((await scanOf(page, "preview-host-owned")).violations).toEqual([]); +}); + +test("edited: saving a hand edit drops the provenance, and the surface previews as an ordinary one", async ({ page }) => { + await setUp(page); + await newProject(page, "Edited"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await editSurface(page, id); + await expect.poll(async () => Object.values(await storedProvenance(page)).some((map) => id in map)).toBe(false); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator("[data-project-canvas]")).toContainText("Work email address"); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); +}); + +test("duplicated and deleted: a duplicate carries provenance that still verifies; deleting a project deletes its provenance", async ({ page }) => { + await setUp(page); + await newProject(page, "Original"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await page.getByTestId("nav-projects").click(); + const grid = page.getByTestId("projects-grid"); + const originalId = (await grid.locator(".af-card").filter({ hasText: "Original" }).first().getAttribute("data-testid"))!.replace("project-", ""); + await page.getByTestId(`project-duplicate-${originalId}`).click(); + await expect(grid).toContainText("Original copy"); + const copyId = (await grid.locator(".af-card").filter({ hasText: "Original copy" }).first().getAttribute("data-testid"))!.replace("project-", ""); + expect(Object.keys((await storedProvenance(page))[`composer.project.blocks.${copyId}`] ?? {})).toEqual([id]); + + await page.getByTestId(`project-build-${copyId}`).click(); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("Made with block auth-form v1"); + await expect(page.getByTestId("preview-provenance")).toContainText(/refinable/i); + + await page.getByTestId("nav-projects").click(); + await page.getByTestId(`project-delete-${copyId}`).click(); + await page.getByTestId(`project-delete-confirm-${copyId}`).click(); + await expect(grid).not.toContainText("Original copy"); + const left = await storedProvenance(page); + expect(left[`composer.project.blocks.${copyId}`]).toBeUndefined(); + expect(Object.keys(left[`composer.project.blocks.${originalId}`] ?? {})).toEqual([id]); +}); + +test("imported: provenance survives export and import, still labels the surface, and is unavailable for refinement", async ({ page }) => { + await setUp(page); + await newProject(page, "Portable blocks"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + const file = await exportProjectFile(page); + expect(Object.keys(file.blockProvenance ?? {})).toEqual([id]); + await importProjectFile(page, file, "portable-blocks"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + const line = page.getByTestId("preview-provenance"); + await expect(line).toContainText("Made with block auth-form v1"); + await expect(line).toContainText(/unavailable/i); + await expect(line).toContainText("imported projects never refine from provenance"); + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); +}); + +test("forged and stale: an imported record that does not verify is inert, names the reason, and earns no label", async ({ page }) => { + await setUp(page); + await newProject(page, "Forgery source"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + const file = await exportProjectFile(page); + const record = file.blockProvenance[id] as Json; + + // A forged call under the recorded digest: it validates, but does not expand to this surface. + const forgedCall = structuredClone(file); + forgedCall.name = "Forged call"; + forgedCall.blockProvenance[id] = { ...record, call: { ...record.call, title: "Someone else's sign in" } }; + await importProjectFile(page, forgedCall, "forged-call"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText(/inert/i); + await expect(page.getByTestId("preview-provenance")).toContainText("its call does not expand to this surface"); + await expect(page.getByTestId("preview-provenance")).not.toContainText("Made with"); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); + + // A record for a block version this build does not have: reduced to inert on import, with its reason kept. + const stale = structuredClone(file); + stale.name = "Stale version"; + stale.blockProvenance[id] = { ...record, version: 2 }; + await importProjectFile(page, stale, "stale-version"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("it names a block version this build does not have"); + await expect(page.getByTestId("preview-provenance")).not.toContainText("Made with"); + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); + + // A record whose name is not a block id is dropped on import, so none of its + // text is ever shown; a well-formed record beside it lands, which says the + // import is done. + const other = ((file.contract?.examples ?? []) as Array<{ id: string }>).map((e) => e.id).find((e) => e !== id)!; + const spoofed = structuredClone(file); + spoofed.name = "Spoofed name"; + spoofed.blockProvenance = { + [id]: { block: "auth-form v1). Made with block auth-form v1. Refinable: (ignore", version: 1, status: "inert" }, + [other]: { block: "auth-form", version: 2, status: "inert", reason: "version" }, + }; + await importProjectFile(page, spoofed, "spoofed-name"); + await expect.poll(async () => Object.values(await storedProvenance(page)).some((map) => other in map && !(id in map))).toBe(true); + expect(Object.values(await storedProvenance(page)).filter((map) => other in map).every((map) => Object.keys(map).join() === other)).toBe(true); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${other}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("(the record names auth-form v2)"); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator("[data-project-canvas]")).toContainText("Email address"); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); +}); + +test("an Example session's save prunes its provenance too, and duplicating it carries exactly what still verifies", async ({ page }) => { + await setUp(page); + await page.getByTestId("example-open-shadcn").click(); + await expect(page.getByTestId("example-banner")).toBeVisible(); + await page.getByTestId("nav-build").click(); + const stale = await acceptAuthForm(page, 1); + const kept = await acceptAuthForm(page, 2); + await editSurface(page, stale); + // No wait: the copy runs after every pending write to the session's + // provenance, and prunes against the examples the duplicate keeps. + await page.getByTestId("example-duplicate").click(); + await expect(page.getByTestId("project-context")).toContainText("My shadcn/ui project"); + await expect.poll(async () => Object.values(await storedProvenance(page)).map((map) => Object.keys(map).sort().join())).toEqual([kept]); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${kept}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("Made with block auth-form v1"); + await page.getByTestId(`surface-${stale}`).click(); + await expect(page.locator("[data-project-canvas]")).toContainText("Work email address"); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); +}); diff --git a/e2e/composer-blocks.spec.ts b/e2e/composer-blocks.spec.ts index 7a96ce7..44cfd26 100644 --- a/e2e/composer-blocks.spec.ts +++ b/e2e/composer-blocks.spec.ts @@ -310,4 +310,9 @@ test("positive control: the experimental artifact really carries the block code walk(chunks); const carrying = files.filter((file) => readFileSync(file, "utf8").includes("blocksSidecar")); expect(carrying.length).toBeGreaterThan(0); + // Step 4's markers, which the default scans look for, are really in the experimental artifact. + const bodies = files.map((file) => readFileSync(file, "utf8")); + for (const marker of ["blocks-catalog", "preview-provenance", "preview-host-owned", "provenanceVerdicts"]) { + expect(bodies.some((body) => body.includes(marker)), marker).toBe(true); + } }); diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts index 98b3082..90f0ca4 100644 --- a/e2e/composer-prod-smoke.spec.ts +++ b/e2e/composer-prod-smoke.spec.ts @@ -675,6 +675,36 @@ test("the Catalog export is today's: the catalog and each surface's messages, an expect(text).toBe(JSON.stringify(bundle, null, 2)); }); +test("a default build ignores and drops block provenance: an imported file's provenance is never stored, and no Blocks catalog or provenance line appears", async ({ page }) => { + // A beta export carries blockProvenance; production drops it on import and + // shows no block UI (docs/blocks/PHASE-2-RELEASE-PLAN.md, §8). + await page.addInitScript(() => { + localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true })); + }); + await page.goto("/"); + await newProject(page, "shadcn", "Beta file"); + await page.getByTestId("nav-projects").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.locator('[data-testid^="project-export-"]').first().click()]); + const file = JSON.parse(readFileSync((await download.path())!, "utf8")) as Record; + expect(file).not.toHaveProperty("blockProvenance"); + const id = String(((file.contract?.examples ?? []) as Array<{ id: string }>)[0]?.id ?? "ex.chat-1"); + file.name = "Beta file with provenance"; + file.blockProvenance = { [id]: { v: 1, reference: { name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }, baseSha256: "0".repeat(64), block: "auth-form", version: 1, intent: "structured-input", blockSchemaSha256: "a".repeat(64), encoding: "named", call: {}, expansionDigest: "b".repeat(64) } }; + await page.getByTestId("import-project-input").setInputFiles({ name: "beta.composerproject.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(file)) }); + await expect(page.getByTestId("project-context")).toContainText("Beta file with provenance"); + const keys = await page.evaluate(() => Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)!).filter((k) => k.startsWith("composer.project.blocks."))); + expect(keys).toEqual([]); + await page.getByTestId("nav-inventory").click(); + await expect(page.getByTestId("catalog-summary")).toBeVisible(); + await expect(page.getByTestId("blocks-catalog")).toHaveCount(0); + await page.getByTestId("nav-preview").click(); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); +}); + +/** Step 4's UI and runtime call; the experimental suite's positive control finds each in its artifact. */ +const STEP4_MARKERS = ["blocks-catalog", "preview-provenance", "preview-host-owned", "provenanceVerdicts"]; + test("the default build ships no interface-block code (docs/BLOCKS.md: blocks stay out of default builds)", () => { // Scans the served artifact on disk, so a lazily loaded chunk cannot hide it. const chunks = fileURLToPath(new URL("../apps/composer/out/_next/static/chunks/", import.meta.url)); @@ -690,7 +720,8 @@ test("the default build ships no interface-block code (docs/BLOCKS.md: blocks st for (const file of files) { const body = readFileSync(file, "utf8"); // actionManifest: the beta's Catalog export key (docs/blocks/HOST-ACTIONS.md). - for (const marker of ["blocksSidecar", "disable-actions-until-complete", "records-table", "auth-form", "guide-steps", "blocks-runtime", "reference-not-in-beta", "beta-switch-", "actionManifest"]) { + // The rest: step 4's Blocks catalog and Preview provenance line, and the runtime call behind them. + for (const marker of ["blocksSidecar", "disable-actions-until-complete", "records-table", "auth-form", "guide-steps", "blocks-runtime", "reference-not-in-beta", "beta-switch-", "actionManifest", ...STEP4_MARKERS]) { expect(body.includes(marker), `${file} contains ${marker}`).toBe(false); } } diff --git a/packages/composer-core/src/blocks/index.ts b/packages/composer-core/src/blocks/index.ts index 8f5b4f1..983e2a0 100644 --- a/packages/composer-core/src/blocks/index.ts +++ b/packages/composer-core/src/blocks/index.ts @@ -62,15 +62,21 @@ export { export { decideRefinement, type BlockSeed, type RefinementDecision, type SwitchReason } from "./refine"; export { buildProvenanceEntry, + INERT_REASONS, parseProvenance, + PROVENANCE_REASON_TEXT, provenanceStatus, + provenanceVerdict, pruneProvenance, PROVENANCE_MAX_BYTES, trustedProvenance, type BlockProvenanceEntry, type InertProvenance, + type InertReason, type ProvenanceRecord, type ProvenanceStatus, + type ProvenanceVerdict, + type UnavailableReason, } from "./provenance"; export { recheckBlockChoice, reconcileBlockChoice, selectBlockDeterministic, withBlockChoice } from "./select"; export { eligibleBlocks, liveBaseDigest, type EligibilityInput, type IneligibleReason } from "./eligibility"; diff --git a/packages/composer-core/src/blocks/provenance-verdict.test.ts b/packages/composer-core/src/blocks/provenance-verdict.test.ts new file mode 100644 index 0000000..ef403a2 --- /dev/null +++ b/packages/composer-core/src/blocks/provenance-verdict.test.ts @@ -0,0 +1,266 @@ +/** + * The provenance verdict (docs/blocks/PHASE-2-RELEASE-PLAN.md, §8; step 4): + * what the beta's Preview says about a surface's block provenance. A surface + * earns "made with block X vN" only by exact expansion identity: the record + * is trusted against a bundled sidecar, its call re-expands to the digest it + * recorded, and the surface is that expansion. Anything less is inert, with + * the specific reason, and no label. The verdict states what a surface is; + * it never carries an action or a grant. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { loadProfile, type Profile } from "@aestheticfunction/dspack-emit"; +import { + actionManifest, + expandBlock, + expansionDigest, + loadBlocks, + parseBlocksSidecar, + parseProvenance, + PROVENANCE_REASON_TEXT, + provenanceVerdict, + type LoadedBlocks, + type ProvenanceVerdict, +} from "./index"; + +type Json = Record; +const repo = (rel: string) => fileURLToPath(new URL(`../../../../${rel}`, import.meta.url)); +const json = (rel: string) => JSON.parse(readFileSync(repo(rel), "utf8")) as Json; + +const rawSidecar = json("apps/composer/experimental/blocks/astryx.blocks.json"); +const contract = json("apps/composer/astryx-project/astryx.dspack.json"); +const profile = loadProfile(json("apps/composer/astryx-project/astryx.profile.json") as never) as Profile; +const parsed = parseBlocksSidecar(rawSidecar); +if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); +const sidecar = parsed.sidecar; +const authForm = sidecar.blocks.find((b) => b.id === "auth-form")!; +const example = authForm.examples[0]!; +const expanded = expandBlock(authForm, example.call, { reference: sidecar.reference, intent: example.intent }); +if (!expanded.ok) throw new Error(expanded.message); +const surface = expanded.surface as unknown as Json; + +/** The same surface after a hand edit: no longer the expansion. */ +const edited = structuredClone(surface); +edited.root.children[0].text = "Edited by hand"; + +let loaded: LoadedBlocks; +let record: Json; +const REFERENCE_PROJECT = { kind: "reference" as const, reference: sidecar.reference }; + +beforeAll(async () => { + loaded = await loadBlocks(rawSidecar, { baseContract: contract, profile }); + record = { + v: 1, + reference: sidecar.reference, + baseSha256: sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: await expansionDigest(surface), + }; +}); + +const verdict = (overrides: Partial[0]> = {}) => + provenanceVerdict({ record, surface, loaded: [loaded], project: REFERENCE_PROJECT, ...overrides }); + +describe("trusted provenance earns the label", () => { + it("a reference project's accepted block surface is made with its block, and refinable", async () => { + expect(await verdict()).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "refinable" }); + }); + + it("an Example session is a reference project too", async () => { + expect(await verdict({ project: { kind: "reference", reference: sidecar.reference } })).toMatchObject({ madeWith: true, status: "refinable" }); + }); +}); + +describe("only exact expansion identity earns the label", () => { + it("edited: a surface changed after acceptance is inert, with no label; saving drops the record", async () => { + expect(await verdict({ surface: edited })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "edited" }); + }); + + it("forged digest: a record that claims the edited surface's digest still cannot label it, because its call does not expand to it", async () => { + const forged = { ...record, expansionDigest: await expansionDigest(edited) }; + expect(await verdict({ record: forged, surface: edited })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "expansion" }); + }); + + it("forged call: another valid call under the recorded digest does not expand to it", async () => { + const forged = { ...record, call: { ...example.call, title: "Someone else's title" } }; + expect(await verdict({ record: forged })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "expansion" }); + }); + + it("forged or stale bindings: each is inert, with the specific reason", async () => { + const cases: Array<[string, Json, string]> = [ + ["a reference this build does not bundle", { ...record, reference: { name: "Elsewhere", dspack: "0.4", version: "9.9.9" } }, "reference"], + ["another base contract", { ...record, baseSha256: "0".repeat(64) }, "base"], + ["a block this build does not bundle", { ...record, block: "auth-wizard" }, "block"], + ["a version this build does not bundle", { ...record, version: 2 }, "version"], + ["a call that fails B1", { ...record, call: { ...example.call, mode: "sign-ip" } }, "validation"], + ["an intent the block does not declare", { ...record, intent: "record-collection" }, "validation"], + ["the wrong encoding", { ...record, encoding: "positional" }, "validation"], + ]; + for (const [what, forged, reason] of cases) { + expect(await verdict({ record: forged }), what).toEqual({ block: forged.block, version: forged.version, madeWith: false, status: "inert", reason }); + } + }); + + it("a reference project only trusts provenance for its own reference", async () => { + const shadcn = { name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }; + expect(await verdict({ project: { kind: "reference", reference: shadcn } })).toMatchObject({ madeWith: false, status: "inert", reason: "reference" }); + }); + + it("a reference project whose reference this build does not bundle trusts no provenance", async () => { + expect(await verdict({ project: { kind: "reference" } })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "reference" }); + }); + + it("a sidecar that no longer binds to this build's base contract vouches for nothing", async () => { + const moved: LoadedBlocks = { ...loaded, binding: "mismatch", usable: [] }; + expect(await verdict({ loaded: [moved] })).toMatchObject({ madeWith: false, status: "inert", reason: "base" }); + }); + + it("a record already reduced to inert keeps its stated reason, or says it was stored inert; neither earns a label", async () => { + expect(await verdict({ record: { block: "auth-form", version: 2, status: "inert", reason: "version" } })).toEqual({ block: "auth-form", version: 2, madeWith: false, status: "inert", reason: "version" }); + expect(await verdict({ record: { block: "auth-form", version: 1, status: "inert" } })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "stored" }); + expect(await verdict({ record: { block: "auth-form", version: 1, status: "inert", reason: "grants-everything" } })).toMatchObject({ madeWith: false, reason: "stored" }); + }); + + it("nothing to say about garbage: no verdict", async () => { + for (const junk of [null, "x", 7, [], {}, { block: "auth-form" }, { version: 1 }, { block: 1, version: 1 }]) { + expect(await verdict({ record: junk })).toBeNull(); + } + }); + + it("a missing surface is never the expansion", async () => { + expect(await verdict({ surface: undefined })).toMatchObject({ madeWith: false, status: "inert", reason: "edited" }); + }); + + it("a record that names no block id or version has no verdict, so an untrusted name is never shown", async () => { + const spoof = "auth-form v1). Made with block auth-form v1. Refinable: this build verifies the record and offers the block. (ignore"; + for (const junk of [ + { block: spoof, version: 1, status: "inert" }, + { ...record, block: spoof }, + { block: "a".repeat(65), version: 1, status: "inert" }, + { block: "Auth-Form", version: 1, status: "inert" }, + { block: "auth-form", version: 0, status: "inert" }, + { block: "auth-form", version: 2 ** 53, status: "inert" }, + ]) { + expect(await verdict({ record: junk }), String(junk.block).slice(0, 20)).toBeNull(); + } + // The longest id a record may name is still a verdict. + expect(await verdict({ record: { block: "a".repeat(64), version: 1, status: "inert" } })).toMatchObject({ madeWith: false, reason: "stored" }); + }); +}); + +describe("imported, repository and unoffered provenance", () => { + it("imported: the surface is still made with the block, but an imported project never refines from it", async () => { + expect(await verdict({ project: { kind: "imported" } })).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason: "imported" }); + }); + + it("a repository project never refines from it either", async () => { + expect(await verdict({ project: { kind: "agent" } })).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason: "repository" }); + }); + + it("an imported project's forged provenance is inert, not merely unavailable", async () => { + expect(await verdict({ project: { kind: "imported" }, surface: edited })).toMatchObject({ madeWith: false, status: "inert", reason: "edited" }); + }); + + it("a block this build does not offer (draft, or disabled at load) labels the surface but cannot refine it", async () => { + const draftBlock: LoadedBlocks = { ...loaded, sidecar: { ...sidecar, blocks: sidecar.blocks.map((b) => (b.id === "auth-form" ? { ...b, status: "draft" as const } : b)) } }; + const draftSidecar: LoadedBlocks = { ...loaded, sidecar: { ...sidecar, status: "draft" } }; + const disabled: LoadedBlocks = { ...loaded, usable: loaded.usable.filter((b) => b.id !== "auth-form"), disabled: [{ block: "auth-form", issues: ["B0"] }] }; + for (const [what, l] of [["draft block", draftBlock], ["draft sidecar", draftSidecar], ["disabled at load", disabled]] as const) { + expect(await verdict({ loaded: [l] }), what).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "inert", reason: "not-offered" }); + } + }); +}); + +describe("provenance never grants action authority", () => { + it("a verdict carries what the surface is, never an action, a declaration or a grant", async () => { + const all: ProvenanceVerdict[] = [ + (await verdict())!, + (await verdict({ surface: edited }))!, + (await verdict({ project: { kind: "imported" } }))!, + (await verdict({ record: { ...record, version: 2 } }))!, + ]; + for (const v of all) { + for (const key of Object.keys(v)) expect(["block", "version", "madeWith", "status", "reason"]).toContain(key); + expect(JSON.stringify(v)).not.toMatch(/auth_submit|declared|action|grant|authori[sz]/i); + } + }); + + it("the label does not bind actions: messages altered after the surface was labelled bind nothing in the manifest", async () => { + const { emitSurface } = await import("@aestheticfunction/dspack-emit"); + const messages = emitSurface(surface as never, contract as never, { profile }).messages as Json[]; + const altered = structuredClone(messages); + altered.find((m) => m.updateComponents)!.updateComponents.components[0].text = "Altered"; + expect(await verdict()).toMatchObject({ madeWith: true }); + const manifest = await actionManifest({ + surfaces: [{ name: "ex.chat-1", messages: altered }], + examples: [{ id: "ex.chat-1", surface }], + provenance: { "ex.chat-1": record }, + references: [{ loaded, contract, profile }], + }); + expect(manifest.surfaces[0]!.block).toBeNull(); + expect(manifest.surfaces[0]!.actions.every((a) => a.declared === false)).toBe(true); + }); +}); + +describe("the words for every reason", () => { + it("every inert and unavailable reason has plain text, in the AF voice", () => { + const reasons = ["reference", "base", "block", "version", "validation", "expansion", "edited", "not-offered", "stored", "imported", "repository"]; + expect(Object.keys(PROVENANCE_REASON_TEXT).sort()).toEqual([...reasons].sort()); + for (const reason of reasons) { + const text = PROVENANCE_REASON_TEXT[reason as keyof typeof PROVENANCE_REASON_TEXT]; + expect(text.length, reason).toBeGreaterThan(10); + expect(text, reason).not.toMatch(/—/); + } + }); +}); + +describe("parseProvenance keeps the reason it reduced a record for", () => { + it("each record reduced to inert on import says why", async () => { + const out = parseProvenance( + { + ok: record, + reference: { ...record, reference: { name: "Elsewhere", dspack: "0.4", version: "9.9.9" } }, + base: { ...record, baseSha256: "0".repeat(64) }, + block: { ...record, block: "auth-wizard" }, + version: { ...record, version: 7 }, + validation: { ...record, call: { ...example.call, mode: "sign-ip" } }, + }, + [sidecar], + ); + expect(out.ok).toEqual(record); + expect(out.reference).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "reference" }); + expect(out.base).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "base" }); + expect(out.block).toEqual({ block: "auth-wizard", version: 1, status: "inert", reason: "block" }); + expect(out.version).toEqual({ block: "auth-form", version: 7, status: "inert", reason: "version" }); + expect(out.validation).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "validation" }); + }); + + it("an inert record keeps a known reason and drops any other text", () => { + const out = parseProvenance( + { a: { block: "auth-form", version: 2, status: "inert", reason: "version" }, b: { block: "auth-form", version: 1, status: "inert", reason: "