# Task 1930 — Schema-and-preference adherence enforcement — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Hard-block customer-facing document deliverables that go out without the account's layer-2 preferences being consulted that turn, and make placement drift (`output/` scratch domination) a visible signal.

**Architecture:** Two new admin hooks (a PreToolUse gate on document-deliverable sends, a UserPromptSubmit reminder), plus spec edits to `SCHEMA.md` and the `data-manager` reconcile brief, plus registration in the account-provisioning settings writer. The periodic scheduling of the reconcile audit is deferred to a filed Task 1931.

**Tech Stack:** Bash + python3 hooks (Claude Code hook protocol), markdown specs. No TypeScript, no npm build.

## Global Constraints

- All work under `maxy-code/` (this is a `getmaxy` worktree; the subtree root is `maxy-code/`). Run commands from `maxy-code/`.
- Hook contract: exit 0 allow, exit 2 block (PreToolUse) / exit 0 always (UserPromptSubmit). Fail OPEN on tty, empty stdin, missing `python3`, or missing/unreadable transcript. A broken hook that blocks everything is worse than the miss.
- No task numbers or internal refs in any operator-visible (stderr block / injected) string.
- British English, plain hyphens, no em-dashes in any shipped markdown or block message.
- New hook tests join the existing tree suite at `platform/plugins/admin/hooks/__tests__/` (they are committed, not ephemeral — the project's hook tests live there).
- Every commit carries `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` and a `Session:` trailer.

---

### Task 1: Preference-consult gate hook

**Files:**
- Create: `platform/plugins/admin/hooks/preference-consult-gate.sh`
- Test: `platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh`

**Interfaces:**
- Consumes: the Claude Code PreToolUse JSON envelope on stdin (`{tool_name, tool_input, transcript_path, ...}`).
- Produces: exit 0 (allow) / exit 2 (block). Stderr `[preference-gate] op=allow|bypass tool=<name>`.

- [ ] **Step 1: Write the failing test**

```bash
# platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh
#!/usr/bin/env bash
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$DIR/../preference-consult-gate.sh"
PASS=0; FAIL=0; FAILED=()
run() { printf '%s' "$1" | bash "$HOOK" >/dev/null 2>&1; echo $?; }
assert_exit() { local got; got=$(run "$2"); if [ "$got" = "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: want exit $1 got $got"); fi; }

TMP="$(mktemp -d)"
# Transcript WITH a profile-read after the last user message.
CONSULTED="$TMP/consulted.jsonl"
{
  printf '%s\n' '{"type":"user","message":{"role":"user","content":"make the quote"}}'
  printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
} > "$CONSULTED"
# Transcript with NO profile-read this turn (one appears BEFORE the last user msg only).
UNCONSULTED="$TMP/unconsulted.jsonl"
{
  printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
  printf '%s\n' '{"type":"user","message":{"role":"user","content":"send it"}}'
  printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}'
} > "$UNCONSULTED"

pdf_env() { printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"memory/users/+447700900000/documents/quote.pdf"},"transcript_path":"%s"}' "$1"; }
email_att_env() { printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{"attachments":["memory/users/+44/documents/x.pdf"]},"transcript_path":"%s"}' "$1"; }

# Gated tool + consulted → allow.
assert_exit 0 "$(pdf_env "$CONSULTED")" "pdf consulted allows"
# Gated tool + not consulted → block.
assert_exit 2 "$(pdf_env "$UNCONSULTED")" "pdf unconsulted blocks"
# Email with attachment + not consulted → block.
assert_exit 2 "$(email_att_env "$UNCONSULTED")" "email+attach unconsulted blocks"
# Email with NO attachment → not a document deliverable → allow.
assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{},"transcript_path":"%s"}' "$UNCONSULTED")" "email no-attach allows"
# browser-pdf-save OUTSIDE customer documents scope → allow.
assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"output/report.pdf"},"transcript_path":"%s"}' "$UNCONSULTED")" "pdf outside scope allows"
# Non-send tool → allow.
assert_exit 0 "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"},"transcript_path":"%s"}' "$UNCONSULTED")" "read allows"
# Empty stdin → fail open (allow).
assert_exit 0 "" "empty stdin fails open"

echo "----- $PASS passed, $FAIL failed -----"
for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo "  $f"; done
[ "$FAIL" -eq 0 ]
```

- [ ] **Step 2: Run it, verify it fails** — `bash platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh` — Expected: FAIL (hook does not exist).

- [ ] **Step 3: Write the hook**

```bash
#!/usr/bin/env bash
# preference-consult-gate — PreToolUse gate on customer-facing document deliverables.
#
# A customer document (a PDF/file to the customer documents scope, or an email/
# Outlook send carrying an attachment) must not go out until the account's saved
# layer-2 preferences were consulted this turn, proven by a profile-read tool
# call after the last user message in the session transcript. Layer-2 records
# hold the signature policy, header, naming and styling rules the fixed layer-1
# block does not; a send with no profile-read this turn is the miss that dropped
# a customer signature.
#
# Scope (document-deliverable shape only; every other tool/shape exits 0):
#   *browser-pdf-save  path under memory/users/<phone>/documents/
#   SendUserFile       path under memory/users/<phone>/documents/ or .pdf/.html/.docx
#   *email-send|reply|draft-send / *outlook-mail-send|reply|draft-send  non-empty attachments
#
# Exit: 0 allow, 2 block. Fail OPEN on tty/empty stdin/no python3/transcript
# unreadable. Block log: [preference-gate] op=bypass tool=<name> detail=no profile-read this turn
set -uo pipefail

if [ -t 0 ]; then exit 0; fi
INPUT=$(cat)
[ -z "$INPUT" ] && exit 0
command -v python3 >/dev/null 2>&1 || exit 0

DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
import os, json, re, sys
try:
    d = json.loads(os.environ["INPUT"])
except Exception:
    sys.exit(0)
tool = d.get("tool_name", "") or ""
ti = d.get("tool_input", {}) or {}

def under_customer_docs(p):
    return bool(re.search(r'(^|/)memory/users/[^/]+/documents/', os.path.normpath(p or "")))

gated = False
if tool.endswith("browser-pdf-save"):
    gated = under_customer_docs(ti.get("path", ""))
elif tool == "SendUserFile" or tool.endswith("__SendUserFile"):
    p = ti.get("path", "") or ti.get("file_path", "")
    gated = under_customer_docs(p) or p.lower().endswith((".pdf", ".html", ".docx"))
elif re.search(r'(email-send|email-reply|email-draft-send|outlook-mail-send|outlook-mail-reply|outlook-draft-send)$', tool):
    atts = ti.get("attachments")
    gated = isinstance(atts, list) and len(atts) > 0

if not gated:
    print("")  # not a document deliverable
    sys.exit(0)

transcript = d.get("transcript_path", "") or ""
if not transcript or not os.access(transcript, os.R_OK):
    print("")  # cannot inspect consultation → fail open
    sys.exit(0)

try:
    with open(transcript) as f:
        lines = f.readlines()
except Exception:
    print("")
    sys.exit(0)

last_user = -1
for i, ln in enumerate(lines):
    try:
        e = json.loads(ln)
    except Exception:
        continue
    msg = e.get("message", e)
    role = e.get("role") or (msg.get("role") if isinstance(msg, dict) else None)
    if role == "user" or e.get("type") == "user":
        last_user = i

consulted = False
for ln in (lines[last_user + 1:] if last_user >= 0 else lines):
    try:
        e = json.loads(ln)
    except Exception:
        continue
    msg = e.get("message", e)
    content = msg.get("content") if isinstance(msg, dict) else None
    if isinstance(content, list):
        for block in content:
            if isinstance(block, dict) and block.get("type") == "tool_use" and (block.get("name", "") or "").endswith("profile-read"):
                consulted = True

print("%s|%s" % (tool, "yes" if consulted else "no"))
PY
)

[ -z "$DECISION" ] && exit 0
TOOL="${DECISION%%|*}"
CONSULTED="${DECISION##*|}"

if [ "$CONSULTED" = "yes" ]; then
  echo "[preference-gate] op=allow tool=$TOOL consulted=true" >&2
  exit 0
fi

echo "[preference-gate] op=bypass tool=$TOOL detail=no profile-read this turn" >&2
echo "Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again." >&2
exit 2
```

- [ ] **Step 4: Make it executable and run the test** — `chmod +x platform/plugins/admin/hooks/preference-consult-gate.sh && bash platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh` — Expected: `7 passed, 0 failed`.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/admin/hooks/preference-consult-gate.sh platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh
git commit -m "feat: preference-consult gate blocks customer documents sent without a profile-read this turn"
```

---

### Task 2: Preference-consult wrapper injector

**Files:**
- Create: `platform/plugins/admin/hooks/preference-consult-directive.sh`
- Test: `platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh`

**Interfaces:**
- Consumes: UserPromptSubmit envelope on stdin (ignored; block is prompt-independent).
- Produces: stdout JSON `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"<preference-adherence>...</preference-adherence>"}}`; always exit 0.

- [ ] **Step 1: Write the failing test**

```bash
#!/usr/bin/env bash
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$DIR/../preference-consult-directive.sh"
PASS=0; FAIL=0; FAILED=()
OUT=$(printf '{"session_id":"x","prompt":"hi"}' | bash "$HOOK" 2>/dev/null); RC=$?
check() { if eval "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
check '[ "$RC" = "0" ]' "exit 0"
check 'printf "%s" "$OUT" | grep -q "hookSpecificOutput"' "emits envelope"
check 'printf "%s" "$OUT" | grep -q "profile-read"' "names profile-read"
check 'printf "%s" "$OUT" | grep -q "output/"' "names output scratch rule"
check 'printf "%s" "$OUT" | grep -qv "—"' "no em-dash"
echo "----- $PASS passed, $FAIL failed -----"
for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo "  $f"; done
[ "$FAIL" -eq 0 ]
```

- [ ] **Step 2: Run it, verify it fails** — Expected: FAIL (hook absent).

- [ ] **Step 3: Write the hook**

```bash
#!/usr/bin/env bash
# preference-consult-directive — UserPromptSubmit hook. Injects a standing line
# naming the two-layer preference architecture and two adherence directives:
# consult layer-2 preferences (profile-read) before any customer document, and
# promote finished deliverables out of output/. Prompt-independent; fail-open.
set -uo pipefail
HOOK_INPUT=$(cat 2>/dev/null || true)
command -v python3 >/dev/null 2>&1 || exit 0
python3 - <<'PY' 2>/dev/null || exit 0
import json
ctx = (
"<preference-adherence>\n"
"Your preferences live in two layers. Layer 1 is the fixed block already in front of you every turn. "
"Layer 2 is the account's own saved records, read with profile-read; it holds the signature policy, header, "
"naming conventions and styling that layer 1 does not, and it grows over time. Before any customer-facing "
"document deliverable (a PDF or file sent to the customer, or an email or Outlook message carrying an "
"attachment), read the relevant layer-2 preferences with profile-read and apply them. "
"Store finished deliverables under documents/ or the project folder. output/ is scratch of last resort, "
"rebuilt on the next tool write, and never the home a graph reference points at.\n"
"</preference-adherence>"
)
print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ctx}}))
PY
echo "[pref-wrapper] op=inject" >&2
exit 0
```

- [ ] **Step 4: Run the test** — `bash platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh` — Expected: `5 passed, 0 failed`.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/admin/hooks/preference-consult-directive.sh platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh
git commit -m "feat: preference-consult UserPromptSubmit directive names the two-layer architecture and output-scratch rule"
```

