oficina / memoria Public

Plan for §8.9: memoria's first provider call

Builds the minimum of slice 2 that §8.9 needs — a TensorX chat client, an
explicit on switch, a disclosure — and none of the embedding half.

Four things the spec does not settle are raised up front with proposed
answers, so a reviewer rules on them rather than finding them in a diff:
the per-keystroke route (bounded by request-context cancellation, §6.5's
800 ms deadline, and the three-way trigger); decision 4's naming rule,
which §8.9 did not extend to model-sourced rows; the deterministic half of
Address (eircode-anchored), deferred and said so; and the short-string
hole in the verbatim guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d23695334b4e39a40af485beee274fd27fdcd7e1 parent 136679c
1 file changed, +160 −0
  • MODEL-FALLBACK-PLAN.md +160 −0
diff --git a/MODEL-FALLBACK-PLAN.md b/MODEL-FALLBACK-PLAN.md
new file mode 100644
index 0000000..1c7a145
--- /dev/null
+++ b/MODEL-FALLBACK-PLAN.md
@@ -0,0 +1,160 @@
+# 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_API_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 model's text rendered instead of the
+sliced span → red**; 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.
+
+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.