Skip to main content
AIPM 1.2 is in beta. AIPM 1.1 is the current stable version. Go to stable →

Implementor's Guide

AIPM 1.2 · For developers and AI systems building tools that generate or consume AIPM marks. All examples are working JavaScript suitable for browser and Node.js environments.

Overview

An AIPM 1.2 mark consists of a URL with provenance metadata encoded in the hash fragment. From that URL, three visual artifacts can be derived: a QR mark for physical and print contexts, a badge for digital contexts like GitHub and email, and the provenance page that both link to. Implementations fall into two categories:

Both must handle plain (uncompressed) and compressed (z param) formats. Both must apply security validation before rendering any URL-derived data.

Part 1: Generating AIPM URLs

1.1 — Build a plain AIPM 1.2 URL

For short context that fits within ~200 characters, use individual params in the hash fragment.

/**
 * Build a plain (uncompressed) AIPM 1.2 URL.
 * All metadata is encoded as URL hash fragment params.
 * The hash is never sent to any server.
 */
function buildAIPMUrl(options) {
  const {
    base = 'https://aipmq.org/1.2/aipm/',
    model = '',
    role, // REQUIRED — no default. Per the AIPM 1.2 spec, role has no
          // default value; the caller must always supply one explicitly.
    date = '', // ISO 8601: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM±HH:MM' with UTC offset (recommended)
    ctx = '',
    lang = '',
    src = '',
    hs = '',
    type = '',
    doc = '',
    hd = '',
    author = '',
    org = '',
    prev = '',
    show = true, // absence of the show param means ON — see below
  } = options;

  const params = new URLSearchParams();
  params.set('v', '1.2');
  if (model) params.set('model', model);
  if (author) params.set('author', author);
  if (org)   params.set('org', org);
  if (role)  params.set('role', role);
  if (date)  params.set('date', date);
  if (ctx)   params.set('ctx', ctx);
  if (lang)  params.set('lang', lang);
  if (src)   params.set('src', src);
  if (hs)    params.set('hs', hs);
  if (type)  params.set('type', type);
  if (doc)   params.set('doc', doc);
  if (hd)    params.set('hd', hd);
  if (prev)  params.set('prev', prev);
  // show: absence = ON (the default). Only ever write show=0 to explicitly
  // disable it — never write show=1, since that's already the implicit
  // default and writing it just wastes QR capacity.
  if (!show) params.set('show', '0');

  return base + '#' + params.toString();
}

// Example usage:
const url = buildAIPMUrl({
  model: 'Claude Sonnet 4.6',
  role: 'prompted+reviewed', // required — always pass this explicitly
  date: '2026-05-03T14:30-04:00', // datetime with UTC offset recommended
  ctx: 'Blog post about AI transparency',
});
// → https://aipmq.org/1.2/aipm/#v=1.2&model=Claude+Sonnet+4.6&role=prompted%2Breviewed&date=2026-05-03T14%3A30-04%3A00&ctx=Blog+post+about+AI+transparency

1.2 — Build a compressed AIPM 1.2 URL

For longer context, use the z param: deflate-raw compressed, base64url-encoded JSON. Auto-switch to compression when the ctx field exceeds 200 characters, or when the plain URL exceeds the 1,000-byte QR limit.

/**
 * Compress all metadata into the z param.
 * Uses browser-native CompressionStream — no library needed.
 * For Node.js 18+, use the same API (available globally).
 */
