diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 1ea9da9..4b450dd 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -67,3 +67,17 @@ jobs:
python-version: "3.12"
- name: Check every field in the body appears in Appendix A
run: python3 scripts/check_appendix.py
+
+ creative-inspection:
+ name: Creative inspection fixtures
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: python3 scripts/check_creative_fixtures.py
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - run: node scripts/check_preview_observations.mjs
diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md
new file mode 100644
index 0000000..b75c288
--- /dev/null
+++ b/docs/creative-inspection-fixtures.md
@@ -0,0 +1,173 @@
+# Inspect creative without reporting live delivery
+
+This contribution implements the proposed wording in
+[#42](https://github.com/IABTechLab/OpenProposal/issues/42) and supplies synthetic
+fixtures that exercise it. It also supports the distinction raised in
+[#32](https://github.com/IABTechLab/OpenProposal/issues/32): resolving a format,
+fetching a document, checking conformance and receiving seller approval are
+separate results.
+
+The normative change adds the inspection subsection to section 5.4 and a note
+to the CTV example. It adds no fields or enum values. The appendix field
+indexes therefore remain unchanged.
+
+## Reproduce the document inspection
+
+```
+python3 scripts/check_creative_fixtures.py
+python3 scripts/check_creative_fixtures.py --json
+```
+
+The standard-library runner starts a loopback HTTP server. The server records
+every request, including requests to synthetic notification traps. Nothing
+loads a campaign URL. The two VAST templates use fixture substitution tokens
+that the runner replaces with the server's loopback address.
+
+The reference inspector retrieves only document request identities listed by
+the test harness. It follows a single-ad wrapper chain, hashes retrieved bytes and
+parses XML. It does not execute code, fetch media, probe event URLs, expand
+macros, use proxy environment variables, or automatically follow redirects.
+An HTTP redirect can itself target an event endpoint. It must not become an
+unchecked route around the retrieval policy.
+
+The allowlist, event guard and cycle detection share one HTTP URL identity
+function. It removes fragments, normalizes scheme/host spelling, IPv6 spelling
+and default ports, and supplies `/` for an empty path. The transport sends that
+same normalized URL. Path spelling, query order, percent encodings and an empty
+query delimiter are preserved. A receipt retains both the supplied document URL
+and the actual request URL. Unsupported schemes, credentials, control characters
+and non-ASCII URLs are blocked in this deliberately limited fixture profile.
+
+The event-role set includes linear, companion, non-linear and icon tracking,
+viewability, verification and error URIs. Clickthrough destinations are also
+excluded from wrapper retrieval. Regression cases deliberately allowlist targets
+that are also declared as events, so an absent allowlist entry cannot hide a
+broken event guard. Fragment aliases are tested on both sides of that comparison.
+This does not infer server-side URL aliases, custom extension semantics or
+resource intent. The caller must establish that its initial resource allowlist
+is appropriate. It is not a production fetch policy.
+
+Successful wrapper traversal reaches the inline document with no requests to
+its impression, playback, click, viewability, verification or error endpoints.
+Failure cases include cycles, missing documents, malformed XML, a redirect to
+an event URL, excessive size, excessive depth and an event URL also used as a
+wrapper target. Unresolved macros, unsupported encodings, DTDs and ad pods are
+reported as not checked within this limited profile. Missing wrapper targets
+and malformed XML fail their specific structural checks.
+
+The server-side positive control deliberately sends GET and HEAD to a local
+notification trap and verifies both are recorded. This establishes that zero
+notification requests in the inspection cases is an observable test result.
+Neither response availability nor HTTP method makes an event request safe.
+
+## Reproduce the browser preview
+
+Requires Node.js 22 or later and an installed Chrome or Chromium executable.
+Set `CHROME_BIN` if it is not at one of the common paths checked by the runner.
+`CREATIVE_CACHE_DIR` can place the temporary browser profile in a chosen cache
+directory. The directory must already exist. The profile is removed on exit.
+
+```
+node scripts/check_creative_preview.mjs
+node scripts/check_creative_preview.mjs --json
+node scripts/check_preview_observations.mjs
+```
+
+The browser runs synthetic creative that attempts a GET, HEAD, POST, beacon,
+XHR, image, script, nested frame, CSS image, video poster, form submission,
+navigation and WebSocket connection. Each mechanism has a positive control
+that must reach its loopback notification trap using the expected method.
+Each isolated case must execute the same attempt and produce zero requests
+at that trap. A receipt includes the browser version, attempted operation,
+server observations, blocked requests and a 500 ms observation interval
+after the synchronous attempt.
+
+The assertion checks the entire notification-trap log for that case, including
+unexpected paths and methods. The receipt preserves every observation and its
+full URL. Control cases must reach only their expected notification endpoint;
+isolated cases must produce no notification at all. The assertion regression
+tests run without a browser and are included in CI.
+
+An integration negative control checks that an unexpected notification cannot
+disappear from the result:
+
+```
+node scripts/check_creative_preview.mjs --json --inject-notification-canary
+```
+
+That command intentionally exits with status 1. The test driver injects one
+loopback event at an unexpected path in `fetch-get/isolated`; that case must
+fail and its receipt must contain the event. This is a check of the assertion,
+not a browser escape or a live campaign notification.
+
+The isolated preview combines a sandboxed frame, a restrictive Content
+Security Policy, browser request interception and an independent HTTP proxy
+that permits only the exact preview-document request. Proxy bypass for
+loopback is disabled. The proxy rejects CONNECT except for the WebSocket
+positive control's loopback tunnel. It permits a control's specific loopback
+event URL only while that control runs. It never forwards to external
+destinations. A separate navigation case disables CSP and browser interception
+to verify that the independent gate itself blocks the notification.
+
+The independent gate is essential to this demonstration. An initial test
+using CSP and target-scoped browser interception alone allowed frame
+navigation to reach the synthetic event endpoint in Chrome. The navigation
+case remains in the suite and must be blocked outside the renderer too.
+This finding supports a requirement about observable notifications rather
+than prescribing a particular browser API as sufficient isolation.
+
+These are finite synthetic tests, not proof that this harness safely executes
+arbitrary third-party code. It is not a production sandbox. Delayed traffic,
+all possible protocols, browser vulnerabilities, WebRTC, native applications
+and real verification SDKs are outside the test coverage. A production
+inspector needs enforcement for its entire execution environment and must
+decline a check it cannot isolate. The browser fixture demonstrates request
+suppression; it does not demonstrate video decoding or playback conformance.
+
+## Report the exact check and its evidence
+
+The JSON receipt is a non-normative sidecar for these tests, not a new
+OpenProposal object or an adopted readiness protocol. Each result names the
+check, its outcome and its reason. Retrieved documents carry their SHA-256
+hash, URL and byte count. The receipt also identifies the asset, inspection
+time, root URL, depth/size limits, redirect policy and macro context.
+
+- `passed`: the named check ran and met that check's condition. Parsing XML
+ does not pass VAST schema validation. Reaching `InLine` does not pass
+ playback, media decode, seller specs or seller approval.
+- `failed`: the named check ran and found a concrete violation, such as
+ malformed XML or a cycle in the retrieved wrapper chain.
+- `not_checked`: the check did not establish an outcome. The receipt states
+ whether data was missing, retrieval failed, policy blocked it or the
+ implementation did not support the check. These reasons do not establish
+ that the creative itself is invalid.
+
+A required check set is satisfied only if every named check has at least one
+result and all its results passed. Missing, failed or not-checked results
+cannot satisfy it. An empty requirement list cannot establish satisfaction.
+Even satisfied inspection checks do not establish seller acceptance.
+`readiness` remains `not_assessed` in this first profile.
+
+The inspector leaves VAST schema conformance, resolved format membership,
+seller-spec conformance, media decode, verification execution, live playback
+and seller approval explicitly not checked. A minimal `` fixture is
+included to prove that successful traversal never becomes a schema pass.
+
+## Boundaries for the next creative contribution
+
+Readiness needs a declared check set for the resolved format and delivery
+protocol. An audit needs the exact asset bytes, resolved wrapper documents,
+seller-spec version, execution context, checker version and evidence expiry.
+Mutable URLs or changed specifications cannot inherit an earlier pass without
+a revalidation rule. Those are follow-up decisions, not implicit guarantees
+of this fixture receipt.
+
+This proposal does not require inspectors to fetch documents or offer a
+preview. It does not change VAST's live-delivery tracking requirements.
+Synthetic event delivery to a documented receiver test mode would need its
+own evidence that the receiver excludes it from production measurement.
+An inspector-owned `test=true` parameter cannot establish that fact.
+
+References: [VAST 4.3, sections 2.3.4 and 2.3.6](https://iabtechlab.com/wp-content/uploads/2022/09/VAST_4.3.pdf),
+[Content Security Policy](https://www.w3.org/TR/CSP3/), and
+[Chrome DevTools Fetch domain](https://chromedevtools.github.io/devtools-protocol/tot/Fetch/).
diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml
index fda3994..0487e17 100644
--- a/examples/line-item-ctv.yaml
+++ b/examples/line-item-ctv.yaml
@@ -33,3 +33,6 @@ buying_route: SSP_PLATFORM
transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] }
serving_mode: ssai
correlation_id: "ep-q4-2026-commitment/weather-app-ctv"
+
+# Inspection and previews must not send production tracking or error notifications.
+# See the creative inspection requirements in spec section 5.4.
diff --git a/fixtures/creative/inline.xml b/fixtures/creative/inline.xml
new file mode 100644
index 0000000..84a50ec
--- /dev/null
+++ b/fixtures/creative/inline.xml
@@ -0,0 +1,45 @@
+
+
+
+
+ OpenProposal inspection fixture
+ Synthetic inspection asset
+
+ synthetic-serving-id
+
+
+
+
+
+
+
+
+
+ synthetic
+
+
+
+
+
+
+
+ synthetic-asset
+
+ 00:00:15
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fixtures/creative/wrapper.xml b/fixtures/creative/wrapper.xml
new file mode 100644
index 0000000..c999551
--- /dev/null
+++ b/fixtures/creative/wrapper.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ OpenProposal inspection fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py
new file mode 100644
index 0000000..0bd8bf8
--- /dev/null
+++ b/scripts/check_creative_fixtures.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+"""Exercise inspection against local resources and local notification traps."""
+
+import argparse
+import json
+import threading
+import urllib.request
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+from inspect_creative_fixture import FixtureTransport, Unchecked, checks_satisfied, inspect, request_url
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def run():
+ requests, routes = [], {}
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ requests.append({"method": "GET", "path": self.path})
+ if self.path == "/redirect":
+ self.send_response(302)
+ self.send_header("Location", "/events/redirect")
+ self.end_headers()
+ return
+ status, body = routes.get(self.path, (404, b"not found"))
+ self.send_response(status)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_HEAD(self):
+ requests.append({"method": "HEAD", "path": self.path})
+ self.send_response(204)
+ self.end_headers()
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ base = "http://127.0.0.1:" + str(server.server_port)
+ inline = (ROOT / "fixtures/creative/inline.xml").read_text().replace("{{BASE}}", base)
+ wrapper = (ROOT / "fixtures/creative/wrapper.xml").read_text().replace("{{BASE}}", base)
+
+ def wrap(target):
+ return wrapper.replace("{{TARGET}}", base + target).encode()
+
+ routes.update({
+ "/inline": (200, inline.encode()), "/wrapper": (200, wrap("/inline")),
+ "/cycle": (200, wrap("/cycle")), "/broken-wrapper": (200, wrap("/missing")),
+ "/external": (200, wrapper.replace("{{TARGET}}", "https://unreachable.example.test/ad").encode()),
+ "/macro": (200, wrap("/inline?context=[PAGEURL]")),
+ "/malformed": (200, b""),
+ "/not-vast": (200, b"
"),
+ "/doctype": (200, b']>&x;'),
+ "/utf16": (200, inline.encode("utf-16")),
+ "/large": (200, b" " * 65537), "/empty": (200, b''),
+ "/pod": (200, b''),
+ "/missing-target": (200, b''),
+ "/both": (200, b''),
+ "/dual-role": (200, wrap("/events/wrapper-impression")),
+ "/events/wrapper-impression": (200, inline.encode()),
+ "/inline-minimal": (200, b''),
+ })
+ allowed = {base + path for path in routes if not path.startswith("/events/")} | {base + "/missing", base + "/redirect"}
+ definitions = [
+ ("wrapper-to-inline", "/wrapper", "wrapper_resolution", "passed", ["/wrapper", "/inline"], 5),
+ ("inline-document", "/inline", "wrapper_resolution", "passed", ["/inline"], 5),
+ ("wrapper-cycle", "/cycle", "wrapper_resolution", "failed", ["/cycle"], 5),
+ ("wrapper-http-failure", "/broken-wrapper", "document_retrieval", "not_checked", ["/broken-wrapper", "/missing"], 5),
+ ("redirect-to-event-blocked", "/redirect", "document_retrieval", "not_checked", ["/redirect"], 5),
+ ("external-target-blocked", "/external", "document_retrieval", "not_checked", ["/external"], 5),
+ ("unresolved-macro-blocked", "/macro", "document_retrieval", "not_checked", ["/macro"], 5),
+ ("malformed-xml", "/malformed", "xml_well_formedness", "failed", ["/malformed"], 5),
+ ("wrong-root", "/not-vast", "vast_root", "failed", ["/not-vast"], 5),
+ ("doctype-not-executed", "/doctype", "xml_well_formedness", "not_checked", ["/doctype"], 5),
+ ("unsupported-encoding", "/utf16", "xml_well_formedness", "not_checked", ["/utf16"], 5),
+ ("document-size-limit", "/large", "document_retrieval", "not_checked", ["/large"], 5),
+ ("empty-response-not-conformance", "/empty", "wrapper_resolution", "not_checked", ["/empty"], 5),
+ ("ad-pod-outside-profile", "/pod", "wrapper_resolution", "not_checked", ["/pod"], 5),
+ ("wrapper-target-missing", "/missing-target", "wrapper_resolution", "failed", ["/missing-target"], 5),
+ ("ambiguous-ad-shape", "/both", "wrapper_resolution", "failed", ["/both"], 5),
+ ("document-count-limit", "/wrapper", "wrapper_resolution", "not_checked", ["/wrapper"], 1),
+ ("event-also-used-as-wrapper-target", "/dual-role", "wrapper_resolution", "not_checked", ["/dual-role"], 5),
+ ("minimal-inline-is-not-schema-validation", "/inline-minimal", "vast_schema_conformance", "not_checked", ["/inline-minimal"], 5),
+ ]
+ dual_allowed = {"/dual-role": {base + "/events/wrapper-impression"}}
+ expected_event_guard = set()
+
+ def add_guard_case(name, xml, target):
+ path = "/" + name
+ routes[path] = (200, xml.encode())
+ allowed.add(base + path)
+ dual_allowed[path] = {target}
+ expected_event_guard.add(path)
+ definitions.append((name, path, "wrapper_resolution", "not_checked", [path], 5))
+
+ event = base + "/events/wrapper-impression"
+ add_guard_case("fragment-on-wrapper-target", wrapper.replace("{{TARGET}}", event + "#inspection"), event + "#inspection")
+ add_guard_case("fragment-on-event-url", wrapper.replace(event, event + "#measurement").replace("{{TARGET}}", event), event)
+ add_guard_case("scheme-case-event-alias", wrapper.replace("{{TARGET}}", event.replace("http:", "HTTP:")), event.replace("http:", "HTTP:"))
+ routes["/cycle-fragment"] = (200, wrap("/cycle-fragment#again"))
+ allowed.update({base + "/cycle-fragment", base + "/cycle-fragment#again"})
+ definitions.append(("fragment-alias-cycle", "/cycle-fragment", "wrapper_resolution", "failed", ["/cycle-fragment"], 5))
+
+ # Fixed VAST roles, intentionally independent of the inspector's role set.
+ # Each target is allowlisted to prove the semantic guard, not a missing
+ # allowlist entry, prevents the request.
+ tracking = base + "/events/shared"
+ routes["/events/shared"] = (200, inline.encode())
+ resource = f''
+ for role in ("CompanionClickTracking", "CompanionClickThrough",
+ "NonLinearClickTracking", "NonLinearClickThrough",
+ "IconViewTracking", "IconClickTracking", "IconClickThrough"):
+ tag = f'<{role}>{role}>'
+ xml = wrapper.replace("{{TARGET}}", tracking)
+ if role.startswith("Companion"):
+ extra = f'{resource}{tag}'
+ xml = xml.replace("", extra + "")
+ elif role.startswith("NonLinear"):
+ extra = f'{resource}{tag}'
+ xml = xml.replace("", extra + "")
+ else:
+ if role != "IconViewTracking":
+ tag = "" + tag + ""
+ extra = f'{resource}{tag}'
+ xml = xml.replace("", extra + "")
+ add_guard_case("event-role-" + role, xml, tracking)
+
+ results = []
+ try:
+ for name, path, check, outcome, expected_requests, limit in definitions:
+ requests.clear()
+ # Dual-role case deliberately allows the URL to prove the semantic
+ # event guard stops it before the transport allowlist is consulted.
+ permitted = allowed | dual_allowed.get(path, set())
+ receipt = inspect(base + path, FixtureTransport(permitted), max_documents=limit)
+ matches = [item for item in receipt["checks"] if item["check"] == check]
+ notifications = [item for item in requests if item["path"].startswith("/events/")]
+ passed = (bool(matches) and matches[-1]["outcome"] == outcome and not notifications
+ and [item["path"] for item in requests] == expected_requests
+ and not checks_satisfied(receipt, ["vast_schema_conformance"])
+ and not checks_satisfied(receipt, ["unknown_required_check"])
+ and not checks_satisfied(receipt, []) and receipt["readiness"] == "not_assessed")
+ if path == "/wrapper" and limit == 5:
+ passed = passed and checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"])
+ passed = passed and len(receipt["identified_event_urls"]) == 13
+ if path in expected_event_guard:
+ passed = passed and matches[-1]["reason"] == "target is also identified as an event URL"
+ passed = passed and not checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"])
+ results.append({"id": name, "passed": bool(passed), "expected_check": check,
+ "expected_outcome": outcome, "server_requests": requests.copy(), "receipt": receipt})
+ # A positive control proves the trap records notifications, including HEAD.
+ # These endpoints are synthetic and loopback-only, never campaign URLs.
+ requests.clear()
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ for method in ("GET", "HEAD"):
+ try:
+ with opener.open(urllib.request.Request(base + "/events/control", method=method)):
+ pass
+ except urllib.error.HTTPError:
+ pass
+ results.append({"id": "notification-trap-positive-control", "passed": requests == [
+ {"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}],
+ "server_requests": requests.copy()})
+ url_cases = [
+ ("url-fragment-removed", "http://example.test/events/x#preview", "http://example.test/events/x"),
+ ("url-http-authority-normalized", "HTTP://EXAMPLE.TEST:80/events/x", "http://example.test/events/x"),
+ ("url-https-default-port", "https://EXAMPLE.TEST:443/events/x", "https://example.test/events/x"),
+ ("url-empty-path", "http://example.test", "http://example.test/"),
+ ("url-ipv6-normalized", "http://[0:0:0:0:0:0:0:1]:80/x", "http://[::1]/x"),
+ ("url-query-preserved", "http://example.test/x?signature=%7e&b=2&a=1#preview", "http://example.test/x?signature=%7e&b=2&a=1"),
+ ("url-empty-query-preserved", "http://example.test/x?#preview", "http://example.test/x?"),
+ ]
+ for name, value, expected in url_cases:
+ actual = request_url(value)
+ results.append({"id": name, "passed": actual == expected, "input": value, "expected": expected, "actual": actual})
+ for name, value in (("url-credentials-blocked", "http://user:password@example.test/x"),
+ ("url-non-http-blocked", "file:///events/x"),
+ ("url-control-character-blocked", "http://example.test/\nx")):
+ try:
+ request_url(value)
+ blocked = False
+ except Unchecked:
+ blocked = True
+ results.append({"id": name, "passed": blocked, "expected": "not_checked"})
+ return {"profile": "creative-inspection-discussion-2", "passed": sum(item["passed"] for item in results),
+ "total": len(results), "cases": results}
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--json", action="store_true")
+ args = parser.parse_args()
+ report = run()
+ if args.json:
+ print(json.dumps(report, indent=2))
+ else:
+ for case in report["cases"]:
+ print(("PASS " if case["passed"] else "FAIL ") + case["id"])
+ print(f'{report["passed"]}/{report["total"]} creative inspection cases pass')
+ raise SystemExit(report["passed"] != report["total"])
diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs
new file mode 100644
index 0000000..e650814
--- /dev/null
+++ b/scripts/check_creative_preview.mjs
@@ -0,0 +1,269 @@
+#!/usr/bin/env node
+// Synthetic loopback preview tests. Never load live creative with this harness.
+import { spawn } from 'node:child_process';
+import { createServer, request as httpRequest } from 'node:http';
+import { connect } from 'node:net';
+import { mkdtemp, readFile, rm, access } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { once } from 'node:events';
+import { assessNotifications } from './creative_preview_observations.mjs';
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+const attempts = {
+ 'fetch-get': `fetch(url).catch(() => {})`,
+ 'fetch-head': `fetch(url, {method: 'HEAD'}).catch(() => {})`,
+ 'fetch-post': `fetch(url, {method: 'POST', body: 'synthetic'}).catch(() => {})`,
+ beacon: `navigator.sendBeacon(url, 'synthetic')`,
+ xhr: `const x = new XMLHttpRequest(); x.open('GET', url); x.send()`,
+ image: `const x = new Image(); x.src = url; document.body.append(x)`,
+ script: `const x = document.createElement('script'); x.src = url; document.body.append(x)`,
+ iframe: `const x = document.createElement('iframe'); x.src = url; document.body.append(x)`,
+ 'css-image': `const x = document.createElement('div'); x.style.cssText = 'width:40px;height:40px;background-image:url(' + url + ')'; document.body.append(x)`,
+ 'video-poster': `const x = document.createElement('video'); x.poster = url; document.body.append(x)`,
+ form: `const x = document.createElement('form'); x.method = 'POST'; x.action = url; document.body.append(x); x.submit()`,
+ navigation: `location.href = url`,
+ websocket: `const x = new WebSocket(url.replace('http:', 'ws:')); x.onerror = () => {}`,
+};
+const jsonMode = process.argv.includes('--json');
+const injectCanary = process.argv.includes('--inject-notification-canary');
+const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
+const escapeAttribute = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<');
+let base;
+const received = [];
+const gateBlocked = [];
+let allowedRequests = new Set();
+let allowControlTunnel = false;
+// A separate HTTP proxy enforces the fixture policy across renderer changes.
+// CSP and target-scoped interception alone missed a navigation in testing.
+const gate = createServer((request, response) => {
+ let url;
+ try { url = new URL(request.url); } catch { response.writeHead(400).end(); return; }
+ if (url.origin !== base || !allowedRequests.has(request.method + ' ' + url.href)) {
+ gateBlocked.push({method: request.method, url: url.href});
+ response.writeHead(403).end(); return;
+ }
+ const upstream = httpRequest(url, {method: request.method, headers: request.headers}, result => {
+ response.writeHead(result.statusCode, result.headers); result.pipe(response);
+ });
+ upstream.on('error', () => response.writeHead(502).end());
+ request.pipe(upstream);
+});
+gate.on('connect', (request, socket, head) => {
+ if (allowControlTunnel && request.url === new URL(base).host) {
+ const upstream = connect(server.address().port, '127.0.0.1', () => {
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head.length) upstream.write(head);
+ socket.pipe(upstream); upstream.pipe(socket);
+ });
+ upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy());
+ socket.on('close', () => upstream.destroy());
+ return;
+ }
+ gateBlocked.push({method: 'CONNECT', url: request.url});
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
+});
+gate.on('upgrade', (request, socket, head) => {
+ let url;
+ try { url = new URL(request.url); } catch { socket.destroy(); return; }
+ // WebSocket proxy requests use ws: while the fixture allowlist uses http:.
+ const normalized = url.href.replace(/^ws:/, 'http:');
+ if (!normalized.startsWith(base + '/') || !allowedRequests.has('UPGRADE ' + normalized)) {
+ gateBlocked.push({method: 'UPGRADE', url: url.href});
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return;
+ }
+ const upstream = connect(server.address().port, '127.0.0.1', () => {
+ upstream.write(request.method + ' ' + url.pathname + ' HTTP/1.1\r\n' +
+ Object.entries(request.headers).map(([key, value]) => key + ': ' + value).join('\r\n') + '\r\n\r\n');
+ if (head.length) upstream.write(head);
+ socket.pipe(upstream); upstream.pipe(socket);
+ });
+ upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy());
+ socket.on('close', () => upstream.destroy());
+});
+const server = createServer((request, response) => {
+ const url = new URL(request.url, base);
+ if (url.pathname === '/preview') {
+ const id = url.searchParams.get('case');
+ const isolated = url.searchParams.get('mode') === 'isolated';
+ const operation = attempts[id];
+ if (!operation) { response.writeHead(400).end(); return; }
+ const eventUrl = base + '/events/' + id;
+ const frame = `${isolated ? `` : ''}`;
+ const html = `Synthetic creative inspection
+
+ `;
+ response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
+ response.end(html);
+ } else if (url.pathname.startsWith('/events/')) {
+ received.push({ method: request.method, path: url.pathname, url: url.href });
+ response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
+ response.end('synthetic notification trap');
+ } else {
+ response.writeHead(404).end();
+ }
+});
+server.on('upgrade', (request, socket) => {
+ const url = new URL(request.url, base);
+ received.push({ method: 'UPGRADE', path: url.pathname, url: url.href });
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
+});
+
+class CDP {
+ constructor(socket) {
+ this.socket = socket; this.id = 0; this.pending = new Map(); this.handlers = [];
+ socket.addEventListener('message', ({data}) => {
+ const message = JSON.parse(data);
+ if (message.id) {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id); clearTimeout(pending.timer);
+ if (message.error) pending.reject(new Error(JSON.stringify(message.error)));
+ else pending.resolve(message.result);
+ } else for (const handler of this.handlers) handler(message);
+ });
+ }
+ call(method, params = {}, sessionId) {
+ const id = ++this.id;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 10000);
+ this.pending.set(id, {resolve, reject, timer});
+ this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? {sessionId} : {}) }));
+ });
+ }
+ close() { this.socket.close(); }
+}
+
+async function findChrome() {
+ const paths = process.env.CHROME_BIN ? [process.env.CHROME_BIN] : [
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
+ ];
+ for (const path of paths) { try { await access(path); return path; } catch {} }
+ throw new Error('Chrome not found. Set CHROME_BIN to a Chrome/Chromium executable.');
+}
+
+let chrome, profile, browser, cdp;
+const cases = [];
+try {
+ const executable = await findChrome();
+ profile = await mkdtemp(join(process.env.CREATIVE_CACHE_DIR || tmpdir(), 'openproposal-preview-'));
+ server.listen(0, '127.0.0.1'); await once(server, 'listening');
+ base = 'http://127.0.0.1:' + server.address().port;
+ gate.listen(0, '127.0.0.1'); await once(gate, 'listening');
+ chrome = spawn(executable, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile,
+ '--no-first-run', '--no-default-browser-check', '--disable-background-networking',
+ '--disable-component-update', '--disable-sync', '--disable-quic',
+ '--proxy-server=http://127.0.0.1:' + gate.address().port, '--proxy-bypass-list=<-loopback>',
+ 'about:blank'], {stdio: 'ignore'});
+ let port;
+ for (let i = 0; i < 100; i++) {
+ try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; } catch {}
+ if (chrome.exitCode !== null) throw new Error('Chrome exited before DevTools started');
+ await delay(100);
+ }
+ if (!port) throw new Error('Chrome did not start DevTools');
+ const endpoint = 'http://127.0.0.1:' + port;
+ browser = await (await fetch(endpoint + '/json/version')).json();
+ for (const id of Object.keys(attempts)) {
+ for (const mode of ['control', 'isolated', ...(id === 'navigation' ? ['network-gate'] : [])]) {
+ const target = await (await fetch(endpoint + '/json/new?about:blank', {method: 'PUT'})).json();
+ const socket = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, {once:true}); socket.addEventListener('error', reject, {once:true}); });
+ cdp = new CDP(socket);
+ const blocked = [], protocolErrors = [], pendingEvents = new Set();
+ const previewUrl = base + '/preview?case=' + id + '&mode=' + mode;
+ const isolated = mode !== 'control';
+ const expectedMethod = {'fetch-head':'HEAD', 'fetch-post':'POST', beacon:'POST', form:'POST', websocket:'UPGRADE'}[id] || 'GET';
+ allowedRequests = new Set(['GET ' + previewUrl]);
+ if (!isolated) allowedRequests.add(expectedMethod + ' ' + base + '/events/' + id);
+ allowControlTunnel = id === 'websocket' && mode === 'control';
+ gateBlocked.length = 0;
+ async function guard(sessionId) {
+ await cdp.call('Fetch.enable', {patterns: [{urlPattern: '*', requestStage: 'Request'}]}, sessionId);
+ await cdp.call('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: true, flatten: true}, sessionId);
+ }
+ cdp.handlers.push(message => {
+ const work = (async () => {
+ if (message.method === 'Fetch.requestPaused') {
+ const {requestId, request} = message.params;
+ if (request.url === previewUrl && request.method === 'GET') {
+ await cdp.call('Fetch.continueRequest', {requestId}, message.sessionId);
+ } else {
+ blocked.push({url: request.url, method: request.method});
+ await cdp.call('Fetch.failRequest', {requestId, errorReason: 'BlockedByClient'}, message.sessionId);
+ }
+ } else if (message.method === 'Target.attachedToTarget' && isolated) {
+ await guard(message.params.sessionId);
+ await cdp.call('Runtime.runIfWaitingForDebugger', {}, message.params.sessionId);
+ }
+ })().catch(error => protocolErrors.push(error.message));
+ pendingEvents.add(work); work.finally(() => pendingEvents.delete(work));
+ });
+ await cdp.call('Page.enable'); await cdp.call('Runtime.enable');
+ if (mode === 'isolated') await guard();
+ const observationStart = received.length;
+ await cdp.call('Page.navigate', {url: previewUrl});
+ let messages = [];
+ for (let i = 0; i < 100; i++) {
+ const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true});
+ messages = state.result.value || [];
+ if (messages.some(message => message.attempted === id)) break;
+ await delay(25);
+ }
+ // Finite observation window is reported with the receipt, not a promise
+ // that arbitrary creative could not schedule traffic at a later time.
+ await delay(500);
+ await Promise.all([...pendingEvents]);
+ const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true});
+ messages = state.result.value || [];
+ // Negative control for the assertion itself, sent by this test driver.
+ // A run with this flag must fail and retain the unexpected event.
+ if (injectCanary && mode === 'isolated' && id === 'fetch-get') {
+ await fetch(base + '/events/unexpected-audit-canary');
+ }
+ const observation = assessNotifications(received.slice(observationStart), {
+ isolated, expectedPath: '/events/' + id, expectedMethod,
+ });
+ const notifications = observation.notifications;
+ const attempted = messages.some(message => message.attempted === id);
+ const unexpectedScriptError = messages.some(message => message.error && message.error !== 'SecurityError');
+ const passed = attempted && !unexpectedScriptError && protocolErrors.length === 0 && observation.passed;
+ cases.push({id: id + '/' + mode, passed, attempted, expected_method: expectedMethod,
+ server_notifications: notifications, blocked_requests: blocked,
+ network_gate_blocks: gateBlocked.slice(), messages,
+ protocol_errors: protocolErrors, observation_ms_after_attempt: 500});
+ await fetch(endpoint + '/json/close/' + target.id);
+ cdp.close(); cdp = null;
+ }
+ }
+ const report = {profile: 'synthetic-browser-inspection-2', browser: browser.Browser,
+ assertion_canary_injected: injectCanary,
+ tested_at: new Date().toISOString(), passed: cases.filter(item => item.passed).length,
+ total: cases.length, cases};
+ if (jsonMode) console.log(JSON.stringify(report, null, 2));
+ else {
+ for (const item of cases) console.log((item.passed ? 'PASS ' : 'FAIL ') + item.id);
+ console.log(`${report.passed}/${report.total} synthetic browser cases pass (${browser.Browser})`);
+ }
+ if (report.passed !== report.total) process.exitCode = 1;
+} finally {
+ cdp?.close();
+ if (chrome && chrome.exitCode === null && chrome.signalCode === null) {
+ chrome.kill('SIGTERM');
+ await Promise.race([once(chrome, 'exit'), delay(3000)]);
+ if (chrome.exitCode === null && chrome.signalCode === null) {
+ chrome.kill('SIGKILL'); await once(chrome, 'exit');
+ }
+ }
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ gate.closeAllConnections();
+ await new Promise(resolve => gate.close(resolve));
+ if (profile) await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 100});
+}
diff --git a/scripts/check_preview_observations.mjs b/scripts/check_preview_observations.mjs
new file mode 100644
index 0000000..ea39205
--- /dev/null
+++ b/scripts/check_preview_observations.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { assessNotifications } from './creative_preview_observations.mjs';
+
+const expectedPath = '/events/fetch-get';
+const expectedMethod = 'GET';
+const cases = [
+ {id:'empty-isolated-log', isolated:true, observations:[], expected:true},
+ {id:'expected-event-is-still-forbidden', isolated:true, observations:[{method:'GET',path:expectedPath}], expected:false},
+ {id:'unexpected-event-path-is-forbidden', isolated:true, observations:[{method:'GET',path:'/events/unexpected-audit-canary'}], expected:false},
+ {id:'unexpected-event-method-is-forbidden', isolated:true, observations:[{method:'HEAD',path:'/events/unexpected'}], expected:false},
+ {id:'mixed-events-are-all-preserved', isolated:true, observations:[{method:'GET',path:expectedPath},{method:'POST',path:'/events/other'}], expected:false},
+ {id:'control-must-reach-trap', isolated:false, observations:[], expected:false},
+ {id:'expected-control-event', isolated:false, observations:[{method:'GET',path:expectedPath}], expected:true},
+ {id:'wrong-control-path', isolated:false, observations:[{method:'GET',path:'/events/other'}], expected:false},
+ {id:'wrong-control-method', isolated:false, observations:[{method:'HEAD',path:expectedPath}], expected:false},
+ {id:'control-with-extra-notification', isolated:false, observations:[{method:'GET',path:expectedPath},{method:'GET',path:'/events/other'}], expected:false},
+];
+const results = cases.map(vector => {
+ const result = assessNotifications(vector.observations, {isolated:vector.isolated, expectedPath, expectedMethod});
+ // The receipt must retain the complete observations, even when they fail.
+ assert.deepEqual(result.notifications, vector.observations);
+ assert.notEqual(result.notifications, vector.observations);
+ return {id:vector.id, passed:result.passed === vector.expected, expected_check_result:vector.expected, actual:result};
+});
+const report = {profile:'preview-notification-assertions-1', passed:results.filter(x=>x.passed).length, total:results.length, cases:results};
+if(process.argv.includes('--json')) console.log(JSON.stringify(report,null,2));
+else {for(const item of results) console.log((item.passed?'PASS ':'FAIL ')+item.id);console.log(`${report.passed}/${report.total} notification assertions pass`);}
+if(report.passed !== report.total) process.exitCode=1;
diff --git a/scripts/creative_preview_observations.mjs b/scripts/creative_preview_observations.mjs
new file mode 100644
index 0000000..76c2880
--- /dev/null
+++ b/scripts/creative_preview_observations.mjs
@@ -0,0 +1,9 @@
+// Shared assertion for the synthetic notification trap, not an egress filter.
+export function assessNotifications(observations, {isolated, expectedPath, expectedMethod}) {
+ const notifications = observations.map(observation => ({...observation}));
+ const expected = observation => observation.path === expectedPath && observation.method === expectedMethod;
+ return {
+ passed: isolated ? notifications.length === 0 : notifications.length > 0 && notifications.every(expected),
+ notifications,
+ };
+}
diff --git a/scripts/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py
new file mode 100644
index 0000000..0c0780b
--- /dev/null
+++ b/scripts/inspect_creative_fixture.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+"""Bounded, fixture-only document inspection supporting OpenProposal #42.
+
+Not a production fetcher, player, VAST validator or creative-readiness service.
+"""
+
+import hashlib
+import ipaddress
+import urllib.error
+import urllib.request
+from urllib.parse import urlsplit, urlunsplit
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
+
+EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough",
+ "CustomClick", "Viewable", "NotViewable", "ViewUndetermined",
+ "CompanionClickTracking", "CompanionClickThrough",
+ "NonLinearClickTracking", "NonLinearClickThrough",
+ "IconViewTracking", "IconClickTracking", "IconClickThrough"}
+
+
+class Unchecked(Exception):
+ """The check could not run within the fixture's retrieval policy."""
+
+
+def request_url(url):
+ """Canonicalize the HTTP request identity used by this fixture transport.
+
+ Fragments never reach the server. Preserve path/query spelling because
+ application routing and signatures can depend on it. This cannot identify
+ arbitrary server-side aliases, redirects or the meaning of a resource.
+ """
+ if not isinstance(url, str) or not url.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in url):
+ raise Unchecked("URL is outside the ASCII fixture profile")
+ try:
+ parts = urlsplit(url)
+ if parts.scheme not in ("http", "https") or not parts.hostname or parts.username is not None or parts.password is not None:
+ raise ValueError("unsupported authority")
+ host = parts.hostname.lower()
+ if ":" in host:
+ host = "[" + ipaddress.IPv6Address(host).compressed + "]"
+ port = parts.port
+ if port is not None and not 1 <= port <= 65535:
+ raise ValueError("invalid port")
+ authority = host
+ if port is not None and port != (80 if parts.scheme == "http" else 443):
+ authority += ":" + str(port)
+ normalized = urlunsplit((parts.scheme, authority, parts.path or "/", parts.query, ""))
+ if not parts.query and "?" in url.split("#", 1)[0]:
+ normalized += "?"
+ return normalized
+ except ValueError as error:
+ raise Unchecked("URL is outside the HTTP fixture profile") from error
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ return None
+
+
+class FixtureTransport:
+ def __init__(self, documents, max_bytes=65536):
+ # Explicit document URLs come from the local fixture server, not the XML.
+ # Callers must establish each URL is a document resource, not an event.
+ self.documents = frozenset(request_url(url) for url in documents)
+ self.max_bytes = max_bytes
+ self.requests = []
+ self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
+
+ def get(self, url):
+ url = request_url(url)
+ if url not in self.documents:
+ raise Unchecked("document URL is outside the fixture allowlist")
+ self.requests.append({"method": "GET", "url": url})
+ try:
+ with self.opener.open(url, timeout=2) as response:
+ data = response.read(self.max_bytes + 1)
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
+ raise Unchecked("document retrieval failed or redirected: " + type(error).__name__) from error
+ if len(data) > self.max_bytes:
+ raise Unchecked("document exceeds the inspection byte limit")
+ return data
+
+
+def inspect(root_url, transport, *, asset_id="fixture-asset", max_documents=5):
+ checks, documents, event_urls, visited = [], [], set(), set()
+ event_requests = set()
+
+ def record(name, outcome, reason, url=None):
+ check = {"check": name, "outcome": outcome, "reason": reason}
+ if url:
+ check["document_url"] = url
+ checks.append(check)
+
+ url = root_url
+ for _ in range(max_documents):
+ try:
+ identity = request_url(url)
+ except Unchecked as error:
+ record("document_retrieval", "not_checked", str(error), url)
+ record("wrapper_resolution", "not_checked", "target URL is unsupported", url)
+ break
+ if identity in event_requests:
+ record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url)
+ break
+ if identity in visited:
+ record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url)
+ break
+ visited.add(identity)
+ try:
+ data = transport.get(url)
+ except Unchecked as error:
+ record("document_retrieval", "not_checked", str(error), url)
+ record("wrapper_resolution", "not_checked", "a required document was not retrieved", url)
+ break
+ record("document_retrieval", "passed", "retrieved the requested document bytes", url)
+ documents.append({"url": url, "request_url": identity,
+ "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
+ try:
+ xml = data.decode("utf-8")
+ except UnicodeDecodeError:
+ record("xml_well_formedness", "not_checked", "fixture reader supports UTF-8 only", url)
+ record("wrapper_resolution", "not_checked", "document was not parsed", url)
+ break
+ if "\x00" in xml or "` or `verificationNotExecuted` URL.
+
+A skipped or blocked check MUST NOT be reported as passed. This section does
+not waive any required checks.
+
+These requirements apply to inspection only. During live delivery,
+implementations remain subject to the tracking and error-reporting requirements
+of the applicable execution standard.
+
### 5.5 Targeting envelope
Each field declares bounds, not a value. A bare value means the field is
@@ -371,6 +407,10 @@ buyer commitment, and `settable` bounds. See
See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml).
+Inspection of this line item's VAST creative, including wrapper retrieval and
+previews, MUST follow the inspection requirements in section 5.4. Inspection
+MUST NOT send production tracking or error notifications.
+
### 6.4 Audio line item
See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml).