diff --git a/docs/PHASE_1_6_BIPA_GATES.md b/docs/PHASE_1_6_BIPA_GATES.md index 5fd2727..27c7167 100644 --- a/docs/PHASE_1_6_BIPA_GATES.md +++ b/docs/PHASE_1_6_BIPA_GATES.md @@ -256,6 +256,69 @@ of the design doc can be picked up then under a v2 consent template. --- +## 6.5. Post-signature deployment runbook + +When counsel returns the countersigned consent template + retention +schedule, the engineering side of "flip from permissive to strict +mode" is one command: + +```bash +# 1. Counsel commits their signature to §7 of the consent template +# markdown (or J commits the signed PDF + updates §7 with counsel's +# name + date). The markdown is the BINDING TEXT — the PDF is just +# a rendering of it. + +# 2. Hash the canonical signed text + seed the gateway allowlist. +./scripts/staffing/seed_consent_version.sh \ + docs/policies/consent/biometric_consent_template_v1.md \ + --label "v1 signed YYYY-MM-DD by [counsel name]" + +# The script: +# - computes SHA-256 of the markdown (binding text) +# - atomically writes /etc/lakehouse/consent_versions.json with +# the new hash + per-seed audit metadata (timestamp, label, +# source path) +# - restarts lakehouse.service so the gateway re-reads the +# allowlist +# - probes /biometric/health for clean restart + +# 3. Verify strict mode is rejecting unknown hashes: +TOKEN=$(cat /etc/lakehouse/legal_audit.token) +curl -sS -X POST http://localhost:3100/biometric/subject/WORKER-1/consent \ + -H "X-Lakehouse-Legal-Token: $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"consent_version_hash":"deadbeefdeadbeef000000000000000000000000000000000000000000000000","consent_collection_method":"electronic_signature","operator_of_record":"strict_mode_probe"}' +# Expect: HTTP 400 + {"error":"consent_version_unknown", ...} +``` + +After this, the gateway is in counsel-tier strict mode: + +- Any consent grant POST whose `consent_version_hash` doesn't match + a known signed template is refused at intake +- Operator typos (mistyped hash) become loud failures, not silent + bad records +- Future template revisions (v2, v3, ...) require counsel re-sign + AND a new `seed_consent_version.sh` run before being accepted — + the v1 hash stays in the allowlist for already-collected subjects' + audit-trail compatibility + +### Pre-counsel demo seed + +For deployments that want to exercise strict mode BEFORE counsel +signs, the same script works against the eng-staged template: + +```bash +./scripts/staffing/seed_consent_version.sh \ + docs/policies/consent/biometric_consent_template_v1.md \ + --label "eng-staged demo seed (NOT counsel-signed)" +``` + +The hash entry should be replaced (rotate the demo hash out, add +the counsel-signed hash) when counsel completes review. The +allowlist's `_meta.seeded_at[]` array preserves the seed history. + +--- + ## 7. What this PRD is NOT - Not legal advice. The `⚖ COUNSEL` markers exist because the binding text needs lawyers, not engineers. diff --git a/docs/counsel/COUNSEL_HANDOFF_EMAIL_2026-05-05.md b/docs/counsel/COUNSEL_HANDOFF_EMAIL_2026-05-05.md new file mode 100644 index 0000000..1358e8f --- /dev/null +++ b/docs/counsel/COUNSEL_HANDOFF_EMAIL_2026-05-05.md @@ -0,0 +1,187 @@ +# Counsel Handoff — Copy-Paste Email Draft + +**Date assembled:** 2026-05-05 +**Operator of record:** J +**Prepared for:** outbound transmission to outside counsel +**Tarball:** `reports/counsel/counsel_packet_2026-05-05.tar.gz` +**Tarball SHA-256:** see `reports/counsel/counsel_packet_2026-05-05.manifest.txt` + +> This is a copy-paste-ready email draft. Subject line + body below. +> Attach the tarball + manifest before sending. The email frames +> what counsel needs to do, by when, and what happens after they +> sign — so the calendar bottleneck has no excuse to sit longer +> than counsel's actual review time. + +--- + +## Subject line + +``` +Lakehouse staffing platform — Phase 1.6 BIPA pre-launch package, ready for legal review +``` + +## Email body + +``` +Hi [Counsel name], + +Engineering on the Lakehouse staffing platform has finished the +Phase 1.6 BIPA (740 ILCS 14) pre-launch substrate. Before we begin +collecting any real candidate biometric data (photographs), the +five engineering-staged documents in the attached package need +your review and, where indicated, your signature. + +What's attached: + +1. counsel_packet_2026-05-05.tar.gz — bundled review package +2. counsel_packet_2026-05-05.manifest.txt — per-file SHA-256 + integrity hashes (re-run `sha256sum -c` on receipt to verify + nothing changed in transit) + +Open the cover letter inside the tarball +(`docs/counsel/COUNSEL_REVIEW_PACKET_2026-05-05.md`) first — it +walks through the substrate, the specific items needing your +sign-off, and recommended review sequence. + +Headline asks (in priority order): + + A. Biometric Retention Schedule v1 + — render into binding language, sign as countersigning + party. Sets the public retention schedule required by + BIPA §15(a). + B. Biometric Consent Template v1 + — render Disclosures 1-3 into binding consent text, sign. + This is the form a candidate signs before any photo is + collected. Required by BIPA §15(b)(1)-(3). + C. BIPA Destruction Runbook + — review for legal sufficiency, attest. Procedural + document; engineering wrote the steps, your role is + confirming they satisfy BIPA §15(a) destruction. + D. Pre-IdentityD Attestation (2026-05-03) + — countersign as the legal party. Establishes the boundary + that no biometric data was collected before the gates + shipped. One-time defense artifact. + +Item C also references a key-rotation runbook (E in the cover +letter) that's lower priority — opine when convenient. + +What happens after you sign: + + - Once we have your countersignatures on A, B, and D, our + operator deploys them to the public privacy policy + the + intake UI. Engineering will hash the canonical signed text + of (B) and add that hash to the gateway's consent_versions + allowlist file (/etc/lakehouse/consent_versions.json) so the + runtime starts refusing any consent record that wasn't + signed under the canonical template. (The gateway is + currently in permissive mode — accepting any non-empty + hash. Strict mode flips on with your signature.) + - The runtime gates are already enforcing the consent flow: + photo upload refuses 403 unless consent.biometric.status = + "given", and the only path to that state is the + /biometric/subject/{id}/consent endpoint with a valid + consent_version_hash. After your signature, that hash will + point at canonical signed text — operator typo-resistant + and counsel-defensible. + +Calendar: + + - We'd like to begin real-photo intake within [N] weeks. If + your review can land within 2-3 weeks of receipt, that + keeps us on schedule. If your bandwidth requires longer, + please flag — we have eng work to advance independently + that doesn't depend on the signatures. + +Open questions for you (sub-questions detailed per-doc inside +the cover letter): + - Confirm 18-month operational retention ceiling vs BIPA's + 3-year statutory cap (we picked 18mo for safety margin) + - Confirm 30-day SLA for destruction following withdrawal + (some interpretations prefer 7 or 14) + - Specify the public privacy policy URL where the retention + schedule will be published + - Specify the candidate-facing contact channel for withdrawal + requests (we have an endpoint; you specify the comms surface) + +Background context (not asks): the engineering substrate is +verified end-to-end. We've run a live demo of the full lifecycle +(consent → photo → withdraw) on a test subject; the audit chain +verifies cryptographically; the retention sweep is scheduled +daily. No real candidate photos have been collected. The +attached pre-identityd attestation has the cryptographic evidence +hashes from that pre-collection state. + +Happy to schedule a call if any of this would be easier to walk +through verbally. + +Thanks, +J +[contact info] +``` + +--- + +## Operator pre-flight checklist (before sending) + +Run through this once before clicking send: + +- [ ] Confirm tarball + manifest are the latest versions + (`bundle_counsel_packet.sh` regenerates if needed; current + hash is in the manifest sidecar) +- [ ] Confirm counsel name is correct (and that the engagement + letter or retainer is in place) +- [ ] Confirm the [N] weeks calendar target — replace with a + real number ("3 weeks" / "by end of June" / etc.) +- [ ] Confirm the email goes to counsel's secure channel; if + counsel uses a portal, upload there + send a notification + email instead +- [ ] Save a copy of the sent email + the tarball to operator + records — this is itself part of the audit trail of "we + asked counsel on [date]" + +--- + +## Post-signature operator runbook (after counsel responds) + +When counsel returns the signed documents: + +1. **Verify integrity.** Run `sha256sum -c manifest.txt` on + the returned tarball if counsel returns the bundle; otherwise + compare returned signed PDFs against the markdown sources to + confirm the binding text matches what counsel reviewed. + +2. **Capture the canonical signed text.** Counsel may sign a + PDF rendering of the markdown — that's fine, but the BINDING + TEXT we hash is the markdown source (counsel commits to git + countersigning §7 of the consent template + §8 of the + retention schedule). + +3. **Compute + seed the consent_version_hash.** Run: + ```bash + ./scripts/staffing/seed_consent_version.sh \ + docs/policies/consent/biometric_consent_template_v1.md + ``` + This computes SHA-256 of the markdown, atomically merges it + into `/etc/lakehouse/consent_versions.json` (creating the + file if absent), and offers to restart the gateway. + +4. **Verify strict mode is live.** Probe with a known-bad hash: + ```bash + TOKEN=$(cat /etc/lakehouse/legal_audit.token) + curl -sS -X POST http://localhost:3100/biometric/subject/WORKER-1/consent \ + -H "X-Lakehouse-Legal-Token: $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"consent_version_hash":"deadbeef","consent_collection_method":"electronic_signature","operator_of_record":"smoke"}' | jq + ``` + Expect HTTP 400 with `error: "consent_version_unknown"`. + +5. **Update STATE_OF_PLAY.md** to record the signature event + + the hash entry in the allowlist. Counsel-tier deployment is + now live. + +6. **Sign and anchor the §2 attestation.** Both J and counsel + sign `docs/attestations/BIPA_PRE_IDENTITYD_ATTESTATION_2026-05-03.md` + and the SHA-256 of the signed markdown is committed to the + git history as a tamper-evident anchor. + +After step 5, the production cutover blocker is closed. diff --git a/scripts/staffing/seed_consent_version.sh b/scripts/staffing/seed_consent_version.sh new file mode 100755 index 0000000..b509be6 --- /dev/null +++ b/scripts/staffing/seed_consent_version.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# seed_consent_version — hash a consent template + add to allowlist. +# +# Specification: docs/PHASE_1_6_BIPA_GATES.md (consent_versions +# allowlist) + docs/policies/consent/biometric_consent_template_v1.md +# +# Why this exists: when counsel returns a signed consent template, +# the operator needs to flip the gateway from permissive mode +# (any non-empty hash accepted, v1 compat) to strict mode (only +# accepted hashes). That requires: +# 1. Compute SHA-256 of the binding text (the markdown source) +# 2. Atomically write it into /etc/lakehouse/consent_versions.json +# 3. Restart the gateway so the in-memory allowlist re-reads +# +# This script does all three. Idempotent: re-running with the same +# template is a no-op (the hash is already in the allowlist). +# +# Usage: +# seed_consent_version.sh [--no-restart] [--label "v1 signed 2026-06-15 by Counsel-Name"] +# +# Environment: +# ALLOWLIST_FILE — default /etc/lakehouse/consent_versions.json +# GATEWAY_SERVICE — default lakehouse.service (used for restart) +# +# Exit codes: +# 0 — hash seeded; allowlist now contains it (whether new or already present) +# 1 — file present but allowlist write/restart failed +# 2 — script error (missing tools, missing input, bad path) + +set -uo pipefail + +if [ "$#" -lt 1 ]; then + cat >&2 <<'USAGE' +usage: seed_consent_version.sh [--no-restart] [--label "free-text"] + + Computes SHA-256 of the markdown file and merges it into the + gateway's consent_versions allowlist. By default also restarts + the gateway so the new entry takes effect immediately. +USAGE + exit 2 +fi + +TEMPLATE_PATH="$1" +shift +DO_RESTART=1 +LABEL="" +while [ "$#" -gt 0 ]; do + case "$1" in + --no-restart) DO_RESTART=0; shift ;; + --label) LABEL="$2"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "[seed] unknown flag: $1" >&2; exit 2 ;; + esac +done + +ALLOWLIST_FILE="${ALLOWLIST_FILE:-/etc/lakehouse/consent_versions.json}" +GATEWAY_SERVICE="${GATEWAY_SERVICE:-lakehouse.service}" + +for cmd in sha256sum jq install; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[seed] FAIL: required tool '$cmd' not found in PATH" >&2 + exit 2 + fi +done + +if [ ! -r "$TEMPLATE_PATH" ]; then + echo "[seed] FAIL: cannot read $TEMPLATE_PATH" >&2 + exit 2 +fi + +HASH=$(sha256sum "$TEMPLATE_PATH" | awk '{print $1}') +SIZE=$(stat -c '%s' "$TEMPLATE_PATH") +ABS=$(readlink -f "$TEMPLATE_PATH") +NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) +[ -n "$LABEL" ] || LABEL="auto-seeded by seed_consent_version.sh from $ABS" + +echo "[seed] template: $ABS" +echo "[seed] size: $SIZE bytes" +echo "[seed] sha256: $HASH" +echo "[seed] allowlist: $ALLOWLIST_FILE" +echo "[seed] label: $LABEL" +echo "[seed] timestamp: $NOW" + +# Build the new allowlist contents. Two cases: +# (a) file absent → create with single entry + audit metadata +# (b) file present → parse, check if hash already exists, add if not +TMP_OUT=$(mktemp) +trap 'rm -f "$TMP_OUT"' EXIT + +if [ ! -e "$ALLOWLIST_FILE" ]; then + echo "[seed] allowlist file absent — creating fresh" + jq -n --arg hash "$HASH" --arg label "$LABEL" --arg ts "$NOW" --arg src "$ABS" ' + { + "_doc": "docs/PHASE_1_6_BIPA_GATES.md consent_versions allowlist. Strict-mode gate for /biometric/subject/{id}/consent. Hashes are SHA-256 of the binding consent template markdown.", + versions: [$hash], + _meta: { + seeded_at: [{ ts: $ts, hash: $hash, label: $label, source_path: $src }] + } + } + ' > "$TMP_OUT" +elif [ ! -r "$ALLOWLIST_FILE" ]; then + echo "[seed] FAIL: $ALLOWLIST_FILE exists but is not readable" >&2 + exit 1 +else + if ! jq empty "$ALLOWLIST_FILE" 2>/dev/null; then + echo "[seed] FAIL: $ALLOWLIST_FILE is not valid JSON" >&2 + exit 1 + fi + ALREADY=$(jq -r --arg h "$HASH" '(.versions // []) | index($h) | tostring' "$ALLOWLIST_FILE") + if [ "$ALREADY" != "null" ]; then + echo "[seed] hash already in allowlist (idempotent no-op for versions array)" + # Still bump the metadata so the audit trail captures the re-attempt. + jq --arg hash "$HASH" --arg label "$LABEL" --arg ts "$NOW" --arg src "$ABS" ' + .versions = (.versions // []) | + ._meta = ((._meta // {}) + | .seeded_at = ((.seeded_at // []) + [{ ts: $ts, hash: $hash, label: ($label + " [reseed]"), source_path: $src }])) + ' "$ALLOWLIST_FILE" > "$TMP_OUT" + else + echo "[seed] adding hash to existing allowlist (now $(($(jq -r '(.versions // []) | length' "$ALLOWLIST_FILE") + 1)) entries)" + jq --arg hash "$HASH" --arg label "$LABEL" --arg ts "$NOW" --arg src "$ABS" ' + .versions = ((.versions // []) + [$hash]) | + ._meta = ((._meta // {}) + | .seeded_at = ((.seeded_at // []) + [{ ts: $ts, hash: $hash, label: $label, source_path: $src }])) + ' "$ALLOWLIST_FILE" > "$TMP_OUT" + fi +fi + +# Atomic move so a crashed write can't leave a half-file. +PARENT=$(dirname "$ALLOWLIST_FILE") +if [ ! -d "$PARENT" ]; then + echo "[seed] FAIL: parent dir $PARENT does not exist (run as root after sudo mkdir -p)" >&2 + exit 1 +fi +if ! sudo install -m 0644 -o root -g root "$TMP_OUT" "$ALLOWLIST_FILE"; then + echo "[seed] FAIL: install to $ALLOWLIST_FILE failed (run with sudo?)" >&2 + exit 1 +fi + +echo "[seed] allowlist written to $ALLOWLIST_FILE" +echo "[seed] current allowlist contents:" +sudo cat "$ALLOWLIST_FILE" | jq '{ versions, latest_seed: ._meta.seeded_at[-1] }' + +# Restart unless --no-restart. +if [ "$DO_RESTART" = "1" ]; then + echo + echo "[seed] restarting $GATEWAY_SERVICE so the new allowlist loads…" + if ! sudo systemctl restart "$GATEWAY_SERVICE"; then + echo "[seed] FAIL: systemctl restart $GATEWAY_SERVICE failed" >&2 + exit 1 + fi + sleep 2 + if ! curl -sf http://localhost:3100/biometric/health >/dev/null; then + echo "[seed] WARN: /biometric/health not responding 200 after restart" >&2 + else + echo "[seed] gateway healthy post-restart. allowlist live." + fi +else + echo "[seed] --no-restart set; gateway must be restarted manually for the change to load." +fi + +echo +echo "[seed] To verify strict mode is now active, probe with a known-bad hash:" +echo +echo ' TOKEN=$(cat /etc/lakehouse/legal_audit.token)' +echo ' curl -sS -X POST http://localhost:3100/biometric/subject/WORKER-1/consent \' +echo ' -H "X-Lakehouse-Legal-Token: $TOKEN" \' +echo ' -H "Content-Type: application/json" \' +echo " -d '{\"consent_version_hash\":\"deadbeef\",\"consent_collection_method\":\"electronic_signature\",\"operator_of_record\":\"smoke\"}' | jq" +echo +echo ' Expected: HTTP 400 + error="consent_version_unknown"'