| 1 | # Implementation plan — §8.9, the model half of the fact box |
| 2 | |
| 3 | Against `docs/superpowers/specs/2026-09-03-memoria-design.md` §8.9 and |
| 4 | amended decision 15. Branch `feat/model-fallback`, worktree |
| 5 | `../memoria-model-fallback`, cut from `origin/main` `1a7f988` (which is |
| 6 | the fact box, landed 2026-09-27) plus the decision-15 spec commit. |
| 7 | |
| 8 | **This is memoria's first provider call.** Nothing in the binary talks to |
| 9 | a model today, so this plan builds the minimum of slice 2 that §8.9 |
| 10 | needs — a chat client, a switch, a disclosure — and nothing of the |
| 11 | embedding half (gates 28-30 stay unbuilt). |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## 0. Things the spec does not settle, which the code forces |
| 16 | |
| 17 | Raised here so the reviewer rules on them rather than finding them in a |
| 18 | diff. Each has a proposed answer. |
| 19 | |
| 20 | **0.1 The per-keystroke route.** `GET /search` renders the results region |
| 21 | on every debounced keystroke (`search.js`, `DEBOUNCE_MS = 120`), and §8.8 |
| 22 | puts the box inside that region so page and fragment agree. A model |
| 23 | fallback there fires on typing pauses, not just on Enter. |
| 24 | |
| 25 | *Proposed:* allow it, and bound it three ways. The call rides the request |
| 26 | context, so `search.js`'s `inflight.abort()` on a changed query cancels |
| 27 | the TensorX request rather than paying for it. A hard deadline — **800 ms, |
| 28 | §6.5's sibling deadline, reused rather than invented** — after which the |
| 29 | box renders without the model row. And the call only happens when AI is on |
| 30 | **and** the query names a valueless kind **and** deterministic extraction |
| 31 | found nothing for it. Rejected: model only on the full-page route, which |
| 32 | makes page and fragment disagree about the box — the thing §8.8 put the |
| 33 | box inside the define to prevent. |
| 34 | |
| 35 | **0.2 Decision 4 says every answer names its provider. §8.9 does not say |
| 36 | the box does.** A model-sourced row is model output. *Proposed:* it |
| 37 | carries the 🤖 marker (AGENTS.md §Authorship) and the model id, and a |
| 38 | deterministic row does not — so the reader can tell which rows a checksum |
| 39 | vouched for and which a model pointed at. This is a spec gap to be closed |
| 40 | in the same branch. |
| 41 | |
| 42 | **0.3 Address has a deterministic half, and Paul's rule says use it |
| 43 | first.** §12's struck row named it: "an eircode certifies the lines above |
| 44 | it". *Proposed: not in this branch.* It is a real improvement and the |
| 45 | right next step, but it is a second recogniser with its own label and |
| 46 | boundary problems, and §8.9 is already the first provider call. Recorded |
| 47 | in §12 as the next deterministic step for Address; the model handles |
| 48 | Address alone until it lands. Flagged because it is exactly the "as much |
| 49 | as possible deterministically" the ruling asks for, and deferring it is a |
| 50 | choice. |
| 51 | |
| 52 | **0.4 A short returned string trivially passes the verbatim check.** |
| 53 | "Dublin" appears in half the corpus. *Proposed:* the model must name |
| 54 | **which** passage (by index) its span came from, the check is against |
| 55 | **that** passage only, and a span must carry at least two lines' worth of |
| 56 | structure — ≥ 12 characters and at least one comma, newline or digit. |
| 57 | Structural, stated, and not a validator. |
| 58 | |
| 59 | --- |
| 60 | |
| 61 | ## 1. `internal/memoria/fact` — the Address kind |
| 62 | |
| 63 | `Kind` gains `Address`. The lexicon gains `address`, `postal address`, |
| 64 | `office address`, `where are we` — longest-first already ensures |
| 65 | `email address` still means Email. **No recogniser**: `Address` has no |
| 66 | entry in `recognisers`, so `Scan` never yields one and the deterministic |
| 67 | path is unchanged. `fact.Validated(k)` reports whether a kind has a |
| 68 | recogniser, which is what decides whether the model may be asked. |
| 69 | |
| 70 | Tests: `address` primes Address; `email address` still primes Email only; |
| 71 | `Scan` never returns an Address. |
| 72 | |
| 73 | ## 2. `internal/memoria/provider` — new package, the TensorX client |
| 74 | |
| 75 | The only code in memoria that dials a model. |
| 76 | |
| 77 | - **Base URL is a constant**, `https://api.tensorx.ai/v1`. Decision 13: a |
| 78 | configurable base URL is how an owner points memoria at a proxy while |
| 79 | the page still says "one named provider". The test seam is an |
| 80 | unexported constructor taking an `*http.Client` and a base, reachable |
| 81 | only from the package's own tests — the `AppForTest` pattern. |
| 82 | - **Model id is a constant**, `deepseek/deepseek-v4-flash-0731` (§8.2's |
| 83 | `model.cheap`). Swapping it is a code change and a review, per §8.2. |
| 84 | - `Extract(ctx, kind, passages []string) ([]Pointer, error)` where |
| 85 | `Pointer{Passage int; Text string}`. JSON-mode request; the response is |
| 86 | parsed strictly (`suite`'s strictjson pattern) and a malformed one is an |
| 87 | error, never a partial result. |
| 88 | - **The response body is capped and the cap is detectable** — read one |
| 89 | byte past it and error (AGENTS.md, the S3 lesson). |
| 90 | - **Nothing from the response is ever logged** — status code and a fixed |
| 91 | string only (gate 31, AGENTS.md "a provider's response body is |
| 92 | content"). The prompt is not logged either. |
| 93 | - **800 ms deadline** applied inside, on top of the caller's context. |
| 94 | |
| 95 | ## 3. `config.go` — on or off |
| 96 | |
| 97 | - `MEMORIA_AI` — `on` or unset. Decision 4: opt-in per team, the owner's |
| 98 | choice. Anything other than `on` or empty is a startup error, so a typo |
| 99 | does not silently mean off. |
| 100 | - `MEMORIA_TENSORX_KEY` — required when `MEMORIA_AI=on`, **refused at |
| 101 | startup** if AI is on and it is absent (loud), and ignored when AI is |
| 102 | off. A key present with AI off does not turn AI on. |
| 103 | |
| 104 | ## 4. `store.go` — the fallback, after everything deterministic |
| 105 | |
| 106 | In `addFacts`, after the per-kind deterministic rules have run: |
| 107 | |
| 108 | 1. For each wanted kind with **no validator** that produced **no** box, |
| 109 | and only if a provider is configured: |
| 110 | 2. send the ruled passages' texts (already opened for the deterministic |
| 111 | pass — no second read) to `provider.Extract`; |
| 112 | 3. for each returned pointer, **locate `Text` in passage `Passage` with |
| 113 | `strings.Index`** — not anywhere, that passage; drop if absent or if it |
| 114 | fails 0.4's structural bound; |
| 115 | 4. slice the span from the document at that index — the model's own |
| 116 | string is now discarded — and derive the label with `fact`'s label |
| 117 | rules at that offset; |
| 118 | 5. apply §8.8's identity, collision and cap rules unchanged; |
| 119 | 6. mark the rows `Model: "deepseek/deepseek-v4-flash-0731"`. |
| 120 | |
| 121 | Any provider error or timeout → no model rows, the page renders as it |
| 122 | would have, one log line carrying status and a fixed string. |
| 123 | |
| 124 | ## 5. Template, CSS |
| 125 | |
| 126 | A model row gets `rst-fact-model` and renders `🤖` plus the model id |
| 127 | beside the citation. Deterministic rows unchanged. |
| 128 | |
| 129 | ## 6. README and §3.6 |
| 130 | |
| 131 | The README's first paragraph currently says no model call exists |
| 132 | anywhere and "no document ever leaves this instance". With `MEMORIA_AI=on` |
| 133 | that stops being true. **It has to say so before the code that makes it |
| 134 | false can ship** — §11: slice 2 "does not land before its disclosure copy |
| 135 | does". The wording must be reconciled with decision 16 (ZDR, treat as |
| 136 | local), which is proposed on `design/zdr-treat-as-local` and not merged; |
| 137 | this branch writes the conservative version and notes where decision 16 |
| 138 | would soften it. |
| 139 | |
| 140 | ## 7. Gates |
| 141 | |
| 142 | **39**, as §8.9 specifies, against an `httptest` stub provider: |
| 143 | presence (span in the passage → renders, document bytes, document label, |
| 144 | 🤖); a span in no passage → no box and not logged; a span that is only in |
| 145 | a withheld passage → no box; the full citation tuple and the recorded |
| 146 | request contents; and **the deterministic-first ordering: a validated |
| 147 | kind never calls the provider at all**, asserted by counting requests at |
| 148 | the stub, not by reading the page. |
| 149 | |
| 150 | *(An earlier version of this line listed "the model's text rendered |
| 151 | instead of the sliced span → red" as a mutation to catch. It cannot be |
| 152 | caught and does not matter: for an exact match the bytes are identical. |
| 153 | What matters is the coordinate, which the tuple assertions see.)* |
| 154 | |
| 155 | Plus: AI off → the stub receives zero requests for every query (the |
| 156 | decision-5 floor, asserted as absence-of-traffic with a presence half |
| 157 | where AI on does call it); startup refusals for the config; the response |
| 158 | cap is detectable; a timeout renders the page without the row. |
| 159 | |
| 160 | ## 8. Review cadence |
| 161 | |
| 162 | "Continuous review", per Paul. A review after each of: the spec (Astra, |
| 163 | running), steps 1-3 (the client and config — the egress surface, so the |
| 164 | highest-stakes review), steps 4-5, and the whole branch before merge. |
| 165 | Each by a model that did not write it, mutations chosen by the reviewer. |
| 166 | |