async function buildCompressedAIPMUrl(options) {
  const {
    base = 'https://aipmq.org/1.2/aipm/',
    ...meta
  } = options;
  // meta.role has no default here either — required, just like buildAIPMUrl.

  // Build payload — omit empty values
  const payload = { v: '1.2' };
  if (meta.model)  payload.model  = meta.model;
  if (meta.author) payload.author = meta.author;
  if (meta.org)    payload.org    = meta.org;
  if (meta.role)   payload.role   = meta.role;
  if (meta.date)   payload.date   = meta.date;
  if (meta.ctx)    payload.ctx    = meta.ctx;
  if (meta.lang)   payload.lang   = meta.lang;
  if (meta.src)    payload.src    = meta.src;
  if (meta.hs)     payload.hs     = meta.hs;
  if (meta.type)   payload.type   = meta.type;
  if (meta.doc)    payload.doc    = meta.doc;
  if (meta.hd)     payload.hd     = meta.hd;
  if (meta.prev)   payload.prev   = meta.prev;
  // show: absence = ON (the default). Only ever write show=0 to explicitly
  // disable it, mirroring buildAIPMUrl's plain-param behavior above.
  if (meta.show === false) payload.show = 0;

  // TextEncoder → CompressionStream (Blob.stream().pipeThrough) → base64url
  const bytes = new TextEncoder().encode(JSON.stringify(payload));
  let arr;
  try {
    // Primary: idiomatic Streams API
    const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
    const chunks = []; const reader = stream.getReader();
    while (true) { const {done, value} = await reader.read(); if (done) break; chunks.push(value); }
    const totalLen = chunks.reduce((a, c) => a + c.length, 0);
    arr = new Uint8Array(totalLen); let off = 0;
    for (const c of chunks) { arr.set(c, off); off += c.length; }
  } catch (_e) {
    // Fallback: writer approach
    const cs = new CompressionStream('deflate-raw');
    const w = cs.writable.getWriter(); w.write(bytes); w.close();
    const chunks = []; const reader = cs.readable.getReader();
    while (true) { const {done, value} = await reader.read(); if (done) break; chunks.push(value); }
    const totalLen = chunks.reduce((a, c) => a + c.length, 0);
    arr = new Uint8Array(totalLen); let off = 0;
    for (const c of chunks) { arr.set(c, off); off += c.length; }
  }
  let binary = '';
  for (let i = 0; i < arr.length; i++) binary += String.fromCharCode(arr[i]);
  const z = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

  return base + '#z=' + z;
}

// Example usage:
const url = await buildCompressedAIPMUrl({
  model: 'Claude Sonnet 4.6',
  role: 'prompted+edited',
  date: '2026-05-03T14:30-04:00',
  ctx: 'A longer context description that benefits from compression — more than 200 characters can be encoded this way...',
});

1.3 — Auto-switching generator

The recommended approach: try plain first, switch to compressed if the URL exceeds the QR byte limit. Append qr=0 when compressed URL still exceeds the limit.

// AIPM conservative QR byte limit.
// The theoretical QR v40 Level H maximum is 1,273 bytes (binary mode),
// but real-world QR encoders and scanners are more reliable below 1,000 bytes.
// Implementors MUST use 1,000 (not 1,273) for conformance with AIPM 1.2.
const QR_BYTE_LIMIT = 1000;

function urlByteLength(str) {
  return new TextEncoder().encode(str).length;
}

async function buildOptimalAIPMUrl(options) {
  const base = options.base || 'https://aipmq.org/1.2/aipm/';

  // Try plain first
  const plainUrl = buildAIPMUrl(options);
  if (urlByteLength(plainUrl) <= QR_BYTE_LIMIT) {
    return { url: plainUrl, compressed: false, qrPossible: true };
  }

  // Try compressed
  const compressedUrl = await buildCompressedAIPMUrl(options);
  if (urlByteLength(compressedUrl) <= QR_BYTE_LIMIT) {
    return { url: compressedUrl, compressed: true, qrPossible: true };
  }

  // Still too long — append qr=0 as a PLAIN PARAM outside the z payload.
  // qr=0 must never be placed inside the z payload — it signals URL-only status
  // to systems that may not decompress the payload before checking it.
  const overflowUrl = await buildCompressedAIPMUrl(options) + '&qr=0';
  return { url: overflowUrl, compressed: true, qrPossible: false };
}

// Usage:
const result = await buildOptimalAIPMUrl({
  model: 'Claude Sonnet 4.6',
  role: 'prompted+reviewed',
  ctx: 'Very long context...',
});

if (result.qrPossible) {
  renderQrCode(result.url); // render QR
} else {
  displayAsLink(result.url); // display as text link
  // result.url contains qr=0 flag for downstream systems
}