---

### Task 3: Register both hooks in account provisioning

**Files:**
- Modify: `platform/scripts/lib/provision-account-dir.sh` (the `SETTINGS_EOF` hooks block, around lines 63-178)

**Interfaces:**
- Consumes: the hook scripts from Tasks 1-2 at `$HOOKS_PATH`.
- Produces: a `.claude/settings.json` with PreToolUse matchers for the document-send tools routed to `preference-consult-gate.sh`, and `preference-consult-directive.sh` in the UserPromptSubmit list.

- [ ] **Step 1: Write the failing test** (asserts the rendered settings block references both hooks)

```bash
#!/usr/bin/env bash
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$DIR/../lib/provision-account-dir.sh"
PASS=0; FAIL=0; FAILED=()
c(){ if grep -qF "$1" "$LIB"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
c 'preference-consult-gate.sh' "gate registered"
c 'preference-consult-directive.sh' "directive registered"
c 'mcp__plugin_email_email__email-send' "email matcher present"
c 'mcp__plugin_outlook_outlook__outlook-mail-send' "outlook matcher present"
echo "----- $PASS passed, $FAIL failed -----"
for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo "  $f"; done
[ "$FAIL" -eq 0 ]
```

Save as `platform/scripts/__tests__/preference-hooks-registered.test.sh`.

