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>
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. |