# Task 1930 — Schema-and-preference adherence enforcement (design)

Date: 2026-07-23
Task: `.tasks/backlog/1930-enforce-schema-and-preference-adherence-scratch-deliverables-and-unconsulted-sends.md`

## Problem

Two adherence failures share one root: a rule the agent follows by judgement, with no gate forcing it.

- **Preferences.** Layer-2 `Preference` nodes (`platform/plugins/memory/mcp/src/tools/profile-read.ts`) are consulted by per-turn judgement. A customer signature preference was missed on a delivered document because nothing forced a `profile-read` before the send.
- **Placement.** `output/` is tool-owned scratch rebuilt each write (`platform/templates/account-schema/SCHEMA.md:29-42`); finished deliverables strand there and graph refs point at those scratch paths. The `data-manager` reconcile audit skips tool-owned dirs, so both are invisible to it.

## Decisions (operator-confirmed)

1. The preference gate fires only on **customer-facing document deliverables**, not every send. Casual channel replies and plain-text emails pass.
2. This sprint ships the gate, the wrapper reminder, and the spec changes. The **periodic scheduling** of the reconcile audit is deferred to Task 1931.
3. The gate hard-blocks (exit 2), matching the user's instruction.

## Architecture

Five units, each single-purpose and independently testable.

### 1. `platform/plugins/admin/hooks/preference-consult-gate.sh` (new, PreToolUse)

Contract mirrors `quote-render-gate.sh`: read the JSON envelope on stdin, exit 0 allow / exit 2 block, fail-open on any uninspectable call (tty, empty stdin, missing `python3`, missing/unreadable transcript).

**Fires only on a customer-facing document deliverable**, decided from `tool_name` + `tool_input`:
- `mcp__plugin_browser_browser__browser-pdf-save` whose output path is under `memory/users/<phone>/documents/`.
- `SendUserFile` whose file path is under `memory/users/<phone>/documents/` or carries a document extension (`.pdf`, `.html`, `.docx`).
- `mcp__plugin_email_email__email-send` / `email-reply` / `email-draft-send` with a non-empty `attachments`.
- `mcp__plugin_outlook_outlook__outlook-mail-send` / `outlook-mail-reply` / `outlook-draft-send` with a non-empty `attachments`.

Any tool or shape not matching the above exits 0 (not a document deliverable).

**Consultation check.** Read the transcript at `transcript_path`. Scan the messages from the last `role:user` entry to the end for an assistant `tool_use` whose name ends in `profile-read`. Found → exit 0. Absent → exit 2.

**Block message** (operator-visible, no task numbers): names that a customer document is about to go out without the account's saved preferences being checked this turn, and directs the agent to run `profile-read` for this account and retry the send. A false block (preferences read in an earlier turn) is a one-step recovery, not a dead end.

**Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block; `op=allow tool=<name> consulted=true` on the allow path for a document deliverable. `browser-pdf-save` already carries `quote-render-gate` on the same matcher; both hooks run and are independent (one on receipt, one on consultation).

### 2. `platform/plugins/admin/hooks/preference-consult-directive.sh` (new, UserPromptSubmit)

Mirrors `datetime-inject.sh`: drain stdin first, fail-open exit 0 on every path, emit `hookSpecificOutput.additionalContext` via python3. The block is standing and prompt-independent. Content: the two-layer preference architecture (layer 1 fixed/injected, layer 2 the account's `Preference` records read via `profile-read`), and two directives: consult the relevant layer-2 preferences before any customer-facing document deliverable, and promote finished deliverables out of `output/` into `documents/` or `projects/`. Breadcrumb line: `[pref-wrapper] op=inject`.

### 3. `platform/templates/account-schema/SCHEMA.md`

In the tool-owned section (`:29-42`), add one sentence: finished deliverables must be promoted out of `output/` into `documents/` or `projects/`; `output/` is scratch of last resort, rebuilt on the next write, and a graph reference must never point into it.

### 4. `platform/templates/specialists/agents/data-manager.md`

Extend "The reconcile audit brief": in addition to (a) files unreachable from any node and (b) refs that resolve to no real path, count (c) graph refs resolving into a scratch dir (`output/`, `generated/`, `extracted/`, `url-get/`) and (d) deliverables referenced by a node but resolving only under scratch. Output contract becomes `unreachable=N broken-refs=M scratch-refs=P stranded=Q`. The audit stays read-only; it moves nothing.

### 5. Registration + docs

- `platform/scripts/lib/provision-account-dir.sh`: add PreToolUse matcher entries routing the document-send tools to `preference-consult-gate.sh`, and add `preference-consult-directive.sh` to the UserPromptSubmit hooks list.
- `platform/plugins/admin/PLUGIN.md`: hook entries for both new hooks (matcher, block condition, block message, log line, fail-open).
- `.docs/` and `platform/plugins/docs/references/`: the sprint-gate documentation targets.

## Registration matcher note

Settings matchers are per-tool. The gate registers against the browser, email, outlook, and SendUserFile matchers; the script itself does the document-deliverable discrimination, so over-matching a non-document send is safe (it exits 0). WhatsApp document delivery routes through the file path the same discrimination covers when a document goes out as a `SendUserFile`; a plain `whatsapp-reply` text is not a document deliverable and is out of scope by decision 1.

## Testing (ephemeral)

- **Gate.** For each document-send tool: envelope whose transcript has a `profile-read` since the last user turn → exit 0; envelope with none → exit 2 with the bypass line. A non-document send (email with no attachments, plain browser-pdf-save outside the customer documents scope) → exit 0. Empty/tty stdin → exit 0.
- **Wrapper.** Emits `hookSpecificOutput.additionalContext` containing both directives; fails open (exit 0) when python3 is absent.
- **Regression.** The 22 `fs-schema-guard` tests and the `quote-render-gate` tests stay green.

## Observability

- **Gate (per send):** `[preference-gate] op=allow|bypass tool=<name>`. Success = `op=allow consulted=true`. Failure = any `op=bypass`.
- **Wrapper:** `[pref-wrapper] op=inject` per turn; absence across turns means the directive is not reaching the agent.
- **Reconcile:** the extended brief emits `unreachable=N broken-refs=M scratch-refs=P stranded=Q`; a rising `scratch-refs`/`stranded` is the placement-drift signal. The standing periodic run that would make this a no-event backstop is Task 1931.

## Out of scope

- Hard write-block on `output/` writes (render tools legitimately use it as scratch).
- Auto-promotion or auto-move of stranded files (stays the `data-manager` paired-move job).
- The periodic scheduler for the reconcile audit (Task 1931).
- Layer-1 fixed-block content, the voice file, and which preferences are stored.
- Quote-specific conformance (1922/1928/1929).