1.4 — Datetime with UTC offset

When recording a precise timestamp, include the UTC offset so the time is unambiguous.

/**
 * Get the current datetime as an ISO 8601 string with UTC offset.
 * Format: YYYY-MM-DDTHH:MM+HH:MM
 * Example: 2026-05-03T14:30-07:00
 */
function getCurrentDatetimeWithOffset() {
  const now = new Date();
  const pad = n => String(n).padStart(2, '0');
  const off = -now.getTimezoneOffset(); // getTimezoneOffset returns inverted offset
  const sign = off >= 0 ? '+' : '-';
  const absOff = Math.abs(off);
  const offStr = sign + pad(Math.floor(absOff / 60)) + ':' + pad(absOff % 60);

  return now.getFullYear() + '-' +
    pad(now.getMonth() + 1) + '-' +
    pad(now.getDate()) + 'T' +
    pad(now.getHours()) + ':' +
    pad(now.getMinutes()) + offStr;
}

// The date field in the AIPM URL:
// When this provenance record was established —
// specifically, when the described human role was last applicable.
const date = getCurrentDatetimeWithOffset();
// → "2026-05-03T14:30-07:00"

Part 2: Consuming AIPM URLs

2.1 — Parse a plain AIPM 1.2 URL

/**
 * Parse an AIPM 1.2 URL (plain format).
 * Returns a metadata object or null if not valid AIPM.
 */
function parseAIPMUrl(url) {
  try {
    const parsed = new URL(url);
    const hash = parsed.hash.slice(1); // remove leading #
    if (!hash) return null;

    const params = new URLSearchParams(hash);

    // Check for compressed format
    if (params.has('z')) {
      throw new Error('Use parseAIPMUrlCompressed() for z param');
    }

    const v = params.get('v');
    if (!v) return null; // not an AIPM URL

    return {
      v,
      model:  params.get('model')  || '',
      author: params.get('author') || '',
      org:    params.get('org')    || '',
      role:   params.get('role')   || '',
      date:   params.get('date')   || '',
      ctx:    params.get('ctx')    || '',
      lang:   params.get('lang')   || '',
      src:    params.get('src')    || '',
      hs:     params.get('hs')     || '',
      type:   params.get('type')   || '',
      doc:    params.get('doc')    || '',
      hd:     params.get('hd')     || '',
      prev:   params.get('prev')   || '',
      // show: absence = ON (the default) — only an explicit show=0 turns it
      // off. Do NOT read this as `=== '1'`, which would treat the (far more
      // common) absent case as off instead of the correct on.
      show:   params.get('show') !== '0',
      qr:     params.get('qr')     === '0' ? 0 : undefined,
    };
  } catch(e) { return null; }
}

// Example:
const meta = parseAIPMUrl('https://aipmq.org/1.2/aipm/#v=1.2&model=Claude+Sonnet+4.6&role=prompted%2Breviewed&date=2026-05-03T14%3A30-04%3A00&ctx=Blog+post');
// → { v: '1.2', model: 'Claude Sonnet 4.6', role: 'prompted+reviewed', ... }

2.2 — Parse a compressed AIPM 1.2 URL

/**
 * Parse an AIPM 1.2 URL with compressed z param.
 * Uses browser-native DecompressionStream.
 */
