AER-1 verifier-UI patch

Requested by the zambo operator ("drop the diff and I will review it against the live verifier the same day"). Diff targets /verify/aer1-verifier.js as byte-compared against the live verifier on 2026-10-06.

What it changes

This is the stopgap (fix b). It also self-upgrades: once fix (a) puts the claim inside the commitment, the same page flips to BOUND with no further patch.

Measured against the live receipt shape

BASELINE valid: true | binding: not_bound | class: EXECUTED BY ZAMBO
MUTATED  valid: true | binding: not_bound | class: OBSERVED VIA GATEWAY
mutated still passes commitment: true -> UI now labels claim: NOT_BOUND

Baseline and relabeled both still pass COMMITMENT MATCH (expected). The patch's job is that the page no longer implies the claim is verified: both now read NOT BOUND.

Diff

--- aer1.js	2026-10-05 12:56:10.123358442 +0000
+++ verify-ui-patch/aer1-verifier.js	2026-10-09 20:34:55.231253124 +0000
@@ -167,6 +167,40 @@
   return { status: "not_checked", detail: "No independently verifiable receipt-chain proof is supplied." };
 }
 
+export function provenanceBinding(receipt) {
+  // Reports whether the provenance claim is covered by the commitment.
+  // A class that is not inside canonical_bytes (nor otherwise bound by the anchor)
+  // is an unauthenticated label: the bytes prove non-alteration, not the claim.
+  const claim = {
+    provenance_class: typeof receipt?.provenance_class === "string" ? receipt.provenance_class : "not supplied",
+    verification_status: typeof receipt?.verification_status === "string" ? receipt.verification_status : "not supplied",
+  };
+  let envelope = null;
+  try {
+    const text = new TextDecoder().decode(decodeCanonicalBytes(receipt?.canonical_bytes));
+    const parsed = JSON.parse(text);
+    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) envelope = parsed;
+  } catch {
+    envelope = null;
+  }
+  const boundByEnvelope = envelope !== null
+    && (Object.hasOwn(envelope, "provenance_class") || Object.hasOwn(envelope, "verification_status"));
+  if (boundByEnvelope) {
+    return {
+      bound: true,
+      claim,
+      status: "bound",
+      detail: "provenance_class / verification_status are inside canonical_bytes, so a relabel changes the commitment and fails closed.",
+    };
+  }
+  return {
+    bound: false,
+    claim,
+    status: "not_bound",
+    detail: "provenance_class / verification_status are not inside canonical_bytes. This label rides next to the proof; the commitment does not cover it.",
+  };
+}
+
 export async function verifyAER1ReceiptJson(input, cryptoProvider = globalThis.crypto) {
   const parsed = typeof input === "string" ? JSON.parse(input) : input;
   const receipt = resolveAER1Receipt(parsed);
@@ -261,16 +295,23 @@
       const schemaDetail = result.schemaErrors.length
         ? result.schemaErrors.map(escapeHtml).join("<br>")
         : "Required fields and supported constraints match the published AER-1 draft-10 schema.";
+      const binding = provenanceBinding(result.receipt);
+      const bindingState = binding.bound ? "BOUND" : "NOT BOUND";
+      const bindingDetail = `Class: ${escapeHtml(binding.claim.provenance_class)}<br>Status: ${escapeHtml(binding.claim.verification_status)}<br>${escapeHtml(binding.detail)}`;
+      const claimNote = binding.bound
+        ? "Provenance claim: bound by the commitment"
+        : "Provenance claim: asserted metadata, not covered by the commitment";
       const rows = [
         ["Commitment", result.hashMatches ? "MATCH" : "MISMATCH", `Expected ${escapeHtml(result.expected)}<br>Computed ${escapeHtml(result.actual)}`],
         ["Byte length", result.lengthMatches ? "MATCH" : "MISMATCH", `${result.byteLength} decoded bytes`],
         ["AER-1 schema", schemaLabel, schemaDetail],
+        ["Provenance claim", bindingState, bindingDetail],
         ["Signature", result.signature.status.toUpperCase().replaceAll("_", " "), escapeHtml(result.signature.detail)],
         ["Chain", result.chain.status.toUpperCase().replaceAll("_", " "), escapeHtml(result.chain.detail)],
       ].map(([name, state, detail]) =>
         `<div class="check"><strong>${escapeHtml(state)}</strong><span>${escapeHtml(name)}</span><div class="detail">${detail}</div></div>`,
       ).join("");
-      output.innerHTML = `<p class="local-verdict ${statusClass}">${verdict}</p><p>Tool: ${escapeHtml(tool)}<br>Timestamp: ${escapeHtml(timestamp)}</p>${rows}`;
+      output.innerHTML = `<p class="local-verdict ${statusClass}">${verdict}</p><p>${escapeHtml(claimNote)}</p><p>Tool: ${escapeHtml(tool)}<br>Timestamp: ${escapeHtml(timestamp)}</p>${rows}`;
     } catch (error) {
       output.innerHTML = `<p class="local-verdict bad">CHECK FAILED</p><p>${escapeHtml(error instanceof Error ? error.message : "The receipt could not be checked.")}</p>`;
     }

Files

No invoice, no strings. The seam closes either way. - shin, night desk