From 2edee7c9f2f865d3050d09360b1c1b2e46d73292 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 13:17:33 -0400 Subject: [PATCH 01/10] Engagement scaffold: gitignore + deploy guard --- .gitignore | 8 ++++++++ scripts/engagement_guard.py | 25 +++++++++++++++++++++++++ scripts/git-hooks/pre-push | 24 ++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 scripts/engagement_guard.py create mode 100755 scripts/git-hooks/pre-push diff --git a/.gitignore b/.gitignore index 41bfe7d..886beed 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,11 @@ secrets.* # dbt local user config dbt/.user.yml + +# --- 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/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 From 27238c8433b365bb194dc4739932abf9b16842de Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 14:34:07 -0400 Subject: [PATCH 02/10] Lock demo golden (dimension-weight-integrity) Pin the deployed demo's committed JSON exports (frontend/src/data/hero.json, all_skus.json) with a byte-level SHA-256 lock plus explicit golden figures for the hero SKU and the 50-SKU portfolio roll-up. The full pipeline that produces these needs Postgres and the four system extracts, so the exports cannot be regenerated in a plain checkout; this freezes them so the client-mode conversion cannot drift the live site or the portfolio numbers. Co-Authored-By: Claude Opus 4.8 --- tests/test_demo_golden.py | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/test_demo_golden.py diff --git a/tests/test_demo_golden.py b/tests/test_demo_golden.py new file mode 100644 index 0000000..6d7bee9 --- /dev/null +++ b/tests/test_demo_golden.py @@ -0,0 +1,98 @@ +"""Demo golden-file lock. + +The deployed demo (dimensions.lailarallc.com) renders the committed JSON exports +in frontend/src/data/. The full pipeline that produces them needs Postgres + the +four system extracts, so it cannot be reproduced in a plain checkout — which is +exactly why the demo output must be pinned as a golden here. If any headline +figure the live site shows drifts during the client-mode conversion, this fails. + +Two layers: + 1. A byte-level SHA-256 lock on each exported file (catches ANY drift). + 2. Explicit, human-readable golden figures (says WHAT changed when it breaks). + +Other suites (test_readme_figures, test_e2e_reconciliation, test_cost_math) +assert these numbers foot against each other and the prose. This suite instead +freezes them as literals, so "the demo is bit-for-bit unchanged" is a single, +independent guarantee rather than an emergent property of the other checks. +""" + +import hashlib +import json +import pathlib + +import pytest + +REPO_ROOT = pathlib.Path(__file__).parent.parent +DATA_DIR = REPO_ROOT / "frontend" / "src" / "data" + +# Byte-level lock. Regenerate deliberately (and update these) only when the demo +# is intended to change — never silently. +GOLDEN_SHA256 = { + "hero.json": "9cee4493226f7a4cf091c2a95b09deda3ed120c049c83d4d784280ed8d4bfee0", + "all_skus.json": "01d736be4ece8153754d21e5e3ed577441dc0f384f9a2b1f779e3dbfa84dc628", +} + + +@pytest.fixture(scope="module") +def hero(): + return json.loads((DATA_DIR / "hero.json").read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def all_skus(): + return json.loads((DATA_DIR / "all_skus.json").read_text(encoding="utf-8")) + + +class TestExportBytesAreLocked: + """A checksum lock: the deployed JSON must be byte-identical to the golden.""" + + @pytest.mark.parametrize("name", ["hero.json", "all_skus.json"]) + def test_exported_file_sha256_is_locked(self, name): + actual = hashlib.sha256((DATA_DIR / name).read_bytes()).hexdigest() + assert actual == GOLDEN_SHA256[name], ( + f"{name} changed (sha256 {actual[:16]}...). If this is an intended " + f"demo change, update GOLDEN_SHA256 and the figures below in the same " + f"commit; otherwise the demo has drifted and must be restored." + ) + + +class TestHeroFiguresAreLocked: + """The hero SKU (CHP-AS-002) numbers the story is built around.""" + + def test_hero_identity(self, hero): + assert hero["hero_sku"]["sku"] == "CHP-AS-002" + assert hero["hero_sku"]["product_name"] == "Roasted Garlic Marinara" + + def test_measurement_of_record_physics(self, hero): + mor = hero["hero_sku"]["measurement_of_record"] + assert mor["source"] == "wms" + assert mor["case_cube_ft3"] == 0.29052734375 + assert mor["density_lb_per_ft3"] == 74.00336134453782 + assert mor["freight_class"] == 50.0 + + def test_gdsn_reclassification(self, hero): + gdsn = hero["hero_sku"]["freight_by_system"]["gdsn"] + assert gdsn["density"] == 37.97802197802198 + assert gdsn["freight_class"] == 55.0 + + def test_cost_drivers(self, hero): + assert hero["cost"]["ltl_reclass"]["per_unit_delta"] == 0.39 + assert hero["cost"]["ltl_reclass"]["annual_units"] == 10851.0 + assert hero["cost"]["ltl_reclass"]["annual_cost"] == 4231.89 + assert hero["cost"]["parcel_reweigh"]["annual_cost"] == 394.0 + assert hero["cost"]["compliance_cb"]["annual_cost"] == 240.0 + + def test_hero_total(self, hero): + total = sum(d["annual_cost"] for d in hero["cost"].values()) + assert total == 4865.89 + + +class TestPortfolioFiguresAreLocked: + """The 50-SKU roll-up shown on the portfolio panel.""" + + def test_aggregate(self, all_skus): + agg = all_skus["aggregate"] + assert agg["total_annual_cost"] == 208310.87 + assert agg["skus_with_class_mismatch"] == 27 + assert agg["total_skus"] == 50 + assert len(all_skus["skus"]) == 50 From 48d06631c9dedec53399ba27b024a4c9b81379c0 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 14:39:26 -0400 Subject: [PATCH 03/10] Add dimension-weight-integrity client mode (engagement.yml + provenance report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the shared lailara_engagement scaffold onto a single-file client path: tolerant CSV/XLSX intake with SKU read as text, engagement.yml column mapping + preflight (branded Data Readiness Report when a required case dimension/weight column is missing), the physical-attribute engine run on validated rows, and a branded, provenance-footed, DRAFT-watermarked readiness summary + per-SKU CSV written to client-output/ only. - dimension_physics.py: canonical Python cube/density/NMFC-class/DIM/billable math, guarded against the dbt macro by a drift test so a third copy of the freight-class table cannot silently diverge. - client_mode.py: preflight gate, engine, branded report; optional columns allow per-row blanks (present-but-partial is normal), required case dims/weight are validated. Rate tables and annual volumes are NOT applied on this path — dollar-cost lanes need the four-system divergence the pipeline builds, so client mode reports physical readiness, not a cost estimate. - INPUT-SPEC.md written from the code (required vs optional fields, types). - engagement.demo.yml (demo: true, safe to deploy). - tests/test_client_mode.py: adversarial fixtures (missing required column -> blocked, BOM+semicolon with leading-zero SKU as text, uncomputable-row disclosure, clean file) + physics/dbt drift guard. importorskip-guarded. - README: 'Client engagement use' one-command section. The dbt + Dagster + Postgres pipeline is unchanged. Demo output is bit-for-bit unchanged (test_demo_golden still green). Full suite: 91 passed. Co-Authored-By: Claude Opus 4.8 --- INPUT-SPEC.md | 90 +++++++++ README.md | 21 +++ client_mode.py | 374 ++++++++++++++++++++++++++++++++++++++ dimension_physics.py | 55 ++++++ engagement.demo.yml | 21 +++ tests/test_client_mode.py | 162 +++++++++++++++++ 6 files changed, 723 insertions(+) create mode 100644 INPUT-SPEC.md create mode 100644 client_mode.py create mode 100644 dimension_physics.py create mode 100644 engagement.demo.yml create mode 100644 tests/test_client_mode.py diff --git a/INPUT-SPEC.md b/INPUT-SPEC.md new file mode 100644 index 0000000..abc4a2b --- /dev/null +++ b/INPUT-SPEC.md @@ -0,0 +1,90 @@ +# INPUT-SPEC — dimension-weight-integrity (client mode) + +What to hand the physical-attribute readiness check in a client engagement. Written so +a client's IT/data person can produce the file without a call. + +## The file + +- **One item-master export: 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; Excel dates/numbers rendered as text. +- **One row per SKU (per case configuration).** Extra columns are ignored. +- This is the client's item master — the case dimensions and weights one system holds. + The full four-system divergence and cost model (ERP vs WMS vs GDSN vs DTC) is the dbt + pipeline's job and takes four extracts; client mode validates one file and computes the + per-SKU physical attributes (cube, density, freight class) it implies. + +## Required columns + +These are the fields freight class is computed from — the "case dimensions + weights" the +readiness check exists to validate. If any is missing, the run produces a **Data Readiness +Report** naming it instead of results. + +| Canonical | Type | Required | Used for | +|---|---|---|---| +| `sku` | identifier (text) | yes | Row key; deduplicated and reported per SKU. §1 | +| `case_length_in` | number | yes | Case length (inches) → cube → density → freight class. §2 | +| `case_width_in` | number | yes | Case width (inches). §2 | +| `case_height_in` | number | yes | Case height (inches). §2 | +| `case_gross_weight_lb` | number (≥ 0) | yes | Case gross weight (lb) → density → freight class. §2 | + +- **Identifiers read as text.** `sku` keeps leading zeros; a numeric-looking SKU is never + parsed to a number. +- **Dimensions and weights are numeric and non-negative.** Zero or blank case dimensions + make cube (and therefore density and freight class) uncomputable for that row; those rows + are counted and disclosed, never silently assumed. + +## Optional columns + +Present → used; absent → skipped and disclosed. None blocks the run. + +| Canonical | Type | Used for | +|---|---|---| +| `product_name` | string | Human label in the report. | +| `gtin` | identifier (text) | Carried through for cross-reference; not validated here (see the GTIN validator tool). | +| `unit_net_weight_lb` | number (≥ 0) | DTC parcel billable-weight check, when a DTC parcel gross weight is also supplied. §3 | +| `case_pack_qty` | integer | Units per case; reported for context. | +| `dtc_parcel_gross_lb` | number (≥ 0) | Actual DTC parcel weight → DIM/billable-weight vs listed net → parcel reweigh exposure. §3 | + +## Column mapping (engagement.yml) + +If the client's headers are not the canonical names, map them. A case/whitespace-insensitive +exact match (e.g. `Case Length (in)` → `case_length_in`) is auto-detected and disclosed; +anything else must be mapped here. + +```yaml +client: + name: "Meridian Farms" +engagement: + id: "MER-2026-08" +as_of_date: "2026-07-31" +columns: + sku: "Item #" + case_length_in: "Case L (in)" + case_width_in: "Case W (in)" + case_height_in: "Case H (in)" + case_gross_weight_lb: "Case Wt (lb)" +``` + +## Run + +```bash +# with lailara_engagement installed: pip install -e ../engagement-template/lib +python client_mode.py --config engagement.yml --input client-data/item_master.csv \ + --out client-output [--final] +``` + +Outputs to `client-output/` (gitignored): +- `dimension-readiness-summary.html` — branded, provenance-footed (input SHA-256, row + counts, `as_of_date`, config hash), DRAFT-watermarked until `--final`. +- `dimension-readiness.csv` — per-SKU cube, density, and freight class. +- or `data-readiness-report.html` if a required column is missing. + +## What is computed vs configured + +- **Computed (physics/standards, never asserted):** case cube, density, NMFC freight + class, DTC DIM weight, billable weight. Same math as the dbt macros (`dimension_physics.py`). +- **Configured (`config/cost_params.yml`):** the DTC box size and DIM divisor used for the + optional parcel check. Rate tables and annual volumes are **not** applied on the + single-file client path — dollar-cost lanes need the four-system divergence the pipeline + builds, so client mode reports physical readiness, not a cost estimate. diff --git a/README.md b/README.md index e161c94..c687b26 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,27 @@ python -m pytest tests/ -v cd frontend && npm test ``` +### Client engagement use + +To validate a client's item master and compute its physical attributes locally +(no database, no deploy), install the shared scaffold and run client mode: + +``` +python -m pip install -e ../engagement-template/lib +python client_mode.py --config engagement.yml --input client-data/item_master.csv \ + --out client-output [--final] +``` + +It reads CSV/XLSX tolerantly (SKU kept as text), runs a preflight that emits a +branded **Data Readiness Report** if a required case dimension/weight column is +missing, then computes cube, density, and NMFC freight class per SKU into a +branded, provenance-footed, DRAFT-watermarked report in `client-output/` +(gitignored). Required fields, mapping, and scope are in +[`INPUT-SPEC.md`](INPUT-SPEC.md). `engagement.demo.yml` is a safe-to-deploy +example (`demo: true`); a real `engagement.yml` is runtime-only and never +deploys (`scripts/engagement_guard.py` enforces this). The four-system +divergence and dollar-cost model below is the dbt pipeline and is unchanged. + ### Deploy **Pushing to `main` deploys the site.** The Cloudflare Pages project is diff --git a/client_mode.py b/client_mode.py new file mode 100644 index 0000000..e67a811 --- /dev/null +++ b/client_mode.py @@ -0,0 +1,374 @@ +"""Client-mode CLI for dimension-weight-integrity. + +Wraps the physical-attribute engine with the shared ``lailara_engagement`` scaffold +so a client's item-master export can be validated and measured locally: tolerant +CSV/XLSX intake (SKU read as text), a preflight that names the required case +dimension/weight columns via ``engagement.yml`` (Data Readiness Report if any is +missing), the physics engine (cube → density → NMFC freight class, plus an optional +DTC parcel billable-weight check) run on the validated rows, and a branded, +provenance-footed, draft-watermarked readiness summary plus a per-SKU CSV — all +written to ``client-output/`` only. + +Scope note: this validates ONE item master and computes the physical attributes it +implies. The four-system divergence and dollar-cost model (ERP/WMS/GDSN/DTC) is the +dbt + Dagster + Postgres pipeline and is unchanged; it takes four extracts and a +database, so it is not driven from here. See INPUT-SPEC.md. + +Usage: + python client_mode.py --config engagement.yml --input client-data/item_master.csv \ + --out client-output [--final] +""" + +from __future__ import annotations + +import argparse +import csv +import html +import io +import pathlib +from collections import Counter + +import yaml + +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 + +import dimension_physics as phys + +TOOL = "dimension-weight-integrity" +TOOL_VERSION = "1.0" + +REPO_ROOT = pathlib.Path(__file__).parent +CONFIG_PATH = REPO_ROOT / "config" / "cost_params.yml" + + +def _spec() -> PreflightSpec: + """The item-master fields the physical-attribute engine consumes. + + Required = SKU + the four fields freight class is computed from. Optional fields + are used when present and disclosed as skipped when absent. + """ + return PreflightSpec( + tool=TOOL, + version=TOOL_VERSION, + columns=[ + ColumnSpec("sku", dtype="identifier", required=True, unique=True, + description="row key; the SKU/item number", + spec_ref="INPUT-SPEC §1"), + ColumnSpec("case_length_in", dtype="number", required=True, not_negative=True, + description="case length in inches", spec_ref="INPUT-SPEC §2"), + ColumnSpec("case_width_in", dtype="number", required=True, not_negative=True, + description="case width in inches", spec_ref="INPUT-SPEC §2"), + ColumnSpec("case_height_in", dtype="number", required=True, not_negative=True, + description="case height in inches", spec_ref="INPUT-SPEC §2"), + ColumnSpec("case_gross_weight_lb", dtype="number", required=True, not_negative=True, + description="case gross weight in pounds", spec_ref="INPUT-SPEC §2"), + # Optional columns allow blanks: present-but-partly-empty is normal for + # them (e.g. DTC weight only on DTC-sold SKUs), so a blank cell is not a + # readiness finding. Non-blank cells are still type-checked. + ColumnSpec("product_name", dtype="string", required=False, allow_blank=True, + spec_ref="INPUT-SPEC §optional"), + ColumnSpec("gtin", dtype="identifier", required=False, allow_blank=True, + spec_ref="INPUT-SPEC §optional"), + ColumnSpec("unit_net_weight_lb", dtype="number", required=False, allow_blank=True, + not_negative=True, spec_ref="INPUT-SPEC §3"), + ColumnSpec("case_pack_qty", dtype="integer", required=False, allow_blank=True, + spec_ref="INPUT-SPEC §optional"), + ColumnSpec("dtc_parcel_gross_lb", dtype="number", required=False, allow_blank=True, + not_negative=True, spec_ref="INPUT-SPEC §3"), + ], + ) + + +def _load_parcel_params() -> tuple[float, float]: + """DTC box size and DIM divisor from config (used only for the optional parcel check).""" + with open(CONFIG_PATH, encoding="utf-8") as f: + cfg = yaml.safe_load(f) + parcel = cfg["parcel"] + return float(parcel["dtc_parcel_box_in"]), float(parcel["dim_divisor"]) + + +def _num(value): + """Parse a numeric cell (all cells are text). Blank/unparseable -> None.""" + s = str(value).strip() + if s == "": + return None + try: + return float(s) + except ValueError: + return None + + +def compute_rows(read, mapping, *, box_in, dim_divisor): + """Run the physics engine per row on validated input. Returns (rows, summary).""" + frame = read.frame + col = mapping # canonical -> resolved client header + + def cell(canonical, i): + header = col.get(canonical) + if header is None: + return None + return frame[header].iloc[i] + + rows = [] + n = len(frame) + for i in range(n): + sku = str(cell("sku", i)).strip() + length = _num(cell("case_length_in", i)) + width = _num(cell("case_width_in", i)) + height = _num(cell("case_height_in", i)) + gross = _num(cell("case_gross_weight_lb", i)) + + rec = { + "sku": sku, + "product_name": (str(cell("product_name", i)).strip() + if col.get("product_name") else ""), + "case_length_in": length, + "case_width_in": width, + "case_height_in": height, + "case_gross_weight_lb": gross, + "case_cube_ft3": None, + "density_lb_per_ft3": None, + "freight_class": None, + "dtc_billable_weight_lb": None, + "parcel_reweigh_flag": False, + "note": "", + } + + if None in (length, width, height) or (length == 0 or width == 0 or height == 0): + rec["note"] = "missing/zero case dimension — cube, density and freight class not computable" + rows.append(rec) + continue + + cube = phys.cube_ft3(length, width, height) + rec["case_cube_ft3"] = round(cube, 5) + if gross is None: + rec["note"] = "missing case gross weight — density and freight class not computable" + rows.append(rec) + continue + + density = phys.density_lb_per_ft3(gross, cube) + rec["density_lb_per_ft3"] = round(density, 2) + rec["freight_class"] = phys.density_to_nmfc_class(density) + + # Optional DTC parcel billable-weight check: only when a DTC parcel gross + # weight is supplied. DIM weight uses the per-unit DTC box, never the case. + parcel_gross = _num(cell("dtc_parcel_gross_lb", i)) if col.get("dtc_parcel_gross_lb") else None + unit_net = _num(cell("unit_net_weight_lb", i)) if col.get("unit_net_weight_lb") else None + if parcel_gross is not None: + dim_wt = phys.dim_weight_lb(box_in, box_in, box_in, dim_divisor) + billable = phys.billable_weight_lb(parcel_gross, dim_wt) + rec["dtc_billable_weight_lb"] = billable + # Reweigh exposure: the listed net understates the billable parcel weight. + if unit_net is not None and billable > unit_net: + rec["parcel_reweigh_flag"] = True + + rows.append(rec) + + computed = [r for r in rows if r["freight_class"] is not None] + summary = { + "total_skus": len(rows), + "computed": len(computed), + "uncomputable": len(rows) - len(computed), + "class_distribution": dict(sorted(Counter( + r["freight_class"] for r in computed + ).items())), + "parcel_reweigh_exposed": sum(1 for r in rows if r["parcel_reweigh_flag"]), + } + return rows, summary + + +def _csv_report(rows) -> str: + buf = io.StringIO() + fields = ["sku", "product_name", "case_length_in", "case_width_in", "case_height_in", + "case_gross_weight_lb", "case_cube_ft3", "density_lb_per_ft3", "freight_class", + "dtc_billable_weight_lb", "parcel_reweigh_flag", "note"] + writer = csv.DictWriter(buf, fieldnames=fields) + writer.writeheader() + for r in rows: + writer.writerow({k: ("" if r.get(k) is None else r.get(k)) for k in fields}) + return buf.getvalue() + + +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;background:{P.LL_HK_SURFACE};color:{P.LL_HK_DARK}}} +.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-note{{font-size:13px;color:{P.LL_TEXT_SEC};margin:8px 0 24px}} +.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 _summary_html(config, summary, rows, provenance: Provenance, *, box_in, dim_divisor, + draft: bool) -> str: + esc = html.escape + draft_class = "ll-draft" if draft else "" + + class_rows = "".join( + f"Class {esc(str(int(c) if float(c).is_integer() else c))}" + f"{n}" + for c, n in summary["class_distribution"].items() + ) or "No freight classes computed." + + # Show the first rows that could not be computed, so exclusions are visible. + excluded = [r for r in rows if r["freight_class"] is None] + excl_rows = "".join( + f"{esc(r['sku'])}{esc(r['note'])}" + for r in excluded[:15] + ) + excl_section = ( + f"

