diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..cc580a6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+# --- lailara engagement scaffold ---
+client-data/
+client-output/
+engagement.yml
+engagement.yaml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b281af4..dec8951 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,7 @@ jobs:
pip install -r requirements.txt pytest
- name: Run core tests
- run: pytest tests.py -v
+ run: pytest tests.py test_golden.py -v
test-api:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index e304d4f..aae8cb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,11 @@ node_modules/
# Security - secret leak prevention
*.pem
secrets.*
+
+# --- lailara engagement scaffold ---
+# Client engagement data is runtime-only: never commit it, never deploy it.
+client-data/
+client-output/
+/engagement.yml
+/engagement.yaml
+# (engagement.demo.yml and engagement.example.yml stay committable)
diff --git a/INPUT-SPEC.md b/INPUT-SPEC.md
new file mode 100644
index 0000000..2f3ba00
--- /dev/null
+++ b/INPUT-SPEC.md
@@ -0,0 +1,54 @@
+# INPUT-SPEC — gtin-validator (client mode)
+
+What to hand the validator in a client engagement. Written so a client's IT person can
+produce the file without a call.
+
+## The file
+
+- **CSV or XLSX.** Read via `lailara_engagement`'s tolerant reader: UTF-8 / UTF-8-BOM /
+ latin-1; comma / semicolon / tab; leading blank rows and trailing junk dropped; header
+ whitespace trimmed.
+- One row per product. Extra columns are ignored.
+
+## Required column
+
+| Canonical | Type | Required | Used for |
+|---|---|---|---|
+| `gtin` | identifier (text) | yes | The GTIN/UPC/barcode validated against GS1 standards. §1 |
+
+- **Read as text.** GTINs keep leading zeros; `012345678905` is never parsed to `12345678905`.
+- Accepted GTIN lengths: 8, 12 (UPC-A), 13 (EAN), 14 (ITF-14 case). The engine checks the
+ mod-10 check digit, GTIN-14 indicator rules, duplicates, company-prefix consistency, and
+ unit→case hierarchy.
+
+## Column mapping (engagement.yml)
+
+If the client's header isn't literally `gtin`, map it:
+
+```yaml
+client:
+ name: "Meridian Farms"
+engagement:
+ id: "MER-2026-08"
+as_of_date: "2026-07-31"
+columns:
+ gtin: "UPC / Barcode" # client header -> canonical
+```
+
+A case/whitespace-insensitive match (e.g. `GTIN`, `UPC`, `barcode`) is auto-detected and
+disclosed; anything else must be mapped here. If no GTIN column resolves, the run produces a
+**Data Readiness Report** naming the missing column instead of results.
+
+## Run
+
+```bash
+# with lailara_engagement installed: pip install -e ../engagement-template/lib
+python client_mode.py --config engagement.yml --input client-data/items.csv \
+ --out client-output [--final]
+```
+
+Outputs to `client-output/` (gitignored):
+- `gtin-readiness-summary.html` — branded, provenance-footed (input SHA-256, row counts,
+ `as_of_date`, config hash), DRAFT-watermarked until `--final`.
+- `gtin-validation.csv` — the full per-GTIN report.
+- or `data-readiness-report.html` if the GTIN column is missing.
diff --git a/client_mode.py b/client_mode.py
new file mode 100644
index 0000000..00bb4bc
--- /dev/null
+++ b/client_mode.py
@@ -0,0 +1,217 @@
+"""Client-mode CLI for the GTIN validator.
+
+Wraps the existing validation engine (`gtin_core`) with the shared
+``lailara_engagement`` scaffold so a client's product-master export can be
+validated locally: tolerant CSV/XLSX intake (GTIN read as text), a preflight
+that names the GTIN column via ``engagement.yml`` (Data Readiness Report if it's
+missing), and a branded, provenance-footed, draft-watermarked readiness summary
+plus the standard CSV report — all written to ``client-output/`` only.
+
+Usage:
+ python client_mode.py --config engagement.yml --input client-data/items.csv \
+ --out client-output [--final]
+"""
+
+from __future__ import annotations
+
+import argparse
+import html
+from pathlib import Path
+
+from lailara_engagement import (
+ ColumnSpec,
+ PreflightSpec,
+ build_provenance,
+ load_config,
+ read_table,
+ run_preflight,
+ validation_status_label,
+ write_report,
+)
+from lailara_engagement import palette as P
+from lailara_engagement.provenance import Provenance
+
+from csv_report import generate_csv_report
+from gtin_core import validate_batch
+
+TOOL = "gtin-validator"
+TOOL_VERSION = "1.0"
+
+
+def _gtin_spec() -> PreflightSpec:
+ return PreflightSpec(
+ tool=TOOL,
+ version=TOOL_VERSION,
+ columns=[
+ ColumnSpec(
+ name="gtin",
+ dtype="identifier",
+ required=True,
+ description="the product GTIN/UPC/barcode to validate",
+ spec_ref="INPUT-SPEC §1",
+ )
+ ],
+ )
+
+
+def _summary_html(config, batch, provenance: Provenance, *, draft: bool) -> str:
+ esc = html.escape
+ s = batch["summary"]
+ score = batch["score"]
+ draft_class = " ll-draft" if draft else ""
+ # top issue types (non-INFO), by count
+ from collections import Counter
+ codes = Counter(
+ i.code for r in batch["results"] for i in r.issues if i.severity.value != "Info"
+ )
+ issue_rows = "".join(
+ f"
| {esc(code)} | {n} |
"
+ for code, n in codes.most_common(10)
+ ) or "| No critical or warning issues. |
"
+
+ grade_fill = P.LL_HK_SURFACE if score["grade"] in ("A", "B") else P.LL_SG_SURFACE
+ grade_text = P.LL_HK_DARK if score["grade"] in ("A", "B") else P.LL_SG_DARK
+
+ return f"""
+
+GTIN Readiness — {esc(config.client_name)}
+
+
+
+
+ Score {score['score']}/100 · Grade {esc(score['grade'])}
+ {esc(score['interpretation'])}
+
+
+ Batch summary
+
+ | Total GTINs | {s['total_gtins']:,} |
+ | Clean (no critical/warning) | {s['clean']:,} |
+ | Critical issues | {s['critical_issues']:,} |
+ | Warnings | {s['warnings']:,} |
+ | Duplicate groups | {s['duplicate_groups']:,} |
+
+
+
+{provenance.to_html()}
+"""
+
+
+def _css(draft: bool) -> str:
+ draft_css = (
+ ".ll-draft::before{content:'DRAFT';position:fixed;top:50%;left:50%;"
+ "transform:translate(-50%,-50%) rotate(-32deg);font-family:var(--s);"
+ "font-size:22vw;font-weight:700;color:rgba(204,16,10,.06);z-index:0;"
+ "pointer-events:none;white-space:nowrap}" if draft else ""
+ )
+ return f"""
+:root{{--s:{P.LL_SERIF};--f:{P.LL_SANS}}}
+*{{box-sizing:border-box}}
+body{{margin:0;background:{P.LL_CANVAS};color:{P.LL_TEXT};font-family:var(--f);line-height:1.6}}
+.ll-page{{position:relative;z-index:1;max-width:{P.LL_MAX_WIDTH};margin:0 auto;padding:48px 24px}}
+.ll-header{{border-bottom:1px solid {P.LL_GRIDLINE};padding-bottom:24px;margin-bottom:24px}}
+.ll-eyebrow{{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:{P.LL_RED};font-weight:600}}
+.ll-title{{font-family:var(--s);font-weight:700;color:{P.LL_INK};font-size:34px;margin:8px 0 16px}}
+.ll-client{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px 24px;font-size:14px}}
+.ll-k{{display:block;color:{P.LL_TEXT_SEC};font-size:11px;text-transform:uppercase;letter-spacing:.04em}}
+.ll-banner{{border-radius:2px;padding:16px 20px;margin-bottom:32px}}
+.ll-score{{font-family:var(--s);font-weight:700;font-size:22px}}
+.ll-h2{{font-family:var(--s);font-weight:700;color:{P.LL_INK};font-size:22px;
+margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid {P.LL_GRIDLINE}}}
+.ll-table{{width:100%;border-collapse:collapse;font-size:14px}}
+.ll-table th{{text-align:left;background:{P.LL_CHICAGO};color:#fff;padding:8px 12px}}
+.ll-table td{{padding:8px 12px;border-bottom:1px solid {P.LL_GRIDLINE}}}
+.mono{{font-family:ui-monospace,Consolas,monospace;font-size:12px}}
+.num{{text-align:right;font-variant-numeric:tabular-nums}}
+.ll-provenance{{margin-top:40px;background:{P.LL_CARD_BG};color:{P.LL_CARD_TEXT};
+padding:20px 24px;border-radius:2px;font-size:13px}}
+.ll-prov-title{{font-family:var(--s);font-weight:700;font-size:16px;margin-bottom:8px}}
+.ll-provenance div{{margin-bottom:4px;color:{P.LL_CARD_SUBTITLE}}}
+.ll-provenance strong{{color:{P.LL_CARD_TEXT}}}
+.ll-prov-inputs{{width:100%;border-collapse:collapse;margin-top:8px}}
+.ll-prov-inputs th{{text-align:left;border-bottom:1px solid rgba(255,255,255,.12);
+padding:4px 8px;color:{P.LL_CARD_MUTED}}}
+.ll-prov-inputs td{{padding:4px 8px;border-bottom:1px solid rgba(255,255,255,.08);color:{P.LL_CARD_SUBTITLE}}}
+.ll-prov-brand{{margin-top:12px;font-family:var(--s);color:{P.LL_CARD_MUTED}}}
+{draft_css}
+@media print{{body{{background:#fff}}}}
+"""
+
+
+def run(config_path: str, input_path: str, out_dir: str, *, final: bool = False) -> dict:
+ config = load_config(config_path)
+ read = read_table(input_path)
+ spec = _gtin_spec()
+ report = run_preflight(read, spec, config)
+
+ out = Path(out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+
+ provenance = build_provenance(
+ tool=TOOL, tool_version=TOOL_VERSION, inputs=[read], config=config,
+ validation_status=validation_status_label(report.status, report.n_warnings),
+ )
+
+ # Preflight gate: no GTIN column -> Data Readiness Report, no results.
+ if not report.passed:
+ paths = write_report(report, config, str(out), provenance=provenance,
+ draft=not final, basename="data-readiness-report",
+ title="GTIN Data Readiness Report")
+ return {"status": "blocked", "readiness_report": paths["html"], "report": paths["html"]}
+
+ gtin_col = report.column_mapping["gtin"]
+ gtins = [v for v in read.frame[gtin_col].astype(str)]
+ batch = validate_batch(gtins)
+
+ # CSV report (reuse the existing generator)
+ csv_path = out / "gtin-validation.csv"
+ csv_path.write_text(generate_csv_report(batch), encoding="utf-8")
+
+ # Branded, provenance-footed readiness summary
+ summary_path = out / "gtin-readiness-summary.html"
+ summary_path.write_text(_summary_html(config, batch, provenance, draft=not final),
+ encoding="utf-8")
+
+ return {
+ "status": "ok",
+ "score": batch["score"]["score"],
+ "grade": batch["score"]["grade"],
+ "clean": batch["summary"]["clean"],
+ "total": batch["summary"]["total_gtins"],
+ "csv": str(csv_path),
+ "report": str(summary_path),
+ }
+
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser(prog="gtin client mode",
+ description="Validate a client GTIN file in engagement mode.")
+ ap.add_argument("--config", required=True)
+ ap.add_argument("--input", required=True)
+ ap.add_argument("--out", default="client-output")
+ ap.add_argument("--final", action="store_true")
+ args = ap.parse_args(argv)
+ result = run(args.config, args.input, args.out, final=args.final)
+ if result["status"] == "blocked":
+ print(f"BLOCKED — data not ready. See {result['readiness_report']}")
+ return 3
+ print(f"scored {result['score']}/100 (Grade {result['grade']}); "
+ f"{result['clean']}/{result['total']} clean")
+ print(f"report -> {result['report']}\ncsv -> {result['csv']}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/engagement.demo.yml b/engagement.demo.yml
new file mode 100644
index 0000000..f542438
--- /dev/null
+++ b/engagement.demo.yml
@@ -0,0 +1,10 @@
+# Demo engagement config (safe to deploy: demo: true).
+client:
+ name: "Cinderhaven Provisions (demo)"
+engagement:
+ id: "DEMO-GTIN-001"
+as_of_date: "2026-01-31"
+prepared_by: "Lailara LLC"
+demo: true
+columns:
+ gtin: "GTIN"
diff --git a/scripts/engagement_guard.py b/scripts/engagement_guard.py
new file mode 100644
index 0000000..ec77677
--- /dev/null
+++ b/scripts/engagement_guard.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+"""Lailara engagement deploy guard (Python, stdlib-only).
+
+Exit 2 if an ACTIVE (non-demo) client engagement.yml is present in the current
+directory. No-op otherwise, so demo builds and clean CI checkouts are unaffected.
+Self-contained (no dependency on the installed lailara_engagement package) so it can
+run in any repo's deploy/build environment.
+"""
+import os
+import re
+import sys
+
+for _f in ("engagement.yml", "engagement.yaml"):
+ if os.path.isfile(_f):
+ with open(_f, encoding="utf-8-sig") as _fh:
+ _txt = _fh.read()
+ if re.search(r"^\s*demo:\s*true\s*$", _txt, re.M):
+ continue # demo config -> safe
+ sys.stderr.write(
+ f"ENGAGEMENT GUARD: active client engagement config present ({_f}). "
+ "Client mode is runtime-only and must never deploy. Deactivate it "
+ "(set 'demo: true', or use engagement.demo.yml) before deploying.\n"
+ )
+ raise SystemExit(2)
+raise SystemExit(0)
diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push
new file mode 100755
index 0000000..9be9de9
--- /dev/null
+++ b/scripts/git-hooks/pre-push
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Lailara engagement deploy guard (git pre-push hook).
+#
+# Refuses to push while an ACTIVE (non-demo) client engagement.yml is present in
+# the working tree. Every tool repo auto-deploys on push, so blocking the push
+# blocks the deploy — client mode is runtime-only and must never ship.
+#
+# No-op when no engagement.yml exists (demo builds and clean CI checkouts push
+# normally), so demo behavior is unchanged.
+#
+# Activated per repo with: git config core.hooksPath scripts/git-hooks
+set -e
+for f in engagement.yml engagement.yaml; do
+ if [ -f "$f" ]; then
+ if grep -Eq '^[[:space:]]*demo:[[:space:]]*true[[:space:]]*$' "$f"; then
+ continue # demo config -> safe
+ fi
+ echo "ENGAGEMENT GUARD: active client engagement config present ($f)." >&2
+ echo "Client mode is runtime-only and must never deploy. Deactivate it" >&2
+ echo "(set 'demo: true', or remove/rename to engagement.demo.yml) before pushing." >&2
+ exit 2
+ fi
+done
+exit 0
diff --git a/test_client_mode.py b/test_client_mode.py
new file mode 100644
index 0000000..7e2840c
--- /dev/null
+++ b/test_client_mode.py
@@ -0,0 +1,77 @@
+"""Client-mode tests for gtin-validator: intake, preflight, provenance report.
+
+Adversarial fixtures per checklist §6: missing GTIN column (blocked path),
+BOM+semicolon, Excel-mangled GTIN read as text, and a clean file.
+Skipped if lailara_engagement isn't installed.
+"""
+
+import pytest
+
+pytest.importorskip("lailara_engagement")
+
+import client_mode # noqa: E402
+
+_CONFIG = """
+client: {name: Meridian Farms}
+engagement: {id: MER-2026-08}
+as_of_date: 2026-07-31
+demo: true
+columns: {gtin: "UPC / Barcode"}
+"""
+
+
+@pytest.fixture
+def cfg(tmp_path):
+ p = tmp_path / "engagement.demo.yml"
+ p.write_text(_CONFIG, encoding="utf-8")
+ return str(p)
+
+
+def _write(tmp_path, name, text, encoding="utf-8"):
+ p = tmp_path / name
+ p.write_bytes(text.encode(encoding) if isinstance(text, str) else text)
+ return str(p)
+
+
+def test_clean_file_scores_and_reports(cfg, tmp_path):
+ src = _write(tmp_path, "items.csv",
+ "UPC / Barcode\n614141000012\n614141000029\n614141000036\n")
+ out = str(tmp_path / "client-output")
+ result = client_mode.run(cfg, src, out)
+ assert result["status"] == "ok"
+ assert result["grade"] == "A" # all valid UPCs -> Grade A (INFO not counted)
+ assert result["clean"] == 3
+ html = open(result["report"], encoding="utf-8").read()
+ assert "Meridian Farms" in html
+ assert "#f5f3ee" in html # branded canvas
+ assert "SHA-256" in html # provenance footer
+ assert "DRAFT" in html
+
+
+def test_missing_gtin_column_is_blocked(cfg, tmp_path):
+ # No column maps to gtin -> Data Readiness Report, no results.
+ src = _write(tmp_path, "bad.csv", "product,price\nA,1\nB,2\n")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, src, out)
+ assert result["status"] == "blocked"
+ html = open(result["readiness_report"], encoding="utf-8").read()
+ assert "gtin" in html.lower()
+
+
+def test_bom_semicolon_and_gtin_as_text(cfg, tmp_path):
+ body = "UPC / Barcode;name\n0614141000012;A\n614141000029;B\n"
+ src = _write(tmp_path, "bom.csv", body)
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, src, out)
+ assert result["status"] == "ok"
+ # leading-zero GTIN preserved as text through the CSV report
+ csv_text = open(result["csv"], encoding="utf-8").read()
+ assert "0614141000012" in csv_text
+
+
+def test_final_flag_drops_watermark(cfg, tmp_path):
+ src = _write(tmp_path, "items.csv", "UPC / Barcode\n614141000012\n")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, src, out, final=True)
+ html = open(result["report"], encoding="utf-8").read()
+ assert "ll-draft" not in html
diff --git a/test_golden.py b/test_golden.py
new file mode 100644
index 0000000..e7f6997
--- /dev/null
+++ b/test_golden.py
@@ -0,0 +1,78 @@
+"""Demo golden-file lock + P1 regression tests for gtin-validator.
+
+The golden test pins the shipped sample dataset's batch summary so the deployed
+demo experience cannot drift during the client-mode conversion. The regression
+tests lock the two 07-31 audit P1s:
+ 1. A UPC-A and its case-level GTIN-14 share a company prefix (no false
+ PREFIX_MISMATCH on textbook unit/case pairs — sample rows 37-39).
+ 2. INFO advisories (UPC_NOT_GTIN13) do not zero the clean count.
+"""
+
+import csv
+import io
+
+from gtin_core import validate_batch, validate_single_gtin
+from sample_data import SAMPLE_DATA
+
+
+def _sample_gtins():
+ rows = list(csv.reader(io.StringIO(SAMPLE_DATA.strip())))[1:]
+ return [r[0] for r in rows]
+
+
+class TestDemoGolden:
+ """Locks the deployed demo output. If this changes, the live site changes."""
+
+ def test_sample_summary_is_locked(self):
+ batch = validate_batch(_sample_gtins())
+ assert batch["summary"] == {
+ "total_gtins": 46,
+ "valid": 40,
+ "critical_issues": 6,
+ "warnings": 4,
+ "clean": 36,
+ "duplicate_groups": 2,
+ "unique_prefixes": 3,
+ }
+
+ def test_sample_score_is_locked(self):
+ batch = validate_batch(_sample_gtins())
+ assert batch["score"]["score"] == 82
+ assert batch["score"]["grade"] == "B"
+
+
+class TestPrefixParityRegression:
+ """07-31 P1: unit UPC and its case GTIN-14 must share a company prefix."""
+
+ def test_sample_case_rows_37_39_no_false_prefix_mismatch(self):
+ batch = validate_batch(_sample_gtins())
+ for r in batch["results"]:
+ if r.row_number in (37, 38, 39): # the GTIN-14 case rows
+ assert not any(i.code == "PREFIX_MISMATCH" for i in r.issues), (
+ f"row {r.row_number} ({r.cleaned}) false-failed PREFIX_MISMATCH"
+ )
+
+ def test_upc_and_case_gtin14_share_prefix(self):
+ unit = validate_single_gtin("614141000012", 1)
+ case = validate_single_gtin("10614141000019", 2)
+ assert unit.company_prefix == case.company_prefix == "0614141"
+
+
+class TestCleanCountRegression:
+ """07-31 P1: INFO advisories must not zero the clean count / grade."""
+
+ def test_all_upc_file_counts_as_clean(self):
+ # Every valid UPC-A gets an INFO UPC_NOT_GTIN13 advisory; the file must
+ # still read as clean and score Grade A, not "0 passed with no issues".
+ valid_upcs = ["614141000012", "614141000029", "614141000036", "614141000043"]
+ batch = validate_batch(valid_upcs)
+ assert batch["summary"]["clean"] == 4
+ assert batch["summary"]["total_gtins"] == 4
+ assert batch["score"]["grade"] == "A"
+
+ def test_info_only_row_is_clean(self):
+ batch = validate_batch(["614141000012"]) # one valid UPC-A -> INFO only
+ r = batch["results"][0]
+ assert r.issues # it does carry the INFO advisory
+ assert all(i.severity.value == "Info" for i in r.issues)
+ assert batch["summary"]["clean"] == 1