lakehouse/scripts/staffing/seed_consent_version.sh
root fcd53168a0 phase 1.6: counsel handoff turnkey + seed_consent_version.sh + strict mode live
The remaining production blocker is counsel-calendar bottleneck
(review + sign-off). Engineering can't make counsel move faster,
but it CAN reduce the round-trip overhead:

(1) docs/counsel/COUNSEL_HANDOFF_EMAIL_2026-05-05.md — copy-paste
    email body J can send to outside counsel. Subject line + body
    + tarball attachment instructions + headline asks (A/B/C/D
    in priority order) + post-signature operator runbook. The
    pre-flight checklist + post-signature workflow turn what
    would have been "I'll figure out the email" into "click send."

(2) scripts/staffing/seed_consent_version.sh — turnkey
    post-signature deployment. Takes the path to a (presumably
    counsel-signed) consent template markdown, computes SHA-256,
    atomically merges into /etc/lakehouse/consent_versions.json
    (creating the file if absent, with per-seed audit metadata
    in _meta.seeded_at[]), restarts lakehouse.service, probes
    /biometric/health post-restart. Idempotent: re-running with
    the same hash is a no-op for the versions array but still
    appends a [reseed] entry to the audit metadata.
    Verified live against the eng-staged template — strict mode
    flipped clean, /biometric/health 200 post-restart.

(3) docs/PHASE_1_6_BIPA_GATES.md §6.5 — post-signature deployment
    runbook embedded in the gates doc. Three steps: counsel signs
    + commits → seed_consent_version.sh → strict-mode probe.
    Plus a "pre-counsel demo seed" subsection documenting how to
    exercise strict mode BEFORE counsel signs (using the
    eng-staged template hash) so the deployment workflow is
    proven before the legal critical path closes.

Strict mode flipped live — verified post-restart:
- /etc/lakehouse/consent_versions.json populated with the
  eng-staged template hash:
  8b09591a8dc15f59197affac48909ce943d575eee01705b42303acf3b32f5c56
- POST /biometric/subject/WORKER-1/consent with deadbeef hash:
  HTTP 400 + error="consent_version_unknown"
- POST with the known eng-staged hash: passes version check
  (then 404 subject_not_found on a ghost candidate, proving
  the gate is hash-aware not auth-broken)

The hash currently seeded is the ENG-STAGED template
(pre-counsel-signature). When counsel returns the signed text,
operator runs `seed_consent_version.sh` again with the
counsel-signed markdown — the new hash gets appended; the demo
hash stays in for backwards-compat with any consent records
collected during the pre-counsel demo period (none, today).

Production blocker is now genuinely just counsel calendar:
1. J transmits reports/counsel/counsel_packet_2026-05-05.tar.gz
   per the handoff email
2. Counsel reviews + signs (their billable time)
3. Counsel returns signed text → operator runs seed script
4. Strict mode flips to canonical hash → cutover complete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:32:16 -05:00

171 lines
6.5 KiB
Bash
Executable File

#!/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 <path-to-consent-template.md> [--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 <path-to-consent-template.md> [--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"'