- [ ] **Step 2: Run it, verify it fails** — Expected: FAIL (strings absent).

- [ ] **Step 3: Edit the settings block.** In the `"PreToolUse"` array, after the `browser-pdf-save` → `quote-render-gate.sh` entry, add matcher entries (one per document-send tool) routed to `preference-consult-gate.sh`, plus a second `browser-pdf-save` hook entry so both gates run on it. Exact JSON to insert inside `"PreToolUse": [ ... ]`:

```json
      {
        "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
        "hooks": [
          { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
        ]
      },
      {
        "matcher": "mcp__plugin_email_email__email-send|mcp__plugin_email_email__email-reply|mcp__plugin_email_email__email-draft-send|mcp__plugin_outlook_outlook__outlook-mail-send|mcp__plugin_outlook_outlook__outlook-mail-reply|mcp__plugin_outlook_outlook__outlook-draft-send|SendUserFile",
        "hooks": [
          { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
        ]
      }
```

In the `"UserPromptSubmit"` array's single `hooks` list, append after `datetime-inject.sh`:

```json
          ,{ "type": "command", "command": "bash $HOOKS_PATH/preference-consult-directive.sh" }
```

- [ ] **Step 4: Run the test + JSON validity check.**

Run: `bash platform/scripts/__tests__/preference-hooks-registered.test.sh` — Expected: `4 passed, 0 failed`.
Then confirm the emitted settings JSON parses. Extract the heredoc body with the placeholders stubbed and pipe through `python3 -m json.tool`:

