# Implementation plan — §8.9, the model half of the fact box

Against `docs/superpowers/specs/2026-09-03-memoria-design.md` §8.9 and
amended decision 15. Branch `feat/model-fallback`, worktree
`../memoria-model-fallback`, cut from `origin/main` `1a7f988` (which is
the fact box, landed 2026-09-27) plus the decision-15 spec commit.

**This is memoria's first provider call.** Nothing in the binary talks to
a model today, so this plan builds the minimum of slice 2 that §8.9
needs — a chat client, a switch, a disclosure — and nothing of the
embedding half (gates 28-30 stay unbuilt).

---

## 0. Things the spec does not settle, which the code forces

Raised here so the reviewer rules on them rather than finding them in a
diff. Each has a proposed answer.

**0.1 The per-keystroke route.** `GET /search` renders the results region
on every debounced keystroke (`search.js`, `DEBOUNCE_MS = 120`), and §8.8
puts the box inside that region so page and fragment agree. A model
fallback there fires on typing pauses, not just on Enter.

*Proposed:* allow it, and bound it three ways. The call rides the request
context, so `search.js`'s `inflight.abort()` on a changed query cancels
the TensorX request rather than paying for it. A hard deadline — **800 ms,
§6.5's sibling deadline, reused rather than invented** — after which the
box renders without the model row. And the call only happens when AI is on
**and** the query names a valueless kind **and** deterministic extraction
found nothing for it. Rejected: model only on the full-page route, which
makes page and fragment disagree about the box — the thing §8.8 put the
box inside the define to prevent.

**0.2 Decision 4 says every answer names its provider. §8.9 does not say
the box does.** A model-sourced row is model output. *Proposed:* it
carries the 🤖 marker (AGENTS.md §Authorship) and the model id, and a
deterministic row does not — so the reader can tell which rows a checksum
vouched for and which a model pointed at. This is a spec gap to be closed
in the same branch.

**0.3 Address has a deterministic half, and Paul's rule says use it
first.** §12's struck row named it: "an eircode certifies the lines above
it". *Proposed: not in this branch.* It is a real improvement and the
right next step, but it is a second recogniser with its own label and
boundary problems, and §8.9 is already the first provider call. Recorded
in §12 as the next deterministic step for Address; the model handles
Address alone until it lands. Flagged because it is exactly the "as much
as possible deterministically" the ruling asks for, and deferring it is a
choice.

**0.4 A short returned string trivially passes the verbatim check.**
"Dublin" appears in half the corpus. *Proposed:* the model must name
**which** passage (by index) its span came from, the check is against
**that** passage only, and a span must carry at least two lines' worth of
structure — ≥ 12 characters and at least one comma, newline or digit.
Structural, stated, and not a validator.

---

## 1. `internal/memoria/fact` — the Address kind

`Kind` gains `Address`. The lexicon gains `address`, `postal address`,
`office address`, `where are we` — longest-first already ensures
`email address` still means Email. **No recogniser**: `Address` has no
entry in `recognisers`, so `Scan` never yields one and the deterministic
path is unchanged. `fact.Validated(k)` reports whether a kind has a
recogniser, which is what decides whether the model may be asked.

Tests: `address` primes Address; `email address` still primes Email only;
`Scan` never returns an Address.

## 2. `internal/memoria/provider` — new package, the TensorX client

The only code in memoria that dials a model.

- **Base URL is a constant**, `https://api.tensorx.ai/v1`. Decision 13: a
  configurable base URL is how an owner points memoria at a proxy while
  the page still says "one named provider". The test seam is an
  unexported constructor taking an `*http.Client` and a base, reachable
  only from the package's own tests — the `AppForTest` pattern.
- **Model id is a constant**, `deepseek/deepseek-v4-flash-0731` (§8.2's
  `model.cheap`). Swapping it is a code change and a review, per §8.2.
- `Extract(ctx, kind, passages []string) ([]Pointer, error)` where
  `Pointer{Passage int; Text string}`. JSON-mode request; the response is
  parsed strictly (`suite`'s strictjson pattern) and a malformed one is an
  error, never a partial result.
- **The response body is capped and the cap is detectable** — read one
  byte past it and error (AGENTS.md, the S3 lesson).
- **Nothing from the response is ever logged** — status code and a fixed
  string only (gate 31, AGENTS.md "a provider's response body is
  content"). The prompt is not logged either.
- **800 ms deadline** applied inside, on top of the caller's context.

## 3. `config.go` — on or off

- `MEMORIA_AI` — `on` or unset. Decision 4: opt-in per team, the owner's
  choice. Anything other than `on` or empty is a startup error, so a typo
  does not silently mean off.
- `MEMORIA_TENSORX_KEY` — required when `MEMORIA_AI=on`, **refused at
  startup** if AI is on and it is absent (loud), and ignored when AI is
  off. A key present with AI off does not turn AI on.

## 4. `store.go` — the fallback, after everything deterministic

In `addFacts`, after the per-kind deterministic rules have run:

1. For each wanted kind with **no validator** that produced **no** box,
   and only if a provider is configured:
2. send the ruled passages' texts (already opened for the deterministic
   pass — no second read) to `provider.Extract`;
3. for each returned pointer, **locate `Text` in passage `Passage` with
   `strings.Index`** — not anywhere, that passage; drop if absent or if it
   fails 0.4's structural bound;
4. slice the span from the document at that index — the model's own
   string is now discarded — and derive the label with `fact`'s label
   rules at that offset;
5. apply §8.8's identity, collision and cap rules unchanged;
6. mark the rows `Model: "deepseek/deepseek-v4-flash-0731"`.

Any provider error or timeout → no model rows, the page renders as it
would have, one log line carrying status and a fixed string.

## 5. Template, CSS

A model row gets `rst-fact-model` and renders `🤖` plus the model id
beside the citation. Deterministic rows unchanged.

## 6. README and §3.6

The README's first paragraph currently says no model call exists
anywhere and "no document ever leaves this instance". With `MEMORIA_AI=on`
that stops being true. **It has to say so before the code that makes it
false can ship** — §11: slice 2 "does not land before its disclosure copy
does". The wording must be reconciled with decision 16 (ZDR, treat as
local), which is proposed on `design/zdr-treat-as-local` and not merged;
this branch writes the conservative version and notes where decision 16
would soften it.

## 7. Gates

**39**, as §8.9 specifies, against an `httptest` stub provider:
presence (span in the passage → renders, document bytes, document label,
🤖); a span in no passage → no box and not logged; a span that is only in
a withheld passage → no box; the full citation tuple and the recorded
request contents; and **the deterministic-first ordering: a validated
kind never calls the provider at all**, asserted by counting requests at
the stub, not by reading the page.

*(An earlier version of this line listed "the model's text rendered
instead of the sliced span → red" as a mutation to catch. It cannot be
caught and does not matter: for an exact match the bytes are identical.
What matters is the coordinate, which the tuple assertions see.)*

Plus: AI off → the stub receives zero requests for every query (the
decision-5 floor, asserted as absence-of-traffic with a presence half
where AI on does call it); startup refusals for the config; the response
cap is detectable; a timeout renders the page without the row.

## 8. Review cadence

"Continuous review", per Paul. A review after each of: the spec (Astra,
running), steps 1-3 (the client and config — the egress surface, so the
highest-stakes review), steps 4-5, and the whole branch before merge.
Each by a model that did not write it, mutations chosen by the reviewer.
