oficina / memoria Public
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.Clientand a base, reachable only from the package's own tests — theAppForTestpattern. - Model id is a constant,
deepseek/deepseek-v4-flash-0731(§8.2'smodel.cheap). Swapping it is a code change and a review, per §8.2. Extract(ctx, kind, passages []string) ([]Pointer, error)wherePointer{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—onor unset. Decision 4: opt-in per team, the owner's choice. Anything other thanonor empty is a startup error, so a typo does not silently mean off.MEMORIA_TENSORX_KEY— required whenMEMORIA_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:
- For each wanted kind with no validator that produced no box, and only if a provider is configured:
- send the ruled passages' texts (already opened for the deterministic pass — no second read) to
provider.Extract; - for each returned pointer, locate
Textin passagePassagewithstrings.Index— not anywhere, that passage; drop if absent or if it fails 0.4's structural bound; - 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; - apply §8.8's identity, collision and cap rules unchanged;
- 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.