```bash
HOOKS_PATH=/x PLATFORM_ROOT=/x ACCOUNT_DIR=/x \
python3 - <<'PY'
import re, os, json
s = open("platform/scripts/lib/provision-account-dir.sh").read()
body = s.split("SETTINGS_EOF", 2)[1] if "SETTINGS_EOF" in s else ""
body = re.sub(r'\$\{?[A-Z_]+\}?', '/x', body)
json.loads(body); print("settings JSON valid")
PY
```
Expected: `settings JSON valid`.

- [ ] **Step 5: Commit**

```bash
git add platform/scripts/lib/provision-account-dir.sh platform/scripts/__tests__/preference-hooks-registered.test.sh
git commit -m "feat: register preference-consult gate and directive in account provisioning settings"
```

---

### Task 4: Spec edits — SCHEMA.md and data-manager reconcile brief

**Files:**
- Modify: `platform/templates/account-schema/SCHEMA.md` (tool-owned section, lines 29-42)
- Modify: `platform/templates/specialists/agents/data-manager.md` (reconcile audit brief + output contract)

**Interfaces:** documentation only; consumed by the agent and the `data-manager` specialist at runtime.

- [ ] **Step 1: Edit `SCHEMA.md`.** In the tool-owned paragraph, after the sentence listing `output/` as recreated on the next write, add:

> Finished deliverables must be promoted out of `output/` into `documents/` or `projects/<name>/` through the `data-manager` specialist; `output/` is scratch of last resort, and a graph reference must never point into it (the next tool write rebuilds the tree and the reference dangles).

- [ ] **Step 2: Edit `data-manager.md`.** In "The reconcile audit brief" paragraph, extend the counted set: after "(b) graph file-references that do not resolve to a real path", add "(c) graph file-references that resolve into a tool-owned scratch dir (`output/`, `generated/`, `extracted/`, `url-get/`), which the next tool write rebuilds so the reference is fragile by construction, and (d) deliverables a node references that resolve only under a scratch dir and were never promoted to `documents/` or `projects/`." In the "Output contract" paragraph, change the reconcile line to: `unreachable=N broken-refs=M scratch-refs=P stranded=Q`.

- [ ] **Step 3: Verify the edits landed**