Rows not computable " + f"({len(excluded)})

These rows are excluded from the " + f"freight-class distribution above and disclosed here rather than assumed.

" + f"" + f"{excl_rows}
SKUReason
" + if excluded else "" + ) + + return f""" + +Dimension & Weight Readiness — {esc(config.client_name)} + +
+
+
Lailara LLC · Dimension & Weight Integrity
+

Physical Attribute Readiness Summary

+
+
Client {esc(config.client_name)}
+
Engagement {esc(config.engagement_id)}
+
As of {esc(config.as_of_date.isoformat())}
+
+
+
+
{summary['computed']:,} of {summary['total_skus']:,} SKUs measured
+
Freight class computed from case dimensions and gross weight (density → NMFC).
+
+
+

Batch summary

+ + + + + +
Item-master rows{summary['total_skus']:,}
Freight class computed{summary['computed']:,}
Not computable (missing dims/weight){summary['uncomputable']:,}
DTC parcel reweigh exposure{summary['parcel_reweigh_exposed']:,}
+

Physics computed, not asserted: cube = L×W×H/1728; density = + gross/cube; NMFC class from the density scale. DTC billable weight uses a + {esc(f'{box_in:g}')}-inch box and DIM divisor {esc(f'{dim_divisor:g}')} from + config/cost_params.yml. Rate tables and annual volumes are not applied on this + single-file path — dollar-cost lanes need the four-system divergence the pipeline builds.

