diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index cc93e8b..0f86d7d 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -5,6 +5,7 @@
# - YAML syntax errors in /schemas/
# - broken internal links across onboarding/forwardables/repos
# - aux-audit failing its own test suite, or drifting from the canonical schemas
+# - a taxonomy gap with no pattern, or a pattern missing one of its four files
#
# Local repro:
# yamllint schemas/ .github/
@@ -22,6 +23,7 @@ on:
- "profile/**"
- "docs/**"
- "packages/**"
+ - "repos/agentic-ux-patterns/**"
- "action.yml"
- "*.md"
- ".github/workflows/**"
@@ -110,3 +112,21 @@ jobs:
if node dist/cli.js run fixtures/invalid-spec.yaml > /dev/null 2>&1; then
echo "expected an invalid spec to be refused"; exit 1
fi
+
+ patterns:
+ name: pattern coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - run: pip install --quiet pyyaml
+
+ # CONTRIBUTING says a pattern PR is rejected if any of the four files are
+ # missing, and the patterns README claims every named gap is closed.
+ # Both are executed here rather than trusted.
+ - name: Every gap closed, every pattern complete, every example runnable
+ run: python repos/agentic-ux-patterns/check-coverage.py
diff --git a/repos/agentic-ux-patterns/README.md b/repos/agentic-ux-patterns/README.md
index 6b4ee04..7f4abbd 100644
--- a/repos/agentic-ux-patterns/README.md
+++ b/repos/agentic-ux-patterns/README.md
@@ -9,21 +9,35 @@ This is a **Reference** repo. Every pattern here:
## Patterns
-| Slug | Closes | Heuristic |
-|---|---|---|
-| **[intent-handshake](./patterns/intent-handshake)** | `tg.judgment.overreach_in_ambiguity` | `aux.H01` |
-| **[memory-in-motion](./patterns/memory-in-motion)** | `tg.contextual.memory_amnesia` | `aux.H08` |
+Seven patterns close all **12 named gaps** in [`trust-gap-taxonomy.yaml`](../../schemas/trust-gap-taxonomy.yaml). Coverage is enforced in CI by [`check-coverage.py`](./check-coverage.py) — adding a gap without a pattern fails the build.
+
+| Pattern | Closes | Heuristic | Trust stage |
+|---|---|---|---|
+| **[intent-handshake](./patterns/intent-handshake)** | `tg.judgment.overreach_in_ambiguity` | `aux.H01` | `aux.T03` |
+| **[memory-in-motion](./patterns/memory-in-motion)** | `tg.contextual.memory_amnesia` `tg.contextual.preference_ignored` | `aux.H08` | `aux.T02` |
+| **[confidence-cues](./patterns/confidence-cues)** | `tg.functional.hallucination` `tg.judgment.confident_nonsense` | `aux.H06` | `aux.T01`, `aux.T03` |
+| **[behavioral-contract](./patterns/behavioral-contract)** | `tg.functional.silent_degradation` `tg.functional.inconsistent_output` | `aux.H10` | `aux.T01` |
+| **[memory-policy-scoping](./patterns/memory-policy-scoping)** | `tg.contextual.context_leak` | `aux.H08` | `aux.T02` |
+| **[escalation-handoff](./patterns/escalation-handoff)** | `tg.judgment.refusal_when_escalation_needed` | `aux.H07` | `aux.T03` |
+| **[user-aligned-objective](./patterns/user-aligned-objective)** | `tg.advocacy.metric_over_user` `tg.advocacy.incentive_misalignment` `tg.advocacy.loyalty_leak` | `aux.H07` | `aux.T04` |
+
+Every pattern ships four files. `example.py` is runnable as-is: `python patterns//example.py`.
## Pattern template
```
/patterns//
-├── README.md # what, why, when, how
-├── diagram.svg # one diagram
-├── example.{py,ts,...} # runnable
+├── README.md # what, why, when, how — embeds the diagram
+├── diagram.svg # one diagram, light/dark aware
+├── example.py # runnable: python example.py
└── anti-pattern.md # what this is not
```
+The anti-pattern is not decoration. Every one of these failure modes ships in
+real products *because it looks like the pattern* — hedging every sentence
+looks like calibration, a snapshot test looks like a contract, a `user_id`
+filter looks like scoping. Naming the near-miss is most of the work.
+
## Contributing
See [`CONTRIBUTING.md`](../../CONTRIBUTING.md). A pattern PR is rejected if any of the four files are missing.
diff --git a/repos/agentic-ux-patterns/check-coverage.py b/repos/agentic-ux-patterns/check-coverage.py
new file mode 100644
index 0000000..3300d2a
--- /dev/null
+++ b/repos/agentic-ux-patterns/check-coverage.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Enforce the two claims this repo makes about itself.
+
+ 1. Every named gap in trust-gap-taxonomy.yaml has a pattern that closes it.
+ 2. Every pattern ships all four files, and example.py actually runs.
+
+CONTRIBUTING says "a pattern PR is rejected if any of the four files are
+missing". This is that rule, executed rather than asserted.
+
+Usage: python repos/agentic-ux-patterns/check-coverage.py
+"""
+import subprocess
+import sys
+from pathlib import Path
+
+import yaml
+
+HERE = Path(__file__).resolve().parent
+REPO = HERE.parent.parent
+PATTERNS = HERE / "patterns"
+REQUIRED = ("README.md", "diagram.svg", "example.py", "anti-pattern.md")
+
+
+def main() -> int:
+ taxonomy = yaml.safe_load((REPO / "schemas" / "trust-gap-taxonomy.yaml").read_text())
+ gaps = taxonomy["gaps"]
+ present = {d.name for d in PATTERNS.iterdir() if d.is_dir()}
+ problems: list[str] = []
+
+ # 1. coverage
+ uncovered = [g for g in gaps if g["fix_pattern"] not in present]
+ for gap in uncovered:
+ problems.append(
+ f"gap {gap['id']} names fix_pattern '{gap['fix_pattern']}', "
+ f"which has no folder under patterns/"
+ )
+
+ # An orphan is not an error — a pattern may exist before a gap names it —
+ # but it should be deliberate, so say so.
+ named = {g["fix_pattern"] for g in gaps}
+ orphans = sorted(present - named)
+
+ # 2. completeness and runnability
+ for slug in sorted(present):
+ for filename in REQUIRED:
+ if not (PATTERNS / slug / filename).exists():
+ problems.append(f"pattern '{slug}' is missing {filename}")
+
+ example = PATTERNS / slug / "example.py"
+ if example.exists():
+ run = subprocess.run(
+ [sys.executable, example.name],
+ cwd=example.parent,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if run.returncode != 0:
+ tail = (run.stderr or run.stdout).strip().splitlines()[-1:]
+ problems.append(f"pattern '{slug}': example.py exited {run.returncode} — {tail}")
+
+ covered = len(gaps) - len(uncovered)
+ print(f"gaps covered: {covered}/{len(gaps)} patterns: {len(present)}")
+ if orphans:
+ print(f"patterns not named by any gap (fine, but deliberate?): {', '.join(orphans)}")
+
+ if problems:
+ print("\nFAILED:")
+ for problem in problems:
+ print(f" - {problem}")
+ return 1
+ print("all gaps closed, all patterns complete, all examples run")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/repos/agentic-ux-patterns/patterns/behavioral-contract/README.md b/repos/agentic-ux-patterns/patterns/behavioral-contract/README.md
new file mode 100644
index 0000000..2186870
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/behavioral-contract/README.md
@@ -0,0 +1,77 @@
+# Pattern · Behavioral Contract
+
+> The agent's behaviour is pinned by golden cases and a version. When it changes, the change is announced — never discovered.
+
+
+
+- **Heuristic:** `aux.H10` Consistency of Behavior
+- **Closes gaps:** `tg.functional.silent_degradation`, `tg.functional.inconsistent_output`
+- **Trust stage:** `aux.T01` Functional
+
+## What it is
+
+A versioned artifact that travels with the agent and states what will not change without notice:
+
+1. **Golden cases.** A small set of inputs with expected-behaviour assertions — not exact strings, but the properties that must hold ("escalates", "cites a source", "does not send email").
+2. **A pin.** Model version, prompt version, tool versions. Anything that can move behaviour is named.
+3. **A gate.** Changing the pin re-runs the golden cases. Drift does not block the release — it blocks a *silent* release. The gate's output is a changelog entry.
+
+## Why it works
+
+Functional trust is the claim "same input, same output, across sessions." Every agent breaks that claim eventually: a model is deprecated, a prompt is tuned, a tool changes its response shape. The trust-destroying part is not the change — it's that the user finds out by being burned.
+
+A contract converts an invisible regression into a visible release note. That is a much smaller promise than "we will never change", and unlike that promise, it can be kept.
+
+## When to use it
+
+- Any agent behind a model you do not control the release schedule of.
+- Any behaviour a user has been told to rely on ("it always asks before sending").
+- Any agent whose output feeds another system.
+
+## When NOT to use it
+
+- Prototypes with no users. A contract with no one on the other side is ceremony.
+- Assertions on exact wording. Pinning prose makes the suite fail on every harmless rephrase, and a suite that cries wolf gets deleted. That's the **[anti-pattern](./anti-pattern.md)**.
+
+## Minimal implementation
+
+```python
+def check(contract: Contract, agent: Agent) -> list[Drift]:
+ drift = []
+ for case in contract.golden:
+ result = agent.run(case.given)
+ for prop, expected in case.must.items():
+ holds = PROPERTIES[prop]
+ if holds(result) != expected:
+ drift.append(Drift(case.name, prop, expected))
+ return drift
+
+
+def release(contract, agent, new_pin) -> str:
+ drift = check(contract, agent)
+ if drift and not contract.acknowledged(drift):
+ raise SilentChangeBlocked(drift) # ship it, but say so
+ return changelog_entry(contract.pin, new_pin, drift)
+```
+
+## What to assert
+
+| Assert on | Don't assert on |
+|---|---|
+| "escalates to a human" | the exact escalation wording |
+| "cites at least one source" | which source it picked |
+| "does not call `email.send`" | the order of the other tool calls |
+| "refuses" / "asks before acting" | the phrasing of the refusal |
+| output parses as the declared schema | field ordering |
+
+The rule: **assert on the properties you promised the user, not on the prose you happened to ship.** A contract that fails when nothing a user would notice has changed will be disabled within a month, and then the real regression ships unannounced.
+
+## Relationship to `aux-audit`
+
+`aux.H10` reaches *present* only when a spec declares `evaluation.golden_transcripts`. That field is this contract's golden cases, pointed at from the spec — the audit checks that a contract exists; this pattern is what goes in it.
+
+## Anti-pattern
+
+See **[anti-pattern.md](./anti-pattern.md)**.
+
+TL;DR: a snapshot test on the exact output string is not a behavioral contract. It's a tripwire that fires on rephrases and stays silent on regressions.
diff --git a/repos/agentic-ux-patterns/patterns/behavioral-contract/anti-pattern.md b/repos/agentic-ux-patterns/patterns/behavioral-contract/anti-pattern.md
new file mode 100644
index 0000000..db173a4
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/behavioral-contract/anti-pattern.md
@@ -0,0 +1,39 @@
+# Anti-pattern · Snapshot-testing the prose
+
+What it looks like:
+
+```python
+def test_refund_flow():
+ out = agent.run("customer wants a refund on order 4831")
+ assert out == (
+ "I've looked up order #4831. I'll draft a refund email for your "
+ "review. I won't send anything without your approval."
+ )
+```
+
+Six weeks later the suite is red on every run, someone adds `--update-snapshots`
+to CI, and the assertion now records whatever the agent did last.
+
+## Why this is not a Behavioral Contract
+
+- **It fails on changes nobody would notice.** "I've looked up" becomes "I looked up" and the build goes red. Nothing about the promise to the user changed.
+- **It passes on changes everybody would notice.** Once snapshots auto-update, the day the agent stops saying "I won't send anything without your approval" — and starts sending — the suite records it as the new truth and stays green.
+- **It asserts on the wrong layer.** The promise was *"won't send without approval."* The test asserts on a sentence that happens to describe that promise. Those are not the same thing, and only one of them is what the user relies on.
+- **It produces no changelog.** Even when it catches something, the output is a diff of two paragraphs, not a statement of which guarantee moved.
+
+## The correct pattern
+
+Assert the property, not the paragraph:
+
+```python
+def test_refund_flow():
+ out = agent.run("customer wants a refund on order 4831")
+ assert not called("email.send", out) # the actual promise
+ assert asks_before_acting(out)
+ assert mentions_order(out, "4831")
+```
+
+Now a rephrase passes, an unannounced send fails, and the failure names the
+guarantee that broke.
+
+See **[README.md](./README.md)**.
diff --git a/repos/agentic-ux-patterns/patterns/behavioral-contract/diagram.svg b/repos/agentic-ux-patterns/patterns/behavioral-contract/diagram.svg
new file mode 100644
index 0000000..451cbb9
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/behavioral-contract/diagram.svg
@@ -0,0 +1,53 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/behavioral-contract/example.py b/repos/agentic-ux-patterns/patterns/behavioral-contract/example.py
new file mode 100644
index 0000000..c8e3da7
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/behavioral-contract/example.py
@@ -0,0 +1,132 @@
+"""Behavioral Contract — minimal runnable example.
+
+Golden cases assert on promised properties, never on prose. Changing the pin
+re-runs them; drift does not block the release, it blocks a *silent* release.
+
+Run with: python example.py
+"""
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List
+
+
+@dataclass
+class Result:
+ text: str
+ tool_calls: List[str] = field(default_factory=list)
+ citations: List[str] = field(default_factory=list)
+ awaited_approval: bool = False
+
+
+# Properties are the vocabulary of the contract. Assert on these, not on text.
+PROPERTIES: Dict[str, Callable[[Result], bool]] = {
+ "asks_before_acting": lambda r: r.awaited_approval,
+ "cites_a_source": lambda r: len(r.citations) > 0,
+ "sends_email": lambda r: "email.send" in r.tool_calls,
+ "escalates": lambda r: "human.handoff" in r.tool_calls,
+}
+
+
+@dataclass
+class GoldenCase:
+ name: str
+ given: str
+ must: Dict[str, bool]
+
+
+@dataclass
+class Drift:
+ case: str
+ prop: str
+ expected: bool
+ actual: bool
+
+ def __str__(self) -> str:
+ return (
+ f"{self.case}: `{self.prop}` was {self.expected}, is now {self.actual}"
+ )
+
+
+@dataclass
+class Contract:
+ version: str
+ pin: Dict[str, str]
+ golden: List[GoldenCase]
+ # Drift the maintainer has reviewed and accepted, by "case:prop".
+ acknowledged: List[str] = field(default_factory=list)
+
+
+class SilentChangeBlocked(Exception):
+ pass
+
+
+def check(contract: Contract, agent: Callable[[str], Result]) -> List[Drift]:
+ drift: List[Drift] = []
+ for case in contract.golden:
+ result = agent(case.given)
+ for prop, expected in case.must.items():
+ actual = PROPERTIES[prop](result)
+ if actual != expected:
+ drift.append(Drift(case.name, prop, expected, actual))
+ return drift
+
+
+def release(contract: Contract, agent: Callable[[str], Result], new_pin: Dict[str, str]) -> str:
+ """Ship, but never quietly. Unacknowledged drift raises; acknowledged drift
+ becomes a changelog entry the user can read."""
+ drift = check(contract, agent)
+ unacknowledged = [d for d in drift if f"{d.case}:{d.prop}" not in contract.acknowledged]
+ if unacknowledged:
+ raise SilentChangeBlocked(
+ "behaviour changed and has not been acknowledged:\n "
+ + "\n ".join(str(d) for d in unacknowledged)
+ )
+
+ changed = "\n".join(f" - {d}" for d in drift) or " - no behavioural change"
+ moved = ", ".join(f"{k} {contract.pin[k]} -> {v}" for k, v in new_pin.items() if contract.pin.get(k) != v)
+ header = f"## {contract.version} ({moved})" if moved else f"## {contract.version} (pin unchanged)"
+ return f"{header}\n{changed}"
+
+
+# ---- demo ----
+if __name__ == "__main__":
+ contract = Contract(
+ version="v1.4.0",
+ pin={"model": "agent-model-2.1", "prompt": "refund@7"},
+ golden=[
+ GoldenCase(
+ name="refund_request",
+ given="customer wants a refund on order 4831",
+ must={"asks_before_acting": True, "sends_email": False},
+ ),
+ GoldenCase(
+ name="policy_question",
+ given="what is the refund window?",
+ must={"cites_a_source": True},
+ ),
+ ],
+ )
+
+ def agent_v21(prompt: str) -> Result:
+ if "refund on order" in prompt:
+ return Result("I'll draft it for your review.", ["crm.lookup"], awaited_approval=True)
+ return Result("30 days.", ["kb.search"], citations=["policy/refunds.md"])
+
+ def agent_v30(prompt: str) -> Result:
+ """The new model stopped waiting for approval and now sends directly."""
+ if "refund on order" in prompt:
+ return Result("Sent the refund email.", ["crm.lookup", "email.send"])
+ return Result("30 days.", ["kb.search"], citations=["policy/refunds.md"])
+
+ print("— same pin, no drift —")
+ print(release(contract, agent_v21, {"model": "agent-model-2.1", "prompt": "refund@7"}))
+
+ print("\n— pin moves, behaviour moves, release is blocked —")
+ try:
+ release(contract, agent_v30, {"model": "agent-model-3.0", "prompt": "refund@7"})
+ except SilentChangeBlocked as blocked:
+ print(f"BLOCKED: {blocked}")
+
+ print("\n— maintainer acknowledges, the change ships as a changelog entry —")
+ contract.acknowledged = ["refund_request:asks_before_acting", "refund_request:sends_email"]
+ contract.version = "v2.0.0"
+ print(release(contract, agent_v30, {"model": "agent-model-3.0", "prompt": "refund@7"}))
diff --git a/repos/agentic-ux-patterns/patterns/confidence-cues/README.md b/repos/agentic-ux-patterns/patterns/confidence-cues/README.md
new file mode 100644
index 0000000..cc7ecb5
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/confidence-cues/README.md
@@ -0,0 +1,72 @@
+# Pattern · Confidence Cues
+
+> The agent's certainty is derived from its evidence, not from its prose — and the surface changes with the band.
+
+
+
+- **Heuristic:** `aux.H06` Graceful Uncertainty
+- **Closes gaps:** `tg.functional.hallucination`, `tg.judgment.confident_nonsense`
+- **Trust stages:** `aux.T01` Functional, `aux.T03` Judgment
+
+## What it is
+
+Three rules, applied to every claim the agent makes:
+
+1. **Derive, don't declare.** Confidence comes from a measurable support signal — retrieval score, agreement across independent sources, whether the answer was found or inferred. Never from how the sentence sounds.
+2. **Band it.** Collapse that signal into a small, fixed set of bands. Three is usually enough: `grounded`, `inferred`, `unsupported`.
+3. **Change the surface, not just the wording.** A different band renders differently: grounded claims assert and cite; inferred claims hedge and show the leap; unsupported claims refuse and say what would resolve it.
+
+## Why it works
+
+Hallucination and confident nonsense are the same defect seen at two trust stages: the agent's *tone* is constant while its *evidence* varies wildly. Users calibrate on tone, because it's the only signal they have. So they trust the invented answer exactly as much as the sourced one — and the first time they catch it, they stop trusting both.
+
+Attaching a band to the evidence breaks that coupling. It also fails safe: a claim with no support cannot be rendered as an assertion, because the renderer has no template for it.
+
+## When to use it
+
+- Any answer drawn from retrieval, search, or a knowledge base.
+- Any numeric or factual claim the user might act on.
+- Any recommendation in a novel situation the agent has no precedent for.
+
+## When NOT to use it
+
+- Deterministic outputs the agent computed itself (a sum, a date difference). Banding these teaches users that the bands are decoration.
+- Conversational filler. Hedging "sure, I can help with that" is the **[anti-pattern](./anti-pattern.md)**.
+
+## Minimal implementation
+
+```python
+def band(support: list[Evidence]) -> Band:
+ if not support:
+ return "unsupported"
+ best = max(e.score for e in support)
+ agreeing = len({e.source for e in support if e.score >= 0.6})
+ if best >= 0.75 and agreeing >= 2:
+ return "grounded"
+ return "inferred"
+
+
+def render(claim: str, support: list[Evidence]) -> str:
+ b = band(support)
+ if b == "grounded":
+ return f"{claim}\n source: {support[0].source}"
+ if b == "inferred":
+ return f"Probably: {claim}\n inferred from {support[0].source}; not stated directly."
+ return f"I don't have support for this.\n To answer it I'd need: {needed_for(claim)}"
+```
+
+## Band table
+
+| Band | Support signal | Surface | User can act alone? |
+|---|---|---|---|
+| `grounded` | ≥2 independent sources agree, best score ≥ 0.75 | assert + cite | yes |
+| `inferred` | some support, weak or single-source | hedge + show the leap | yes, with the caveat visible |
+| `unsupported` | nothing retrieved above threshold | refuse + name what would resolve it | no — the agent says so |
+
+Two properties worth preserving: the thresholds live in **one place** so they can be tuned and audited, and the `unsupported` branch names *what would resolve it*. "I don't know" ends the conversation; "I'd need the Q3 policy doc" continues it.
+
+## Anti-pattern
+
+See **[anti-pattern.md](./anti-pattern.md)**.
+
+TL;DR: hedging every sentence is not calibration. Uniform uncertainty is as useless as uniform confidence — it just moves the miscalibration to the other end.
diff --git a/repos/agentic-ux-patterns/patterns/confidence-cues/anti-pattern.md b/repos/agentic-ux-patterns/patterns/confidence-cues/anti-pattern.md
new file mode 100644
index 0000000..534d9cc
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/confidence-cues/anti-pattern.md
@@ -0,0 +1,31 @@
+# Anti-pattern · Hedging everything
+
+What it looks like:
+
+```
+User: What's our refund window?
+Agent: I believe it may be around 30 days, though I'm not entirely certain
+ and you may want to verify this with the policy team.
+
+User: And what's 14 days after March 3rd?
+Agent: That would likely be March 17th, but please double-check my arithmetic.
+
+User: Is the customer's account active?
+Agent: Based on what I can see, it appears the account might be active,
+ though I'd recommend confirming.
+```
+
+## Why this is not Confidence Cues
+
+- **The band never changes, so it carries no information.** Three claims with wildly different evidence — a retrieved policy, a deterministic calculation, a live database read — all render identically. A signal that is always on is not a signal.
+- **It is derived from nothing.** "I believe", "appears", "might" are tone, not measurement. The agent hedged the date arithmetic, which it computed exactly, and hedged the account status, which it read directly.
+- **It offloads the agent's job onto the user.** "Please verify" on every answer means the user must independently check everything, which is the work they delegated in the first place.
+- **It is defensive, not honest.** Blanket hedging protects the agent from being wrong. It does not help the user be right.
+
+The mirror image is equally wrong and more common: uniform confidence, where an invented statistic and a cited one arrive in the same flat declarative voice.
+
+## The correct pattern
+
+Measure support, band it, and let the surface differ. The date calculation asserts flatly. The account status cites the read. The refund window either cites the policy doc or says it could not find one — and names the doc it would need.
+
+See **[README.md](./README.md)**.
diff --git a/repos/agentic-ux-patterns/patterns/confidence-cues/diagram.svg b/repos/agentic-ux-patterns/patterns/confidence-cues/diagram.svg
new file mode 100644
index 0000000..77f9c35
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/confidence-cues/diagram.svg
@@ -0,0 +1,56 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/confidence-cues/example.py b/repos/agentic-ux-patterns/patterns/confidence-cues/example.py
new file mode 100644
index 0000000..2e8b061
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/confidence-cues/example.py
@@ -0,0 +1,90 @@
+"""Confidence Cues — minimal runnable example.
+
+The band is computed from evidence, never from phrasing, and the renderer has
+no template for asserting an unsupported claim.
+
+Run with: python example.py
+"""
+from dataclasses import dataclass, field
+from typing import List, Literal
+
+Band = Literal["grounded", "inferred", "unsupported"]
+
+# One place to tune and audit. Never inline these.
+STRONG_SCORE = 0.75
+AGREEMENT_SCORE = 0.60
+MIN_AGREEING_SOURCES = 2
+
+
+@dataclass
+class Evidence:
+ source: str
+ score: float
+ quote: str = ""
+
+
+@dataclass
+class Claim:
+ text: str
+ support: List[Evidence] = field(default_factory=list)
+ # What the agent would need to answer this, when it cannot.
+ resolved_by: str = ""
+ # Set for values the agent computed itself; these are not banded.
+ deterministic: bool = False
+
+
+def band(claim: Claim) -> Band:
+ if not claim.support:
+ return "unsupported"
+ best = max(e.score for e in claim.support)
+ agreeing = len({e.source for e in claim.support if e.score >= AGREEMENT_SCORE})
+ if best >= STRONG_SCORE and agreeing >= MIN_AGREEING_SOURCES:
+ return "grounded"
+ return "inferred"
+
+
+def render(claim: Claim) -> str:
+ """Each band gets its own surface. There is deliberately no branch that
+ renders an unsupported claim as an assertion."""
+ if claim.deterministic:
+ return claim.text
+
+ b = band(claim)
+ if b == "grounded":
+ cites = ", ".join(sorted({e.source for e in claim.support}))
+ return f"{claim.text}\n source: {cites}"
+ if b == "inferred":
+ best = max(claim.support, key=lambda e: e.score)
+ return (
+ f"Probably: {claim.text}\n"
+ f" inferred from {best.source} — not stated directly there."
+ )
+ needed = claim.resolved_by or "a source I don't currently have access to"
+ return f"I don't have support for this.\n To answer it I'd need: {needed}"
+
+
+# ---- demo ----
+if __name__ == "__main__":
+ claims = [
+ Claim(
+ "The refund window is 30 days.",
+ support=[
+ Evidence("policy/refunds.md", 0.91, "customers may request a refund within 30 days"),
+ Evidence("support/macros.yaml", 0.82, "30-day refund"),
+ ],
+ ),
+ Claim(
+ "This customer is on the legacy plan.",
+ support=[Evidence("crm/notes", 0.55, "migrated 2023, plan field empty")],
+ ),
+ Claim(
+ "Enterprise customers get a 90-day window.",
+ resolved_by="the enterprise addendum, which is not in the indexed corpus",
+ ),
+ Claim("14 days after March 3rd is March 17th.", deterministic=True),
+ ]
+
+ for c in claims:
+ label = "computed" if c.deterministic else band(c)
+ print(f"[{label}]")
+ print(f" {render(c)}\n")
diff --git a/repos/agentic-ux-patterns/patterns/escalation-handoff/README.md b/repos/agentic-ux-patterns/patterns/escalation-handoff/README.md
new file mode 100644
index 0000000..a21d1b8
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/escalation-handoff/README.md
@@ -0,0 +1,76 @@
+# Pattern · Escalation Handoff
+
+> When the agent cannot proceed, it routes — it does not refuse. A refusal that names no next step is a dead end the user has to escape alone.
+
+
+
+- **Heuristic:** `aux.H07` Appropriate Agent Assertiveness
+- **Closes gap:** `tg.judgment.refusal_when_escalation_needed`
+- **Trust stage:** `aux.T03` Judgment
+
+## What it is
+
+A refusal is replaced by a route, carrying three things:
+
+1. **A named destination.** A team, a queue, a role — something the user can picture. "Support" is a destination; "the appropriate team" is not.
+2. **A context packet.** Everything the agent already gathered, so the human starts where the agent stopped and the user does not re-tell the story.
+3. **A stated position.** What the agent *would* have done, and why it stopped. The human receiving the handoff needs the agent's read, not just its transcript.
+
+Only when no route exists does the agent refuse — and it says that no route exists, which is different from saying no.
+
+## Why it works
+
+Judgment trust is earned by escalating *when escalation is the right call*. The taxonomy names the failure precisely: refusal when escalation was needed. Both a refusal and a handoff end the agent's involvement, so they look equivalent from inside the system — which is why this failure is so easy to ship. From the user's side they are opposites: one ends the task, the other moves it.
+
+The context packet is what makes the difference real rather than cosmetic. A handoff that drops the user into a queue to start over is a refusal wearing a routing label.
+
+## When to use it
+
+- Policy boundaries: the agent is not permitted, but someone is.
+- Confidence floors: the agent could act, but shouldn't at this stake and this certainty.
+- Repeated failure: two attempts at the same goal have not resolved it.
+- Explicit request: the user asked for a human. This one is never overridden.
+
+## When NOT to use it
+
+- When the agent can just do it. Escalation as a reflex is `calibrated-assertiveness` failing in the timid direction, and it trains users to skip the agent entirely.
+- When the destination is fictional. Routing into a queue nobody reads is worse than an honest refusal — that's the **[anti-pattern](./anti-pattern.md)**.
+
+## Minimal implementation
+
+```python
+def handle(situation, policy) -> Outcome:
+ route = policy.route_for(situation)
+ if route is None:
+ return Refusal(
+ reason=situation.blocker,
+ no_route_because=policy.why_no_route(situation),
+ )
+ return Handoff(
+ to=route.destination,
+ packet=situation.gathered, # don't make the user repeat themselves
+ agent_position=situation.would_have_done,
+ eta=route.eta,
+ )
+```
+
+## What the user should see
+
+```
+I can't approve a refund above £500 — that needs a supervisor.
+
+I've routed this to the Billing supervisor queue with everything so far:
+ · order #4831, £780, purchased 12 days ago
+ · policy check: within the 30-day window
+ · my read: this qualifies, the only blocker is the amount
+
+Typical response: under 2 hours. You don't need to repeat any of it.
+```
+
+Three properties: the **blocker is named** (the amount, not "policy"), the **agent's position is stated** so the supervisor can agree in one click, and the **user is told they're done** — the most valuable sentence in the message.
+
+## Anti-pattern
+
+See **[anti-pattern.md](./anti-pattern.md)**.
+
+TL;DR: "I'm not able to help with that" is not a handoff. Neither is a ticket number with no context in it.
diff --git a/repos/agentic-ux-patterns/patterns/escalation-handoff/anti-pattern.md b/repos/agentic-ux-patterns/patterns/escalation-handoff/anti-pattern.md
new file mode 100644
index 0000000..22d611c
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/escalation-handoff/anti-pattern.md
@@ -0,0 +1,39 @@
+# Anti-pattern · The polite dead end
+
+What it looks like:
+
+```
+User: The refund is £780, above your limit. Can you get it approved?
+Agent: I'm not able to help with that. Please contact our support team
+ for further assistance.
+```
+
+Or, wearing a routing label:
+
+```
+Agent: I've created ticket #99412 for you.
+```
+
+...and the ticket body reads, in full: *"Customer enquiry via assistant."*
+
+## Why this is not an Escalation Handoff
+
+- **The destination is not a place.** "Our support team" is not something the user can picture, reach, or estimate. They now have a second task: find out who that is.
+- **The context is dropped.** The agent had the order number, the amount, the purchase date, and a completed policy check. All of it is discarded. The user re-tells the story to a human who starts from zero — which is precisely the work they came to avoid.
+- **The agent's read is missing.** The agent had concluded the refund qualified and that the only blocker was the amount. That single sentence would let a supervisor approve in one click. It is not passed on.
+- **Nobody said the user is done.** After a dead end, the user does not know whether to wait, chase, or start again. Most start again, in a different channel, which is how one blocked task becomes three.
+- **The ticket variant is worse than the refusal**, because it looks resolved. The user waits on a queue entry that contains nothing anyone can act on.
+
+## The correct pattern
+
+Name the blocker, name the destination, hand over everything already gathered,
+state what the agent would have done, and tell the user they can stop:
+
+```
+I can't approve above £500 — that needs a supervisor.
+Routed to the Billing supervisor queue with the order, the policy check,
+and my read that this qualifies. Typical response under 2 hours.
+You don't need to repeat any of it.
+```
+
+See **[README.md](./README.md)**.
diff --git a/repos/agentic-ux-patterns/patterns/escalation-handoff/diagram.svg b/repos/agentic-ux-patterns/patterns/escalation-handoff/diagram.svg
new file mode 100644
index 0000000..ba419bd
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/escalation-handoff/diagram.svg
@@ -0,0 +1,52 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/escalation-handoff/example.py b/repos/agentic-ux-patterns/patterns/escalation-handoff/example.py
new file mode 100644
index 0000000..0f4fcd0
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/escalation-handoff/example.py
@@ -0,0 +1,130 @@
+"""Escalation Handoff — minimal runnable example.
+
+A blocked situation produces a route with a context packet and the agent's
+position. Refusal happens only when no route exists — and says so.
+
+Run with: python example.py
+"""
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Union
+
+
+@dataclass
+class Situation:
+ goal: str
+ blocker: str
+ gathered: List[str] = field(default_factory=list)
+ would_have_done: str = ""
+ user_asked_for_human: bool = False
+ attempts: int = 1
+
+ def kind(self) -> str:
+ """The routable category, e.g. "refund" from "refund: order 4831"."""
+ return self.goal.split(":")[0].strip()
+
+
+@dataclass
+class Route:
+ destination: str
+ eta: str
+ # Human-readable authority, for the first line: "that needs ".
+ authority: str
+
+
+@dataclass
+class Handoff:
+ to: str
+ packet: List[str]
+ agent_position: str
+ blocker: str
+ eta: str
+ authority: str
+
+ def render(self) -> str:
+ lines = [f"I can't {self.blocker} — that needs {self.authority}.", ""]
+ lines.append(f"I've routed this to the {self.to} with everything so far:")
+ lines += [f" · {item}" for item in self.packet]
+ if self.agent_position:
+ lines.append(f" · my read: {self.agent_position}")
+ lines.append("")
+ lines.append(f"Typical response: {self.eta}. You don't need to repeat any of it.")
+ return "\n".join(lines)
+
+
+@dataclass
+class Refusal:
+ blocker: str
+ no_route_because: str
+
+ def render(self) -> str:
+ return (
+ f"I can't {self.blocker}, and there's no one I can route this to: "
+ f"{self.no_route_because}.\n"
+ "Saying so rather than leaving you waiting on a queue that won't answer."
+ )
+
+
+Outcome = Union[Handoff, Refusal]
+
+
+class Policy:
+ """Routes are data, not conditionals scattered through the agent."""
+
+ def __init__(self, routes: Dict[str, Route]) -> None:
+ self.routes = routes
+
+ def route_for(self, s: Situation) -> Optional[Route]:
+ if s.user_asked_for_human:
+ return self.routes.get("human_requested")
+ if s.attempts >= 2:
+ return self.routes.get("repeated_failure")
+ return self.routes.get(s.kind())
+
+ def why_no_route(self, s: Situation) -> str:
+ return f"nothing in this workspace is authorised for '{s.blocker}'"
+
+
+def handle(situation: Situation, policy: Policy) -> Outcome:
+ route = policy.route_for(situation)
+ if route is None:
+ return Refusal(situation.blocker, policy.why_no_route(situation))
+ return Handoff(
+ to=route.destination,
+ packet=situation.gathered,
+ agent_position=situation.would_have_done,
+ blocker=situation.blocker,
+ eta=route.eta,
+ authority=route.authority,
+ )
+
+
+# ---- demo ----
+if __name__ == "__main__":
+ policy = Policy(
+ {
+ "refund": Route("Billing supervisor queue", "under 2 hours", "a supervisor"),
+ "human_requested": Route("on-call support agent", "under 15 minutes", "a person"),
+ "repeated_failure": Route("technical support queue", "same day", "technical support"),
+ }
+ )
+
+ blocked_by_limit = Situation(
+ goal="refund: order 4831",
+ blocker="approve a refund above £500",
+ gathered=[
+ "order #4831, £780, purchased 12 days ago",
+ "policy check: within the 30-day window",
+ ],
+ would_have_done="this qualifies; the only blocker is the amount",
+ )
+
+ no_route = Situation(
+ goal="contract_amendment: clause 7",
+ blocker="amend a signed contract",
+ gathered=["customer asked to strike the auto-renewal clause"],
+ )
+
+ for label, s in [("blocked by a limit", blocked_by_limit), ("nothing to route to", no_route)]:
+ print(f"— {label} —")
+ print(handle(s, policy).render())
+ print()
diff --git a/repos/agentic-ux-patterns/patterns/intent-handshake/README.md b/repos/agentic-ux-patterns/patterns/intent-handshake/README.md
index d2f3d40..c7ab0f9 100644
--- a/repos/agentic-ux-patterns/patterns/intent-handshake/README.md
+++ b/repos/agentic-ux-patterns/patterns/intent-handshake/README.md
@@ -2,6 +2,8 @@
> Before the agent executes, it shows the plan. When the stakes warrant, it waits for a green light.
+
+
- **Heuristic:** `aux.H01` Visibility of Agent Intent & Action
- **Closes gap:** `tg.judgment.overreach_in_ambiguity`
- **Trust stage:** `aux.T03` Judgment
diff --git a/repos/agentic-ux-patterns/patterns/intent-handshake/diagram.svg b/repos/agentic-ux-patterns/patterns/intent-handshake/diagram.svg
new file mode 100644
index 0000000..8175ae6
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/intent-handshake/diagram.svg
@@ -0,0 +1,56 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/intent-handshake/example.py b/repos/agentic-ux-patterns/patterns/intent-handshake/example.py
index 3224f7f..59b0272 100644
--- a/repos/agentic-ux-patterns/patterns/intent-handshake/example.py
+++ b/repos/agentic-ux-patterns/patterns/intent-handshake/example.py
@@ -2,6 +2,7 @@
Run with: python example.py
"""
+import sys
from dataclasses import dataclass
from typing import Callable, List, Literal, Optional
@@ -25,9 +26,25 @@ def preview(self) -> str:
return f"I'll do {len(self.steps)} things:\n{bullets}\n{will_not}".strip()
+# ---------------------------------------------------------------------------
+# Demo input. At a terminal this prompts a human; with no TTY (CI, a pipe) it
+# replays SCRIPT, so the example stays interactive *and* stays runnable
+# unattended. Neither the pattern nor its signatures depend on this.
+SCRIPT: List[str] = []
+
+
+def _prompt(tag: str, default: str = "") -> str:
+ if sys.stdin.isatty():
+ return input(tag).strip().lower()
+ reply = SCRIPT.pop(0) if SCRIPT else default
+ print(f"{tag}{reply}")
+ return reply
+# ---------------------------------------------------------------------------
+
+
def ask_user(prompt: str, options: List[str]) -> str:
print(prompt)
- return input(f"{'/'.join(options)}> ").strip().lower()
+ return _prompt(f"{'/'.join(options)}> ", default=options[0])
def intent_handshake(plan: Plan, amend: Callable[[Plan, str], Plan]) -> Optional[Plan]:
@@ -38,7 +55,7 @@ def intent_handshake(plan: Plan, amend: Callable[[Plan, str], Plan]) -> Optional
if reply == "cancel":
return None
if reply == "edit":
- change = input("what should change?> ")
+ change = _prompt("what should change?> ", default="no change")
return amend(plan, change)
return plan
@@ -58,5 +75,8 @@ def amend(p: Plan, change: str) -> Plan:
p.steps.append(Step(f"(edit) {change}"))
return p
+ SCRIPT[:] = ["edit", "also say the 30-day window was checked"]
confirmed = intent_handshake(plan, amend)
- print("\n→ executing:", confirmed)
+ print("\n→ executing:")
+ for step in (confirmed.steps if confirmed else []):
+ print(f" · {step.action}")
diff --git a/repos/agentic-ux-patterns/patterns/memory-in-motion/README.md b/repos/agentic-ux-patterns/patterns/memory-in-motion/README.md
index 73e65e4..675275a 100644
--- a/repos/agentic-ux-patterns/patterns/memory-in-motion/README.md
+++ b/repos/agentic-ux-patterns/patterns/memory-in-motion/README.md
@@ -2,6 +2,8 @@
> The agent visibly updates its memory *in front of the user*, at the moment new information arrives, with a one-click edit.
+
+
- **Heuristic:** `aux.H08` Context Efficiency
- **Closes gap:** `tg.contextual.memory_amnesia`
- **Trust stage:** `aux.T02` Contextual
diff --git a/repos/agentic-ux-patterns/patterns/memory-in-motion/diagram.svg b/repos/agentic-ux-patterns/patterns/memory-in-motion/diagram.svg
new file mode 100644
index 0000000..01a384e
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/memory-in-motion/diagram.svg
@@ -0,0 +1,50 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/memory-in-motion/example.py b/repos/agentic-ux-patterns/patterns/memory-in-motion/example.py
index 4f2aa4e..6b10082 100644
--- a/repos/agentic-ux-patterns/patterns/memory-in-motion/example.py
+++ b/repos/agentic-ux-patterns/patterns/memory-in-motion/example.py
@@ -2,6 +2,7 @@
Run with: python example.py
"""
+import sys
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
@@ -48,10 +49,26 @@ def forget(self, user: str, keys: List[str]) -> None:
self._store[user].pop(k, None)
+# ---------------------------------------------------------------------------
+# Demo input. At a terminal this prompts a human; with no TTY (CI, a pipe) it
+# replays SCRIPT, so the example stays interactive *and* stays runnable
+# unattended. Neither the pattern nor its signatures depend on this.
+SCRIPT: List[str] = []
+
+
+def _prompt(tag: str, default: str = "") -> str:
+ if sys.stdin.isatty():
+ return input(tag).strip().lower()
+ reply = SCRIPT.pop(0) if SCRIPT else default
+ print(f"{tag}{reply}")
+ return reply
+# ---------------------------------------------------------------------------
+
+
def notify(user: str, text: str, actions: List[str]) -> Optional[str]:
print(f" 💡 {text}")
print(f" [{'] ['.join(actions)}]")
- return input(f" action ({'/'.join(actions + ['skip'])})> ").strip().lower()
+ return _prompt(f" action ({'/'.join(actions + ['skip'])})> ", default="skip")
def memory_in_motion(
@@ -74,8 +91,8 @@ def memory_in_motion(
if reply == "forget":
memory.forget(user, list(delta.added) + list(delta.changed))
elif reply == "edit":
- k = input(" which key?> ")
- v = input(f" new value for {k}?> ")
+ k = _prompt(" which key?> ", default=next(iter(delta.added), ""))
+ v = _prompt(f" new value for {k}?> ", default="(unchanged)")
memory.update(user, {k: v})
@@ -85,9 +102,11 @@ def memory_in_motion(
user = "emil"
print("First capture:")
+ SCRIPT[:] = ["skip"]
memory_in_motion({"language_preference": "British English"}, user, m, notify)
- print("\nUpdate:")
+ print("\nUpdate, then the user corrects it inline:")
+ SCRIPT[:] = ["edit", "response_style", "brief but warm"]
memory_in_motion({"language_preference": "British English", "response_style": "terse"}, user, m, notify)
print("\nFinal memory:", m.get(user))
diff --git a/repos/agentic-ux-patterns/patterns/memory-policy-scoping/README.md b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/README.md
new file mode 100644
index 0000000..7cc4f0e
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/README.md
@@ -0,0 +1,67 @@
+# Pattern · Memory Policy Scoping
+
+> Every memory carries the scope it was learned in. Reads are filtered by the scope of the moment — and crossing a boundary is an event the user sees.
+
+
+
+- **Heuristic:** `aux.H08` Context Efficiency
+- **Closes gap:** `tg.contextual.context_leak`
+- **Trust stage:** `aux.T02` Contextual
+
+## What it is
+
+Three rules on the memory store:
+
+1. **Write with a scope.** Nothing enters memory unlabelled. The scope is the boundary the user would recognise — this workspace, this project, this counterparty, this device.
+2. **Read through the current scope.** A retrieval in context *X* sees memories written in *X* and in scopes that legitimately contain it. It does not see siblings.
+3. **Bridge explicitly.** When the agent genuinely needs something from another scope, it asks, names both scopes, and the bridge is one-shot — not a permanent merge.
+
+## Why it works
+
+`memory-in-motion` makes the agent remember. This pattern is the constraint that makes remembering safe, and the two are meant to ship together: persistence without scoping does not produce a helpful agent, it produces one that mentions Client A's numbers in a document for Client B.
+
+The failure is not that the agent recalled something. It's that the user could not have predicted the recall, because from the outside there was no boundary — memory looked like one undifferentiated pool. Naming scopes makes the pool legible, and a legible boundary is one a user can trust.
+
+## When to use it
+
+- Any agent used across more than one client, customer, project, or tenant.
+- Anything touching regulated or personal data, where "which scope was this learned in" is the audit question.
+- Any shared or team-visible agent — the scope boundary is often *per person*, not per workspace.
+
+## When NOT to use it
+
+- Genuinely single-scope tools. Inventing scopes for a personal note-taker adds a boundary the user must now reason about, for no protection.
+- As a substitute for access control. Scoping decides what is *contextually appropriate* to recall; it is not what decides who is *permitted* to read. Conflating the two is the **[anti-pattern](./anti-pattern.md)**.
+
+## Minimal implementation
+
+```python
+def visible(memory: Memory, ctx: Scope) -> bool:
+ return memory.scope == ctx or memory.scope in ctx.ancestors()
+
+
+def recall(store, ctx, query) -> tuple[list[Memory], list[Memory]]:
+ hits = store.search(query)
+ return (
+ [m for m in hits if visible(m, ctx)],
+ [m for m in hits if not visible(m, ctx)], # withheld — offer a bridge
+ )
+```
+
+The second return value is the point. A store that silently drops out-of-scope hits is indistinguishable from one that has forgotten — so the agent can offer *"I know this from another project, want me to bring it in?"* instead of going quiet.
+
+## Scope shapes
+
+| Shape | Example | Reads see |
+|---|---|---|
+| Flat | one scope per client | that client only |
+| Nested | `org / project / thread` | own scope + ancestors |
+| Per-principal | one scope per human on a shared agent | own scope only, never siblings |
+
+Two invariants worth enforcing in code rather than convention: **ancestors are readable, siblings are never**, and **a bridge is one-shot** — it copies a named memory into the current scope with its origin recorded, and does not open a channel.
+
+## Anti-pattern
+
+See **[anti-pattern.md](./anti-pattern.md)**.
+
+TL;DR: a `user_id` filter on the query is not scoping. It stops other people's data leaking; it does nothing about the user's own Client A data surfacing in Client B's context.
diff --git a/repos/agentic-ux-patterns/patterns/memory-policy-scoping/anti-pattern.md b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/anti-pattern.md
new file mode 100644
index 0000000..bf5219f
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/anti-pattern.md
@@ -0,0 +1,41 @@
+# Anti-pattern · `WHERE user_id = ?` and calling it scoped
+
+What it looks like:
+
+```python
+def recall(query, user_id):
+ return store.search(query, filter={"user_id": user_id})
+```
+
+Then, in a document being drafted for Client B:
+
+```
+Agent: Based on your usual pricing, I'd suggest £48k — that's in line with
+ what you agreed with Northgate last quarter.
+```
+
+Northgate is a different client. The filter did its job perfectly: it is the
+same user.
+
+## Why this is not Memory Policy Scoping
+
+- **It solves tenancy, not context.** Access control answers *who may read this*. Scoping answers *is this appropriate to surface here*. The leak above passes access control cleanly — and is exactly the disclosure that loses the account.
+- **The user cannot predict it.** From the outside, memory is one pool with one boundary drawn around the whole person. There is no line the user can point at and say "the agent will not cross this."
+- **It fails silently in both directions.** Nothing is withheld, so nothing can be offered. The agent either volunteers Northgate unprompted, or — in a design that does filter — goes quiet without saying it knows something relevant.
+- **It cannot be audited.** "Which scope was this learned in" has no answer, because nothing recorded one.
+
+## The correct pattern
+
+Label the write with the boundary the user would recognise, filter the read
+through the current one, and surface what was withheld:
+
+```
+Agent: I'd suggest £48k for this proposal.
+ I have a comparable figure from another client engagement —
+ bring it in? [yes] [no]
+```
+
+The user now sees the boundary, and chooses whether to cross it. That is a
+boundary they can trust, because they watched it hold.
+
+See **[README.md](./README.md)**.
diff --git a/repos/agentic-ux-patterns/patterns/memory-policy-scoping/diagram.svg b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/diagram.svg
new file mode 100644
index 0000000..1cb47d4
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/diagram.svg
@@ -0,0 +1,47 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/memory-policy-scoping/example.py b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/example.py
new file mode 100644
index 0000000..3bc7283
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/memory-policy-scoping/example.py
@@ -0,0 +1,108 @@
+"""Memory Policy Scoping — minimal runnable example.
+
+Ancestors are readable, siblings never are, and out-of-scope hits are withheld
+rather than dropped — so the agent can offer a bridge instead of going quiet.
+
+Run with: python example.py
+"""
+from dataclasses import dataclass, field
+from datetime import date
+from typing import List, Optional, Tuple
+
+
+@dataclass(frozen=True)
+class Scope:
+ """A path the user would recognise, e.g. acme/northgate/proposal."""
+ path: str
+
+ def ancestors(self) -> List["Scope"]:
+ parts = self.path.split("/")
+ return [Scope("/".join(parts[:i])) for i in range(1, len(parts))]
+
+ def __str__(self) -> str:
+ return self.path
+
+
+@dataclass
+class Memory:
+ text: str
+ scope: Scope
+ learned_on: date = field(default_factory=date.today)
+ # Set when this memory was bridged in from elsewhere. Origin is never lost.
+ bridged_from: Optional[Scope] = None
+
+
+class Store:
+ def __init__(self) -> None:
+ self._items: List[Memory] = []
+
+ def write(self, text: str, scope: Scope) -> Memory:
+ memory = Memory(text, scope)
+ self._items.append(memory)
+ return memory
+
+ def _search(self, query: str) -> List[Memory]:
+ terms = query.lower().split()
+ return [m for m in self._items if any(t in m.text.lower() for t in terms)]
+
+ def recall(self, query: str, ctx: Scope) -> Tuple[List[Memory], List[Memory]]:
+ """Returns (visible, withheld). Withheld is not an error — it is the
+ material for an explicit, one-shot bridge."""
+ visible, withheld = [], []
+ readable = {ctx, *ctx.ancestors()}
+ for m in self._search(query):
+ (visible if m.scope in readable else withheld).append(m)
+ return visible, withheld
+
+ def bridge(self, memory: Memory, into: Scope) -> Memory:
+ """One-shot: copies one named memory, records its origin, opens nothing."""
+ copy = Memory(memory.text, scope=into, bridged_from=memory.scope)
+ self._items.append(copy)
+ return copy
+
+
+def offer_bridge(withheld: List[Memory], ctx: Scope) -> str:
+ if not withheld:
+ return ""
+ other = withheld[0]
+ return (
+ f'I have something relevant from "{other.scope}", which is outside '
+ f'"{ctx}". Bring it in? [yes] [no]'
+ )
+
+
+# ---- demo ----
+if __name__ == "__main__":
+ store = Store()
+ acme = Scope("acme")
+ northgate = Scope("acme/northgate")
+ riverbend = Scope("acme/riverbend")
+
+ store.write("House style: no em-dashes in client-facing copy.", acme)
+ store.write("Agreed day rate 48k for the Q3 engagement.", northgate)
+ store.write("Prefers weekly written updates, not calls.", riverbend)
+
+ print(f"context: {riverbend}\n")
+ visible, withheld = store.recall("rate style updates", riverbend)
+
+ print("visible:")
+ for m in visible:
+ origin = f" (bridged from {m.bridged_from})" if m.bridged_from else ""
+ print(f" · {m.text} [{m.scope}]{origin}")
+
+ print("\nwithheld (sibling scope — never read implicitly):")
+ for m in withheld:
+ print(f" · {m.text} [{m.scope}]")
+
+ print(f"\nagent says: {offer_bridge(withheld, riverbend)}")
+
+ print("\n— user says yes —")
+ store.bridge(withheld[0], into=riverbend)
+ visible, _ = store.recall("rate", riverbend)
+ for m in visible:
+ origin = f" (bridged from {m.bridged_from})" if m.bridged_from else ""
+ print(f" · {m.text} [{m.scope}]{origin}")
+
+ print("\n— the bridge was one-shot: northgate's scope is still closed —")
+ _, still_withheld = store.recall("day rate", riverbend)
+ print(f" {len(still_withheld)} memory still withheld from {northgate}")
diff --git a/repos/agentic-ux-patterns/patterns/user-aligned-objective/README.md b/repos/agentic-ux-patterns/patterns/user-aligned-objective/README.md
new file mode 100644
index 0000000..27c2a69
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/user-aligned-objective/README.md
@@ -0,0 +1,71 @@
+# Pattern · User-Aligned Objective
+
+> The agent's objective function is written down, inspectable, and defaults to the user. When the platform's interest and the user's diverge, the divergence is shown — not resolved silently.
+
+
+
+- **Heuristic:** `aux.H07` Appropriate Agent Assertiveness
+- **Closes gaps:** `tg.advocacy.metric_over_user`, `tg.advocacy.incentive_misalignment`, `tg.advocacy.loyalty_leak`
+- **Trust stage:** `aux.T04` Advocacy
+
+## What it is
+
+1. **Write the objective down.** Which quantities the agent is optimising, and their weights. If nobody can name them, the agent is optimising something anyway — usually whatever the training signal rewarded.
+2. **Score both sides separately.** User value and platform value are computed as distinct numbers. Never blended into one score before anyone can inspect them.
+3. **Default to the user on divergence.** When the ranking differs, the user's ranking wins, and the fact that it diverged is disclosed in the answer.
+4. **Never let platform value break a tie invisibly.** If it does break a tie, say so.
+
+## Why it works
+
+Advocacy trust is the last stage and the only one that can collapse the whole ladder — the Trust Architecture is explicit that a single advocacy violation drops trust back to functional. It is also the only stage where the agent's failure is *structural rather than accidental*: nothing malfunctioned. The agent optimised what it was told to optimise, and that turned out not to be the user.
+
+You cannot test your way out of this one, because there is no error state to detect. The only defence is making the objective explicit enough that a human can look at it and say "that weight is wrong."
+
+Disclosure is what turns the pattern from a policy into something the user can verify. An agent that claims to act in your interest is making a promise; an agent that shows you the case it argued against is showing evidence.
+
+## When to use it
+
+- Any recommendation where the agent's operator earns differently across the options: marketplaces, brokers, comparison tools, upgrade prompts, anything with a house product.
+- Any retention, renewal, or cancellation flow.
+- Any agent that will be asked "why this one?" — which is all of them, eventually.
+
+## When NOT to use it
+
+- Where there is genuinely no divergence. Manufacturing a disclosure for a spellchecker teaches users to ignore the disclosures that matter.
+- As a banner. "We always put you first" in the footer is the **[anti-pattern](./anti-pattern.md)** — a claim, where the pattern requires a computation.
+
+## Minimal implementation
+
+```python
+def recommend(options, objective) -> Recommendation:
+ scored = [(o, objective.user_value(o), objective.platform_value(o)) for o in options]
+
+ best_for_user = max(scored, key=lambda s: s[1])[0]
+ best_for_platform = max(scored, key=lambda s: s[2])[0]
+
+ return Recommendation(
+ pick=best_for_user, # the user always wins
+ diverged=best_for_user is not best_for_platform,
+ would_have_picked=best_for_platform, # disclosed, not hidden
+ objective=objective.describe(), # inspectable
+ )
+```
+
+## What the user should see
+
+```
+Recommended: Meridian Basic — £18/mo
+
+ Heads up: we earn more when you pick Northwind Plus (£42/mo), and on our
+ own numbers it doesn't fit your usage. You're using 4GB of a 100GB plan.
+
+ Ranked on: fit to your usage (60%), total cost (30%), switching effort (10%).
+```
+
+The disclosure names the option the agent *didn't* pick and why the house preferred it. A disclosure that only says "we may earn commission" carries no information — every option earns something. Naming the specific alternative and the specific reason is what makes it checkable.
+
+## Anti-pattern
+
+See **[anti-pattern.md](./anti-pattern.md)**.
+
+TL;DR: a blended score is not an aligned objective. Once user value and platform value are added together behind one number, nobody — including the team that shipped it — can tell which one won.
diff --git a/repos/agentic-ux-patterns/patterns/user-aligned-objective/anti-pattern.md b/repos/agentic-ux-patterns/patterns/user-aligned-objective/anti-pattern.md
new file mode 100644
index 0000000..5d40cb2
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/user-aligned-objective/anti-pattern.md
@@ -0,0 +1,38 @@
+# Anti-pattern · The blended score
+
+What it looks like:
+
+```python
+def rank(option, user):
+ return (
+ 0.5 * fit_to_usage(option, user)
+ + 0.3 * margin(option) # <- house economics, same formula
+ + 0.2 * (1 / option.price)
+ )
+```
+
+Shipped alongside a footer that reads *"We always recommend what's best for you."*
+
+## Why this is not a User-Aligned Objective
+
+- **The two interests are added together, so neither can be inspected.** Once `margin` is inside the sum, no one can answer "would we have recommended this if we earned nothing on it?" — not the user, not support, not the team that wrote the function. The information is destroyed at the point of addition, not hidden.
+- **Divergence becomes undetectable by construction.** There is no moment where the code notices that the house pick and the user pick differ, because it never computes them separately. Nothing can be disclosed, because nothing was ever distinguished.
+- **The weight looks defensible and isn't.** 0.3 sounds modest. Across a catalogue where fit scores cluster between 0.6 and 0.8, a 0.3-weighted margin term decides nearly every ranking. Blended weights hide their own influence.
+- **The footer is a claim, not evidence.** "We always recommend what's best for you" is exactly what a system optimising 30% for margin would also say. A promise that is equally consistent with its own violation is not a promise.
+- **This is what makes it a `loyalty_leak`.** Not malice — arithmetic. Nobody decided to sell the user the wrong plan; the objective function did, quietly, and the disclosure layer had nothing to report because the divergence never had a name.
+
+## The correct pattern
+
+Compute the two separately, let the user's ranking win, and disclose when they
+disagreed:
+
+```python
+user_pick = max(options, key=objective.user_value)
+platform_pick = max(options, key=objective.platform_value)
+# recommend user_pick; if the two differ, say so, and name the other one.
+```
+
+Now "would we have recommended this if we earned nothing on it?" has an answer,
+and it can be printed.
+
+See **[README.md](./README.md)**.
diff --git a/repos/agentic-ux-patterns/patterns/user-aligned-objective/diagram.svg b/repos/agentic-ux-patterns/patterns/user-aligned-objective/diagram.svg
new file mode 100644
index 0000000..cbc3c6e
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/user-aligned-objective/diagram.svg
@@ -0,0 +1,56 @@
+
diff --git a/repos/agentic-ux-patterns/patterns/user-aligned-objective/example.py b/repos/agentic-ux-patterns/patterns/user-aligned-objective/example.py
new file mode 100644
index 0000000..9671024
--- /dev/null
+++ b/repos/agentic-ux-patterns/patterns/user-aligned-objective/example.py
@@ -0,0 +1,118 @@
+"""User-Aligned Objective — minimal runnable example.
+
+User value and platform value are scored separately and never summed. The user's
+ranking wins; divergence is disclosed by naming the option the house preferred
+and why it lost.
+
+Run with: python example.py
+"""
+from dataclasses import dataclass
+from typing import Callable, List, Optional
+
+
+@dataclass
+class Plan:
+ name: str
+ price: float
+ included_gb: int
+ margin: float # what the operator earns. Never enters user_value.
+ switching_hours: float
+
+
+@dataclass
+class Usage:
+ monthly_gb: float
+
+
+@dataclass
+class Objective:
+ """Written down, weighted, and inspectable. If you cannot print it,
+ you do not have one."""
+ weights: dict
+ usage: Usage
+
+ def user_value(self, p: Plan) -> float:
+ headroom = p.included_gb / max(self.usage.monthly_gb, 0.1)
+ # Enough headroom is good; far too much is money wasted, not a benefit.
+ fit = 1.0 if 1.5 <= headroom <= 4 else 1 / (1 + abs(headroom - 2.5) / 4)
+ cost = 1 / (1 + p.price / 20)
+ effort = 1 / (1 + p.switching_hours)
+ return (
+ self.weights["fit"] * fit
+ + self.weights["cost"] * cost
+ + self.weights["effort"] * effort
+ )
+
+ def platform_value(self, p: Plan) -> float:
+ return p.margin
+
+ def describe(self) -> str:
+ parts = [f"{k} ({int(v * 100)}%)" for k, v in self.weights.items()]
+ return "ranked on: " + ", ".join(parts)
+
+
+@dataclass
+class Recommendation:
+ pick: Plan
+ diverged: bool
+ house_pick: Optional[Plan]
+ reason_house_lost: str
+ objective: str
+
+ def render(self) -> str:
+ lines = [f"Recommended: {self.pick.name} — £{self.pick.price:.0f}/mo", ""]
+ if self.diverged and self.house_pick is not None:
+ lines.append(
+ f" Heads up: we earn more when you pick {self.house_pick.name} "
+ f"(£{self.house_pick.price:.0f}/mo), and on our own numbers it "
+ f"doesn't fit. {self.reason_house_lost}"
+ )
+ lines.append("")
+ lines.append(f" {self.objective}")
+ return "\n".join(lines)
+
+
+def recommend(options: List[Plan], objective: Objective) -> Recommendation:
+ user_pick = max(options, key=objective.user_value)
+ house_pick = max(options, key=objective.platform_value)
+ diverged = user_pick is not house_pick
+
+ reason = ""
+ if diverged:
+ reason = (
+ f"You're using {objective.usage.monthly_gb:.0f}GB of a "
+ f"{house_pick.included_gb}GB plan."
+ )
+
+ return Recommendation(
+ pick=user_pick,
+ diverged=diverged,
+ house_pick=house_pick if diverged else None,
+ reason_house_lost=reason,
+ objective=objective.describe(),
+ )
+
+
+# ---- demo ----
+if __name__ == "__main__":
+ plans = [
+ Plan("Meridian Basic", price=18, included_gb=10, margin=0.10, switching_hours=0.5),
+ Plan("Meridian Pro", price=29, included_gb=40, margin=0.22, switching_hours=0.5),
+ Plan("Northwind Plus", price=42, included_gb=100, margin=0.48, switching_hours=2.0),
+ ]
+
+ objective = Objective(
+ weights={"fit": 0.6, "cost": 0.3, "effort": 0.1},
+ usage=Usage(monthly_gb=4),
+ )
+
+ print(recommend(plans, objective).render())
+
+ print("\n— the scores that produced it, kept separate on purpose —")
+ print(f" {'plan':<16} {'user':>6} {'house':>7}")
+ for p in plans:
+ print(f" {p.name:<16} {objective.user_value(p):>6.3f} {objective.platform_value(p):>7.2f}")
+
+ print("\n— a heavy user: no divergence, so nothing is disclosed —")
+ heavy = Objective(weights=objective.weights, usage=Usage(monthly_gb=60))
+ print(recommend(plans, heavy).render())