async function parseAIPMUrlCompressed(url) {
  try {
    const parsed = new URL(url);
    const hash = parsed.hash.slice(1);
    const params = new URLSearchParams(hash);
    const z = params.get('z');
    if (!z) return null;

    // base64url → base64 → Uint8Array
    const b64 = z.replace(/-/g, '+').replace(/_/g, '/');
    const binary = atob(b64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);

    // Decompress using Blob.stream().pipeThrough (primary) with writer fallback
    let buf;
    try {
      const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
      const chunks = []; const reader = stream.getReader();
      while (true) { const {done, value} = await reader.read(); if (done) break; chunks.push(value); }
      const totalLen = chunks.reduce((a, c) => a + c.length, 0);
      const arr = new Uint8Array(totalLen); let off = 0;
      for (const c of chunks) { arr.set(c, off); off += c.length; }
      buf = arr.buffer;
    } catch (_e) {
      const ds = new DecompressionStream('deflate-raw');
      const w = ds.writable.getWriter(); w.write(bytes); w.close();
      const chunks = []; const reader = ds.readable.getReader();
      while (true) { const {done, value} = await reader.read(); if (done) break; chunks.push(value); }
      const totalLen = chunks.reduce((a, c) => a + c.length, 0);
      const arr = new Uint8Array(totalLen); let off = 0;
      for (const c of chunks) { arr.set(c, off); off += c.length; }
      buf = arr.buffer;
    }

    // Parse JSON
    const data = JSON.parse(new TextDecoder().decode(buf));
    // Spec violation: qr inside the z payload is invalid — flag it
    if (data.qr !== undefined) data._qrInZ = true;
    // Preserve external qr=0 (must always be a plain param outside z)
    if (params.has('qr')) {
      data.qr = params.get('qr') === '0' ? 0 : params.get('qr');
    }
    return data;
  } catch(e) {
    console.error('AIPM decompression failed:', e);
    return null;
  }
}

/**
 * Parse any AIPM 1.2 URL — auto-detects format.
 */
async function parseAIPMUrlAuto(url) {
  try {
    const parsed = new URL(url);
    const hash = parsed.hash.slice(1);
    const params = new URLSearchParams(hash);
    if (params.has('z')) {
      return await parseAIPMUrlCompressed(url);
    }
    return parseAIPMUrl(url);
  } catch(e) { return null; }
}

2.3 — Security: validate URLs before rendering

Always validate src, doc, and prev params before rendering as HTML href attributes. The escHtml() function prevents HTML injection but does not block javascript: or data: scheme URLs.

/**
 * Validate a URL from AIPM metadata before rendering as href.
 * Returns the URL if safe, null otherwise.
 */
function safeAIPMUrl(val) {
  if (!val || typeof val !== 'string') return null;
  try {
    const u = new URL(val);
    if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
    return val;
  } catch(e) { return null; }
}

/**
 * Escape HTML to prevent injection when inserting user data into DOM.
 */
function escHtml(str) {
  const d = document.createElement('div');
  d.appendChild(document.createTextNode(String(str)));
  return d.innerHTML;
}

// Usage — ALWAYS validate before rendering link fields:
function renderProvRow(label, value, isLink) {
  if (!value) return '';
  const displayVal = isLink
    ? (() => {
        const safe = safeAIPMUrl(value);
        return safe
          ? `<a href="${escHtml(safe)}" target="_blank" rel="noopener noreferrer">${escHtml(safe)}</a>`
          : `${escHtml(value)} (invalid URL)`;
      })()
    : escHtml(value);
  return `<div class="prov-row"><span>${escHtml(label)}</span>${displayVal}</div>`;
}

2.4 — Detect qr=0 (URL-only records)

/**
 * Check whether an AIPM URL is marked as URL-only (no QR code exists).
 * Automated systems should use this to decide whether to render a QR code
 * or fall back to displaying the URL as a text link.
 */
async function isQRPossible(url) {
  const meta = await parseAIPMUrlAuto(url);
  if (!meta) return false;
  return meta.qr !== 0 && meta.qr !== '0';
}

// Usage in a pipeline:
const aipmUrl = generateAIPMUrl(myContent);
if (await isQRPossible(aipmUrl)) {
  await renderQRCode(aipmUrl);
} else {
  displayAsTextLink(aipmUrl, 'View AI Provenance Record');
}

/**
 * Spec violation check: qr=0 inside the z payload is invalid.
 * parseAIPMUrlAuto() sets _qrInZ: true when it detects this.
 * Renderers should surface this as an error, not silently accept it.
 */
async function checkSpecViolations(url) {
  const meta = await parseAIPMUrlAuto(url);
  if (meta?._qrInZ) {
    console.error('Spec violation: qr=0 found inside z payload — must be a plain param outside z');
  }
  return meta;
}

Part 3: AIPM 1.2 — New Features