+
+
+

Freight class distribution

+ + {class_rows}
NMFC classSKUs
+
+{excl_section} +{provenance.to_html()} +
""" + + +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 = _spec() + report = run_preflight(read, spec, config) + + out = pathlib.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: a missing required 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="Dimension & Weight Data Readiness Report") + return {"status": "blocked", "readiness_report": paths["html"], "report": paths["html"]} + + box_in, dim_divisor = _load_parcel_params() + rows, summary = compute_rows(read, report.column_mapping, box_in=box_in, dim_divisor=dim_divisor) + + csv_path = out / "dimension-readiness.csv" + csv_path.write_text(_csv_report(rows), encoding="utf-8") + + summary_path = out / "dimension-readiness-summary.html" + summary_path.write_text( + _summary_html(config, summary, rows, provenance, + box_in=box_in, dim_divisor=dim_divisor, draft=not final), + encoding="utf-8", + ) + + return { + "status": "ok", + "total": summary["total_skus"], + "computed": summary["computed"], + "uncomputable": summary["uncomputable"], + "csv": str(csv_path), + "report": str(summary_path), + } + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="dimension client mode", + description="Validate a client item master and compute " + "physical-attribute readiness 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"measured {result['computed']}/{result['total']} SKUs " + f"({result['uncomputable']} not computable)") + print(f"report -> {result['report']}\ncsv -> {result['csv']}") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/dimension_physics.py b/dimension_physics.py new file mode 100644 index 0000000..c8140a0 --- /dev/null +++ b/dimension_physics.py @@ -0,0 +1,55 @@ +"""Physical-attribute computations for a case: cube, density, NMFC freight class, +parcel DIM weight, billable weight. + +These are physics and published standards — computed, never asserted (per CLAUDE.md, +the "exact vs parameter split is the credibility core"). The pipeline computes the +same quantities in dbt macros; this pure-Python module lets client mode run the same +math on a client's item master without a database. + +The NMFC density scale here MUST match dbt/macros/density_to_nmfc_class.sql and the +canonical band table in tests/test_cost_math.py. tests/test_client_mode.py parses the +dbt macro and asserts this module agrees, so a third copy cannot silently drift. +""" + +from __future__ import annotations + +import math + +# Canonical NMFC density -> freight-class scale. A CASE returns the FIRST match, +# so order (descending threshold) is load-bearing. +NMFC_BANDS: list[tuple[float, float]] = [ + (50.0, 50), (35.0, 55), (30.0, 60), (22.5, 65), + (15.0, 70), (13.5, 77.5), (12.0, 85), (10.5, 92.5), + (9.0, 100), (8.0, 110), (7.0, 125), (6.0, 150), + (5.0, 175), (4.0, 200), (3.0, 250), (2.0, 300), + (1.0, 400), +] +NMFC_FALLBACK_CLASS: float = 500 + + +def cube_ft3(length_in: float, width_in: float, height_in: float) -> float: + """Case cube in cubic feet from inch dimensions.""" + return (length_in * width_in * height_in) / 1728.0 + + +def density_lb_per_ft3(weight_lb: float, cube: float) -> float: + """Density = weight / cube. Caller guards cube > 0.""" + return weight_lb / cube + + +def density_to_nmfc_class(density: float) -> float: + """Map density (lb/ft^3) to an NMFC freight class.""" + for threshold, nmfc_class in NMFC_BANDS: + if density >= threshold: + return nmfc_class + return NMFC_FALLBACK_CLASS + + +def dim_weight_lb(length_in: float, width_in: float, height_in: float, divisor: float) -> float: + """Dimensional weight: each dimension is rounded UP to the next inch (carrier rule).""" + return (math.ceil(length_in) * math.ceil(width_in) * math.ceil(height_in)) / divisor + + +def billable_weight_lb(actual_weight: float, dim_weight: float) -> int: + """Billable weight: the greater of actual and DIM, rounded up to the next pound.""" + return math.ceil(max(actual_weight, dim_weight)) diff --git a/engagement.demo.yml b/engagement.demo.yml new file mode 100644 index 0000000..dace4e1 --- /dev/null +++ b/engagement.demo.yml @@ -0,0 +1,21 @@ +# Demo engagement config (safe to deploy: demo: true). +# +# Client mode is runtime-only and must never deploy with an ACTIVE config. This +# demo config carries `demo: true`, so scripts/engagement_guard.py treats it as +# safe. A real engagement uses engagement.yml (gitignored) without demo: true. +client: + name: "Cinderhaven Provisions (demo)" +engagement: + id: "DEMO-DIM-001" +as_of_date: "2026-01-31" +prepared_by: "Lailara LLC" +demo: true +columns: + sku: "SKU" + case_length_in: "Case Length (in)" + case_width_in: "Case Width (in)" + case_height_in: "Case Height (in)" + case_gross_weight_lb: "Case Gross Weight (lb)" + unit_net_weight_lb: "Unit Net Weight (lb)" + case_pack_qty: "Case Pack" + dtc_parcel_gross_lb: "DTC Parcel Weight (lb)" diff --git a/tests/test_client_mode.py b/tests/test_client_mode.py new file mode 100644 index 0000000..ae5a585 --- /dev/null +++ b/tests/test_client_mode.py @@ -0,0 +1,162 @@ +"""Client-mode tests for dimension-weight-integrity: intake, preflight, engine, report. + +Adversarial fixtures per checklist §6: missing required column (blocked path), +BOM + semicolon with an Excel-mangled leading-zero SKU read as text, and a clean +file that computes freight classes and renders a branded, provenance-footed report. +Plus a drift guard tying dimension_physics.py to the production dbt macro. + +Skipped if lailara_engagement isn't installed. +""" + +import pathlib +import re + +import pytest + +pytest.importorskip("lailara_engagement") + +import client_mode # noqa: E402 +import dimension_physics as phys # noqa: E402 + +REPO_ROOT = pathlib.Path(__file__).parent.parent + +_CONFIG = """ +client: {name: Meridian Farms} +engagement: {id: MER-2026-08} +as_of_date: 2026-07-31 +demo: true +columns: + sku: "Item #" + case_length_in: "Case L (in)" + case_width_in: "Case W (in)" + case_height_in: "Case H (in)" + case_gross_weight_lb: "Case Wt (lb)" +""" + + +@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_computes_and_reports(cfg, tmp_path): + # One dense case (hero-like: class 50) and one light-for-size case. + src = _write( + tmp_path, "item_master.csv", + "Item #,Case L (in),Case W (in),Case H (in),Case Wt (lb)\n" + "SKU-1,11.25,8.5,5.25,21.5\n" # density 74 -> class 50 + "SKU-2,20,20,20,3\n", # density ~0.65 -> class 500 + ) + out = str(tmp_path / "client-output") + result = client_mode.run(cfg, src, out) + assert result["status"] == "ok" + assert result["computed"] == 2 + assert result["uncomputable"] == 0 + + csv_text = open(result["csv"], encoding="utf-8").read() + assert ",50," in csv_text # hero-like row scored class 50 + assert ",500," in csv_text # light-for-size row scored class 500 + + html = open(result["report"], encoding="utf-8").read() + assert "Meridian Farms" in html # client header block + assert "#f5f3ee" in html # branded canvas + assert "SHA-256" in html # provenance footer + assert "DRAFT" in html # draft watermark + + +def test_missing_required_column_is_blocked(cfg, tmp_path): + # No case weight column maps -> Data Readiness Report, no results. + src = _write( + tmp_path, "bad.csv", + "Item #,Case L (in),Case W (in),Case H (in)\nSKU-1,11.25,8.5,5.25\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 "case_gross_weight_lb" in html + + +def test_bom_semicolon_and_sku_as_text(cfg, tmp_path): + # UTF-8 BOM + semicolon delimiter + a leading-zero SKU Excel would mangle to a number. + body = ( + "Item #;Case L (in);Case W (in);Case H (in);Case Wt (lb)\n" + "0012345;11.25;8.5;5.25;21.5\n" + ) + src = _write(tmp_path, "bom.csv", body) + out = str(tmp_path / "out") + result = client_mode.run(cfg, src, out) + assert result["status"] == "ok" + csv_text = open(result["csv"], encoding="utf-8").read() + assert "0012345" in csv_text # leading zero preserved as text end to end + + +def test_uncomputable_row_is_disclosed_not_assumed(cfg, tmp_path): + # A zero dimension makes cube uncomputable; the row must be excluded and disclosed. + src = _write( + tmp_path, "item_master.csv", + "Item #,Case L (in),Case W (in),Case H (in),Case Wt (lb)\n" + "SKU-1,11.25,8.5,5.25,21.5\n" + "SKU-2,0,8.5,5.25,21.5\n", + ) + out = str(tmp_path / "out") + result = client_mode.run(cfg, src, out) + assert result["status"] == "ok" + assert result["computed"] == 1 + assert result["uncomputable"] == 1 + html = open(result["report"], encoding="utf-8").read() + assert "not computable" in html.lower() + + +def test_final_flag_drops_watermark(cfg, tmp_path): + src = _write( + tmp_path, "item_master.csv", + "Item #,Case L (in),Case W (in),Case H (in),Case Wt (lb)\nSKU-1,11.25,8.5,5.25,21.5\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 + + +# --- Drift guard: dimension_physics.py must match the production dbt macro --- + + +def test_physics_nmfc_table_matches_dbt_macro(): + """The client-mode Python physics and the dbt macro encode the same NMFC scale. + + dimension_physics.py is a third encoding of the density->class table (dbt macro + and tests/test_cost_math.py are the others). Parse the macro's branches and + assert this module returns the same class at each breakpoint, so a copy here + cannot silently diverge from what the pipeline computes. + """ + macro = (REPO_ROOT / "dbt" / "macros" / "density_to_nmfc_class.sql").read_text() + body = re.sub(r"\{#.*?#\}", "", macro, flags=re.S) + branches = re.findall(r"when\s+.+?>=\s*([0-9.]+)\s*then\s*([0-9.]+)", body) + assert branches, "could not parse the dbt macro branches" + + macro_pairs = [(float(t), float(c)) for t, c in branches] + module_pairs = [(t, float(c)) for t, c in phys.NMFC_BANDS] + assert module_pairs == macro_pairs, "dimension_physics NMFC table drifted from the dbt macro" + + # Agreement at each breakpoint and just below the lowest (the fallback class). + for threshold, expected_class in macro_pairs: + assert phys.density_to_nmfc_class(threshold) == expected_class + assert phys.density_to_nmfc_class(0.5) == phys.NMFC_FALLBACK_CLASS + assert re.search(rf"else\s+{int(phys.NMFC_FALLBACK_CLASS)}\b", body) + + +def test_physics_hero_case_scores_class_50(): + """Sanity anchor: the hero case (11.25 x 8.5 x 5.25, 21.5 lb) is class 50.""" + cube = phys.cube_ft3(11.25, 8.5, 5.25) + density = phys.density_lb_per_ft3(21.5, cube) + assert round(density, 2) == 74.00 + assert phys.density_to_nmfc_class(density) == 50 From 2749d82f8433c834611d8aceb7d1de7495317884 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 21:01:07 -0400 Subject: [PATCH 04/10] ci: add server-side engagement guard to deploy workflow(s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt 6 step 0.e. core.hooksPath (the local pre-push guard) is inert on a fresh clone, so a force-added active engagement.yml could otherwise reach a deploy. Add an 'Engagement guard' step (python3 scripts/engagement_guard.py — python3 is preinstalled on ubuntu-latest) right after checkout in the deploy path, so the guard runs server-side regardless of local git config. No-op for demo/clean checkouts (engagement.yml is gitignored). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52153b7..4dc5a74 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Engagement guard + run: python3 scripts/engagement_guard.py + - name: Setup Node.js uses: actions/setup-node@v4 with: From 001ee6384395a0b2d8f5d9c3f3f72b38bc6b41a9 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 21:30:09 -0400 Subject: [PATCH 05/10] CI: gate demo golden (dimension-weight-integrity) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/golden.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/golden.yml diff --git a/.github/workflows/golden.yml b/.github/workflows/golden.yml new file mode 100644 index 0000000..2d2bdd5 --- /dev/null +++ b/.github/workflows/golden.yml @@ -0,0 +1,26 @@ +name: golden + +# Gates the demo golden lock: freezes the deployed demo's byte-level exports and +# headline figures so they cannot drift during the client-mode conversion. Runs +# ONLY the golden (tests/test_demo_golden.py) — it needs base deps only (pytest + +# stdlib, reads committed JSON), never lailara_engagement. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + golden: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install test deps + run: pip install pytest + - name: Demo golden lock + run: python -m pytest tests/test_demo_golden.py -q From e860d2e494034c6c272e08eea270394de02dbc9e Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 21:50:44 -0400 Subject: [PATCH 06/10] ci: run the demo golden in CI on every commit (0.a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt 6 step 0.a. This repo's demo golden test ran only locally — no CI job collected it. Add demo-golden.yml: setup-python + pip install -r requirements.txt pytest, run the golden, with the server-side engagement guard first. Verified green in a clean venv from requirements.txt alone (CI simulation), so this check is trustworthy, not aspirational. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/demo-golden.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/demo-golden.yml diff --git a/.github/workflows/demo-golden.yml b/.github/workflows/demo-golden.yml new file mode 100644 index 0000000..6f24c5e --- /dev/null +++ b/.github/workflows/demo-golden.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + demo-golden: + name: Demo golden + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Server-side deploy guard (fresh clones have no local hook). No-op clean. + - name: Engagement guard + run: python3 scripts/engagement_guard.py + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install deps + run: pip install -r requirements.txt pytest + + - name: Demo golden + run: pytest tests/test_demo_golden.py -v From d6a49a46a12f106ebf3b2a7a197fa207f58a4a48 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 22:00:12 -0400 Subject: [PATCH 07/10] ci: drop redundant demo-golden.yml (superseded by concurrent golden.yml) A concurrent session added a comprehensive golden.yml 0.a sweep across all repos (verified green). My demo-golden.yml here duplicated it (both run the same tests/test_demo_golden.py). Remove my redundant copy so each repo has one consistently-named golden gate; the concurrent session's golden.yml is retained. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/demo-golden.yml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/workflows/demo-golden.yml diff --git a/.github/workflows/demo-golden.yml b/.github/workflows/demo-golden.yml deleted file mode 100644 index 6f24c5e..0000000 --- a/.github/workflows/demo-golden.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: CI - -on: - push: - pull_request: - workflow_dispatch: - -jobs: - demo-golden: - name: Demo golden - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - # Server-side deploy guard (fresh clones have no local hook). No-op clean. - - name: Engagement guard - run: python3 scripts/engagement_guard.py - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install deps - run: pip install -r requirements.txt pytest - - - name: Demo golden - run: pytest tests/test_demo_golden.py -v From 7f065d0b7588e84a7bf79a1352e3d31467dc6604 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Thu, 6 Aug 2026 13:40:36 -0400 Subject: [PATCH 08/10] ci: gate the client-mode suite in CI (0.b) Adds .github/workflows/client-mode.yml: installs the repo's deps + lailara_engagement from the private repo pinned to v0.2.1 (LAILARA_ENGAGEMENT_TOKEN secret), then runs the client-mode suite that previously importorskip-skipped the lib. The demo golden gate is untouched and credential-free, so a PAT expiry degrades client-mode coverage only, never the demo invariant. Install verified green in a clean venv locally (lib from the local path, identical code to the tagged git URL). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/client-mode.yml | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/client-mode.yml diff --git a/.github/workflows/client-mode.yml b/.github/workflows/client-mode.yml new file mode 100644 index 0000000..72f909c --- /dev/null +++ b/.github/workflows/client-mode.yml @@ -0,0 +1,34 @@ +name: client-mode + +# Runs the client-mode suite (checklist §6), which imports lailara_engagement. +# The lib is installed from the private repo pinned to v0.2.1 via the +# LAILARA_ENGAGEMENT_TOKEN secret (fine-grained, read-only). This is the ONLY job +# that depends on the PAT; the demo golden gate stays credential-free, so a token +# expiry degrades client-mode coverage without ever reddening the demo invariant. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + client-mode: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Engagement guard + run: python3 scripts/engagement_guard.py + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install (repo runtime + lailara_engagement @ v0.2.1) + env: + LAILARA_ENGAGEMENT_TOKEN: ${{ secrets.LAILARA_ENGAGEMENT_TOKEN }} + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt pytest + pip install "lailara_engagement @ git+https://x-access-token:${LAILARA_ENGAGEMENT_TOKEN}@github.com/MsShawnP/lailara-engagement.git@v0.2.1#subdirectory=lib" + - name: Client-mode suite + run: python -m pytest tests/test_client_mode.py -q From e9ddddc6bdc9ca3a6dc7ec283cf1d853b571597e Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Fri, 7 Aug 2026 13:07:50 -0400 Subject: [PATCH 09/10] ci: fail-fast if LAILARA_ENGAGEMENT_TOKEN is empty or '-' (client-mode guard) Explicit ::error:: before the lib install when the secret is empty or the literal '-' (the --body - failure mode), instead of git's misleading 'password authentication is not supported.' Co-Authored-By: Claude Opus 4.8 --- .github/workflows/client-mode.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/client-mode.yml b/.github/workflows/client-mode.yml index 72f909c..eedb89b 100644 --- a/.github/workflows/client-mode.yml +++ b/.github/workflows/client-mode.yml @@ -23,6 +23,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Verify engagement token is present + env: + LAILARA_ENGAGEMENT_TOKEN: ${{ secrets.LAILARA_ENGAGEMENT_TOKEN }} + run: | + if [ -z "${LAILARA_ENGAGEMENT_TOKEN}" ] || [ "${LAILARA_ENGAGEMENT_TOKEN}" = "-" ]; then + echo "::error::LAILARA_ENGAGEMENT_TOKEN is empty or unavailable (literal '-' means the secret was set with 'gh secret set --body -'). Re-set via stdin: gh secret set LAILARA_ENGAGEMENT_TOKEN --repo MsShawnP/ < tokenfile" + exit 1 + fi - name: Install (repo runtime + lailara_engagement @ v0.2.1) env: LAILARA_ENGAGEMENT_TOKEN: ${{ secrets.LAILARA_ENGAGEMENT_TOKEN }} From 83193b2177509aaf5476bea823e51a0bffbf8a07 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Fri, 7 Aug 2026 20:11:10 -0400 Subject: [PATCH 10/10] ci: re-pin lailara_engagement @v0.2.1 -> @v0.2.2 (Fix 1 provenance label + Fix 2 duplicate-mapping WARN) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/client-mode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/client-mode.yml b/.github/workflows/client-mode.yml index eedb89b..20093f7 100644 --- a/.github/workflows/client-mode.yml +++ b/.github/workflows/client-mode.yml @@ -37,6 +37,6 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt pytest - pip install "lailara_engagement @ git+https://x-access-token:${LAILARA_ENGAGEMENT_TOKEN}@github.com/MsShawnP/lailara-engagement.git@v0.2.1#subdirectory=lib" + pip install "lailara_engagement @ git+https://x-access-token:${LAILARA_ENGAGEMENT_TOKEN}@github.com/MsShawnP/lailara-engagement.git@v0.2.2#subdirectory=lib" - name: Client-mode suite run: python -m pytest tests/test_client_mode.py -q