diff --git a/capabilities/cap.implement.generate-direction.yaml b/capabilities/cap.implement.generate-direction.yaml
index cd693b8..1f5335c 100644
--- a/capabilities/cap.implement.generate-direction.yaml
+++ b/capabilities/cap.implement.generate-direction.yaml
@@ -68,6 +68,7 @@ workflows:
candidate: { type: object }
initialState: preparing
initialContext:
+ assets_ok: false
renderable: false
candidate: {}
reason: ""
@@ -232,7 +233,7 @@ workflows:
verify_gate:
transitions:
ok:
- target: done
+ target: checking_assets
actor: deterministic
guards:
- { kind: expr, expr: "$.context.renderable == true" }
@@ -242,6 +243,40 @@ workflows:
guards:
- { kind: expr, expr: "$.context.renderable != true" }
+ # ASSET REFERENCES (#4 made local assets legal; nothing checked them).
+ # A candidate that references files which are not there renders as a page
+ # full of broken-image icons while parsing perfectly — observed live, and
+ # carried four generations toward a human before anyone noticed. A network
+ # reference is the same class: it renders here and fails everywhere else,
+ # including in the headless screenshotter.
+ checking_assets:
+ goal: Confirm every referenced asset is a local file that exists.
+ transitions:
+ check:
+ target: assets_gate
+ actor: deterministic
+ executor:
+ kind: script
+ subject: verify.assets-resolve
+ args:
+ - "{{ $.workflow.input.out_dir }}/candidate-{{ $.workflow.input.candidate_id }}.html"
+ output:
+ assets_ok: "$.output.json.assets_ok"
+ reason: "$.output.json.reason"
+
+ assets_gate:
+ transitions:
+ ok:
+ target: done
+ actor: deterministic
+ guards:
+ - { kind: expr, expr: "$.context.assets_ok == true" }
+ broken:
+ target: failed
+ actor: deterministic
+ guards:
+ - { kind: expr, expr: "$.context.assets_ok != true" }
+
done:
terminal: true
outcome: success
diff --git a/scripts-library/assets.test.mjs b/scripts-library/assets.test.mjs
new file mode 100644
index 0000000..2126098
--- /dev/null
+++ b/scripts-library/assets.test.mjs
@@ -0,0 +1,160 @@
+// assets.test.mjs — a referenced asset must actually be there, and must be local.
+//
+// #4 made local assets legal and the pack could check neither of the two rules
+// that came with them. Both failed live:
+//
+// * The brief that named the artwork lost its filename list to a copy/paste
+// slip. The generator invented plausible names from the story titles
+// (`art/dragon-under-stairs.png`) and wrote eight tags pointing at
+// nothing. The candidate parsed, passed verify.candidate-renderable, scored,
+// and was on its way to a human as a legitimate direction. Four generations
+// went by; a person looking at the pixels caught it, not the pipeline.
+//
+// * "Renders with the network unplugged" is stated in both prompt layers and
+// verified nowhere. A CDN font would look perfect on the machine that made
+// it and degrade silently everywhere else.
+//
+// Same discipline the pack already applies to writes: the claim is not trusted,
+// deterministic code re-opens the artifact, and a described-but-absent thing is
+// a failure rather than a shrug.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, writeFileSync, mkdtempSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+function bodyRunner(yamlName) {
+ const raw = readFileSync(join(HERE, yamlName), "utf8");
+ const lines = raw.split("\n");
+ const i = lines.findIndex((l) => /^\s*body:\s*\|\s*$/.test(l));
+ assert.ok(i >= 0, `no 'body: |' block in ${yamlName}`);
+ const indent = lines[i].match(/^(\s*)/)[1].length + 2;
+ const out = [];
+ for (let k = i + 1; k < lines.length; k++) {
+ const l = lines[k];
+ if (l.trim() === "") { out.push(""); continue; }
+ if (l.match(/^(\s*)/)[1].length < indent) break;
+ out.push(l.slice(indent));
+ }
+ const file = join(mkdtempSync(join(tmpdir(), "as-")), yamlName.replace(/\.yaml$/, ".js"));
+ writeFileSync(file, out.join("\n"));
+ return file;
+}
+
+const CHECK = bodyRunner("verify.assets-resolve.yaml");
+
+// Build a candidate directory with a real art/ folder, then check some markup.
+function check(bodyHtml, { assets = ["art/plate-one.png"] } = {}) {
+ const dir = mkdtempSync(join(tmpdir(), "as-run-"));
+ for (const a of assets) {
+ mkdirSync(join(dir, dirname(a)), { recursive: true });
+ writeFileSync(join(dir, a), "PNGDATA");
+ }
+ const artifact = join(dir, "candidate-c1.html");
+ writeFileSync(artifact, `
`);
+ assert.equal(out.assets_ok, true);
+ assert.equal(out.checked, 1);
+});
+
+test("CSS url() references are checked too, not just markup", () => {
+ const out = check(``);
+ assert.equal(out.assets_ok, true);
+ assert.equal(out.checked, 1);
+});
+
+test("srcset candidates are split off their descriptors before resolving", () => {
+ const out = check(
+ `
`,
+ { assets: ["art/plate-one.png", "art/plate-two.png"] },
+ );
+ assert.equal(out.assets_ok, true);
+});
+
+test("a query string or fragment does not make a real file look missing", () => {
+ const out = check(`
`);
+ assert.equal(out.assets_ok, true);
+});
+
+// --- the failure observed live ---------------------------------------------
+
+test("invented filenames are BROKEN_ASSET_REFERENCE", () => {
+ const out = check(`
+
+
+
`);
+ assert.equal(out.assets_ok, false);
+ assert.equal(out.reason, "BROKEN_ASSET_REFERENCE");
+ assert.equal(out.missing_count, 2);
+ assert.ok(out.missing.includes("art/dragon-under-stairs.png"));
+ assert.ok(!out.missing.includes("art/plate-one.png"), "the file that exists is not reported");
+});
+
+test("a root-relative path is broken — a self-contained page has no document root", () => {
+ const out = check(`
`);
+ assert.equal(out.assets_ok, false);
+ assert.equal(out.reason, "BROKEN_ASSET_REFERENCE");
+});
+
+// --- the network rule, finally enforced -------------------------------------
+
+test("a CDN stylesheet is NETWORK_ASSET_REFERENCE", () => {
+ const out = check(``);
+ assert.equal(out.assets_ok, false);
+ assert.equal(out.reason, "NETWORK_ASSET_REFERENCE");
+ assert.equal(out.network_count, 1);
+});
+
+test("a protocol-relative URL is a network reference too", () => {
+ const out = check(`
`);
+ assert.equal(out.assets_ok, false);
+ assert.equal(out.reason, "NETWORK_ASSET_REFERENCE");
+});
+
+test("a remote webfont in CSS is caught", () => {
+ const out = check(``);
+ assert.equal(out.assets_ok, false);
+ assert.equal(out.network_count, 1);
+});
+
+// Network is the stricter rule and the one that fails on someone else's
+// machine, so it names the reason when both are present.
+test("network takes precedence over broken when both are present", () => {
+ const out = check(`
`);
+ assert.equal(out.reason, "NETWORK_ASSET_REFERENCE");
+ assert.equal(out.missing_count, 1, "the broken one is still reported");
+ assert.equal(out.network_count, 1);
+});
+
+// --- things that are not file references at all ------------------------------
+
+test("data: URIs, anchors and mailto are ignored, not resolved", () => {
+ const out = check(`
+