```bash
grep -q "scratch of last resort" platform/templates/account-schema/SCHEMA.md && \
grep -q "scratch-refs=P stranded=Q" platform/templates/specialists/agents/data-manager.md && echo "spec edits present"
```
Expected: `spec edits present`.

- [ ] **Step 4: Commit**

```bash
git add platform/templates/account-schema/SCHEMA.md platform/templates/specialists/agents/data-manager.md
git commit -m "docs: SCHEMA promote-out-of-output rule and data-manager reconcile counts scratch-refs and stranded deliverables"
```

---

### Task 5: Plugin and reference docs + file deferred Task 1931

**Files:**
- Modify: `platform/plugins/admin/PLUGIN.md` (hooks section — add both hooks)
- Modify: `.docs/` (the relevant enforcement/hooks doc) and `platform/plugins/docs/references/` (the shipped guide) as the sprint doc-gate requires
- Create: `.tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md`
- Modify: `.tasks/LANES.md` (Ready row for 1931)

- [ ] **Step 1: PLUGIN.md.** In the hooks list (near the `quote-render-gate` entry ~line 190), add an entry for `preference-consult-gate.sh` (PreToolUse matcher set = the document-send tools; block condition = document deliverable with no `profile-read` after the last user message; block message and `[preference-gate] op=bypass` log line; fail-open cases) and for `preference-consult-directive.sh` (UserPromptSubmit; injects the two-layer directive; `[pref-wrapper] op=inject`).

- [ ] **Step 2: `.docs/` + references.** Add the two hooks to whichever `.docs/` file documents the admin hook surface and to the shipped `platform/plugins/docs/references/` guide that lists enforcement behaviour. Locate them by grep: `grep -rl "fs-schema-guard\|quote-render-gate" .docs platform/plugins/docs/references`.

- [ ] **Step 3: File Task 1931.** Create `.tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md`: problem (the extended reconcile counts scratch-refs and stranded deliverables but only runs on ad-hoc `data-manager` dispatch, so the no-event placement drift is not caught on a standing basis); success (the audit runs on a periodic cadence per account and emits `[reconcile] op=run unreachable=… broken-refs=… scratch-refs=… stranded=…`, with absence-of-run itself a detectable failure); scope in (a scheduler mechanism for the read-only audit, deterministic where possible); scope out (the counts themselves — landed in 1930; auto-promotion). Add its Ready row to `.tasks/LANES.md`.

- [ ] **Step 4: Verify** — `test -f .tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md && grep -q "1931" .tasks/LANES.md && grep -q "preference-consult-gate" platform/plugins/admin/PLUGIN.md && echo "docs + task filed"` — Expected: `docs + task filed`.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/admin/PLUGIN.md .docs platform/plugins/docs/references .tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md .tasks/LANES.md
git commit -m "docs: document preference-consult hooks; file Task 1931 for standing reconcile cadence"
```

---

## Verification (Phase 4)

- All hook tests pass: `for t in platform/plugins/admin/hooks/__tests__/*.test.sh platform/scripts/__tests__/preference-hooks-registered.test.sh; do bash "$t" | tail -1; done`. The 22 `fs-schema-guard` tests and `quote-render-gate` tests stay green.
- Settings JSON parses (Task 3 Step 4).
- **Pi signal verification (mandatory — this sprint touches hook input handling).** After deploy, trigger a customer-document send in admin chat without a `profile-read` that turn and confirm the block; read the transcript JSONL structure on the Pi and confirm the `role:user` boundary and `tool_use` `name` fields match what the test pipes. If the live envelope's `tool_input` field names diverge (e.g. `attachments` vs another key, or the transcript entry shape), fix the hook and the test before proceeding.

## Self-Review

- **Spec coverage:** gate (Task 1), wrapper (Task 2), registration (Task 3), SCHEMA + data-manager (Task 4), PLUGIN/docs + deferred 1931 (Task 5). All five spec units mapped.
- **Placeholder scan:** none; every hook and test carries full code.
- **Type consistency:** the gate emits `op=allow`/`op=bypass` and the wrapper `op=inject` consistently across plan, spec, and PLUGIN entry. The reconcile contract `unreachable=N broken-refs=M scratch-refs=P stranded=Q` is identical in Task 4 and the spec.