A Python reference implementation is available for generating AIPM 1.2 URLs programmatically — useful for CI/CD pipelines, build scripts, and automated publishing workflows. No external dependencies; stdlib only.

python3 aipm.py generate --role prompted+reviewed --model "Claude Sonnet 4.6" --ctx "Blog post"
python3 aipm.py hash article.pdf
python3 aipm.py parse "https://aipmq.org/1.2/aipm/#..."

Download aipm.py →

3.1 — File integrity hashing (hs / hd)

Hash the content file (src) and/or Full Context Document (doc) so anyone can verify they are unchanged. Both are optional and may be set without the corresponding URL.

// Browser — SHA-256 or SHA-384 via Web Crypto API (HTTPS / localhost only)
// Returns W3C SRI format: "sha256-<base64url>" (50 chars) or "sha384-<base64url>" (71 chars)
async function hashSRI(file, algo = 'SHA-256') {
  const buf     = await file.arrayBuffer();
  const hashBuf = await crypto.subtle.digest(algo, buf);
  const arr     = new Uint8Array(hashBuf);
  let binary = '';
  for (let i = 0; i < arr.length; i++) binary += String.fromCharCode(arr[i]);
  const prefix = algo.toLowerCase().replace('-',''); // 'sha256' or 'sha384'
  return prefix + '-' + btoa(binary).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}

const hs = await hashSRI(contentFile);          // SHA-256 (default): "sha256-uU0nuZNNPgilLlLX…"
const hd = await hashSRI(contextFile, 'SHA-384'); // SHA-384: "sha384-i1a7tJL6ypCC0KcdfQknC…"
params.set('hs', hs);
params.set('hd', hd);

The file content is not normalized before hashing — the hash is computed over the raw file bytes as stored. For text files, CRLF vs LF line endings produce different hashes of the same logical content; the display page offers line-ending variant checking on mismatch. The hash value itself is stored in W3C Subresource Integrity (SRI) format: sha256-<base64url> (50 chars) — not raw hex.

Terminal equivalent (files >2 GB, or non-browser contexts). Terminal tools output hex (64 lowercase hex chars) — paste directly into the generator or verification paste field; the generator auto-converts to SRI format. Get-FileHash requires Windows 10+ / PowerShell 5.1+.

Mac / Linux (SHA-256):   shasum -a 256 filename
Mac / Linux (SHA-384):   shasum -a 384 filename
Mac / Linux (SHA-512):   shasum -a 512 filename

Windows 10+ (SHA-256):   Get-FileHash filename -Algorithm SHA256
Windows 10+ (SHA-384):   Get-FileHash filename -Algorithm SHA384
Windows 10+ (SHA-512):   Get-FileHash filename -Algorithm SHA512

Python CLI (any algorithm):
  python3 aipm.py hash --algo sha256 filename
  python3 aipm.py hash --algo sha384 filename
  python3 aipm.py hash --algo sha512 filename

3.2 — Content type (type)

Declare the media type of the content. Recommended for disclosure under EU AI Act Article 50 — obligations differ by content type.

// Valid values: text | code | image | audio | video | multimodal
const VALID_TYPES = new Set(['text','code','image','audio','video','multimodal']);
if (VALID_TYPES.has(meta.type)) params.set('type', meta.type);

// EU Act Article 50 applies to these types when AI-generated:
const EU_ACT_50_TYPES = ['text','image','audio','video'];

3.3 — Automated role

Use role=automated when AI generated content without human involvement — publishing pipelines, automated summaries, etc. Use org to identify the responsible party.

const mark = buildAIPMUrl({
  role: 'automated',
  model: 'GPT-4o',
  type: 'text',
  org: 'Acme Publishing Inc.',
  date: new Date().toISOString(),
  ctx: 'Automated news summary',
  show: 1,  // Show mode → "AI" on QR mark line 3
});
// Badge renders as: AIPM v1.2 | AI

Conformance Checklist

Use this checklist to verify your AIPM 1.2 implementation. Check each item when satisfied. Progress is not saved — this is a self-assessment tool.