The model switch, and what pays for a third network package
MEMORIA_AI is the team owner's opt-in (decision 4), and only "on" or unset are accepted: "true", "On" and "off" are all refusals, because a typo must not be read as either answer. A key with AI unset does not turn AI on — a credential is not consent. MEMORIA_AI=on without a key refuses to boot. WireModel is one call from main with its logic and tests here, the WireSiblings shape, and the wiring gate now lists it: its absence is silent, since MEMORIA_AI=on would do nothing and nothing would say so. **TestNoOutboundNetworkInAppCode caught the provider package**, which is the gate working — it is new outbound network code. Its comment says a third exemption "should be harder still", and each existing one is paid for by a runtime guard stronger than the grep. The provider had not paid: it used a plain http.Client that would follow a redirect anywhere, and its test seam swapped in an unpinned client so the refusal path was never exercised. So the provider now carries notion's guard exactly: a transport pinned to TensorX's host and scheme, refusing everything else before dialling, and every redirect refused. The pin applies in tests too, against the test server's host, so the refusal path is the one under test. Asserted with requests counted at the listener, so "refused" means nothing arrived. The different-host case uses localhost against 127.0.0.1 — same scheme, same port — because notion's first version used an address that failed the scheme check too and stayed green with the host pin deleted. Mutation-tested: pass-through, host-only, and following redirects each go red on their own subtest. The gate comment records all three conditions for the exemption and says a fourth should need its own spec section. It cites gate 39 for the AI-off absence of traffic; gate 39 is the next commit's job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+323
−22
cmd/memoria/main.go+7 −0internal/memoria/config.go+29 −0internal/memoria/model.go+39 −0internal/memoria/model_config_test.go+82 −0internal/memoria/provider/provider.go+49 −7internal/memoria/provider/provider_test.go+83 −10internal/memoria/store.go+4 −0internal/memoriatest/gates_test.go+30 −5
CI failed — run details
CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
? amadan.net/oficina/memoria/cmd/memoria [no test files]
--- FAIL: TestCreditsMatchProductionDependencies (0.03s)
credits_inventory_test.go:36: production dependency graph unavailable exit status 1
FAIL
FAIL amadan.net/oficina/memoria/internal/memoria 0.806s
ok amadan.net/oficina/memoria/internal/memoria/chunk 0.007s
ok amadan.net/oficina/memoria/internal/memoria/fact 0.406s
? amadan.net/oficina/memoria/internal/memoria/icons [no test files]
ok amadan.net/oficina/memoria/internal/memoria/index 0.044s
ok amadan.net/oficina/memoria/internal/memoria/notion 0.026s
ok amadan.net/oficina/memoria/internal/memoria/provider 0.867s
ok amadan.net/oficina/memoria/internal/memoria/query 0.009s
ok amadan.net/oficina/memoria/internal/memoria/seal 0.006s
ok amadan.net/oficina/memoria/internal/memoria/sibling 3.028s
ok amadan.net/oficina/memoria/internal/memoriatest 1.864s
? amadan.net/oficina/memoria/tools/bench [no test files]
? amadan.net/oficina/memoria/tools/drive [no test files]
? amadan.net/oficina/memoria/tools/seed [no test files]
? amadan.net/oficina/memoria/tools/vendorassets [no test files]
FAIL
make: *** [Makefile:62: test] Error 1
amadan: step test failed: exit status 2
amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go| index 879a932..6b7858c 100644 |
| --- a/cmd/memoria/main.go |
| +++ b/cmd/memoria/main.go |
| @@ -107,6 +107,13 @@ func main() { |
| // |
| // nil dial: the module's team-bound transport does its own host check |
| // before dialling. The hook is for tests. |
| + // The model layer (§8.9): off unless MEMORIA_AI=on. One call; the logic |
| + // and its tests are in WireModel. |
| + if err := memoria.WireModel(app, cfg); err != nil { |
| + logger.Error("wire model layer", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| if err := memoria.WireSiblings(app, os.Getenv, nil); err != nil { |
| logger.Error("sibling roster", "err", err) |
| os.Exit(1) |
diff --git a/internal/memoria/config.go b/internal/memoria/config.go| index 35773f9..9d174fd 100644 |
| --- a/internal/memoria/config.go |
| +++ b/internal/memoria/config.go |
| @@ -31,6 +31,13 @@ type Config struct { |
| // `carlos logs` is platform-held storage we cannot grep, so that is |
| // a credential we cannot retract. Opt in explicitly for local dev. |
| AllowLoggedMail bool |
| + |
| + // AI is the team owner's opt-in to the model layer (decision 4). With |
| + // it off, nothing is ever sent to a provider and §8.9 does nothing. |
| + AI bool |
| + // TensorXKey is the provider credential. Required when AI is on, |
| + // ignored when it is off. Never logged, never in an error. |
| + TensorXKey string |
| } |
| // ErrMissing is returned when required platform config is absent. |
| @@ -89,6 +96,28 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| } |
| } |
| + // The model layer: off unless the owner says "on", in exactly those |
| + // words (decision 4 — opt-in per team). |
| + // |
| + // **Only "on" and "" are accepted.** A typo — "yes", "true", "On" — |
| + // is a refusal, not a silent off: an owner who meant to switch AI on |
| + // and got nothing would be told nothing, and an owner who meant to |
| + // switch it OFF and wrote "false" must not be read as meaning on. |
| + // Refusing both is the only reading that cannot be wrong. |
| + // |
| + // And a key with AI off does NOT turn AI on. Presence of a |
| + // credential is not consent; the switch is. |
| + switch ai := getenv("MEMORIA_AI"); ai { |
| + case "": |
| + case "on": |
| + c.AI = true |
| + if c.TensorXKey = getenv("MEMORIA_TENSORX_API_KEY"); c.TensorXKey == "" { |
| + return c, fmt.Errorf("%w: MEMORIA_TENSORX_API_KEY (MEMORIA_AI=on names a provider but gives no credential for it)", ErrMissing) |
| + } |
| + default: |
| + return c, fmt.Errorf("memoria: MEMORIA_AI must be \"on\" or unset; refusing to guess what %q means", ai) |
| + } |
| + |
| // Origin is the only one with a default, and only because the |
| // default is a loopback address that cannot be reached from |
| // anywhere else. It decides the CSRF yardstick and the cookie |
diff --git a/internal/memoria/model.go b/internal/memoria/model.go| new file mode 100644 |
| index 0000000..9109e52 |
| --- /dev/null |
| +++ b/internal/memoria/model.go |
| @@ -0,0 +1,39 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/provider" |
| +) |
| + |
| +// Extractor is what §8.9 asks of a model: every postal address in the |
| +// sent passages, as pointers into them. provider.Client satisfies it; so |
| +// does a test's recorder, which is how gate 39 sees exactly what was sent. |
| +type Extractor interface { |
| + ExtractAddresses(ctx context.Context, passages []string) ([]provider.Pointer, error) |
| +} |
| + |
| +// WireModel switches the model layer on when the owner has asked for it, |
| +// and leaves it off otherwise. |
| +// |
| +// One call from main, with the logic here where it has tests — the |
| +// WireSiblings shape, for the reason AGENTS.md gives: a gate over main's |
| +// text can see that a call is spelled and never that it is reached, so |
| +// main should hold nothing worth getting wrong. The wiring gate lists |
| +// this call because its absence is silent: MEMORIA_AI=on would do nothing |
| +// at all, and nothing would say so. |
| +func WireModel(a *app, cfg Config) error { |
| + if !cfg.AI { |
| + return nil |
| + } |
| + c, err := provider.New(cfg.TensorXKey) |
| + if err != nil { |
| + return err |
| + } |
| + a.store.extractor = c |
| + // Named, because decision 4 names the provider everywhere, and this |
| + // is the line an operator reads to learn that content will leave. |
| + // The key is not in it. |
| + a.logger.Info("model layer on", "provider", provider.Name, "model", provider.Model) |
| + return nil |
| +} |
diff --git a/internal/memoria/model_config_test.go b/internal/memoria/model_config_test.go| new file mode 100644 |
| index 0000000..7153086 |
| --- /dev/null |
| +++ b/internal/memoria/model_config_test.go |
| @@ -0,0 +1,82 @@ |
| +package memoria |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "io" |
| + "log/slog" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/provider" |
| +) |
| + |
| +func baseEnv() map[string]string { |
| + return map[string]string{ |
| + "MEMORIA_DEK": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), |
| + "MEMORIA_INSTANCE_KEY": "test-only-instance", |
| + "MEMORIA_OWNERS": "alice@example.test", |
| + "MEMORIA_ALLOW_LOGGED_MAIL": "1", |
| + } |
| +} |
| + |
| +// Decision 4: the model layer is the team owner's opt-in, and it is |
| +// switched by one word. Each refusal below is paired with its presence |
| +// half, because a switch that refuses everything passes every refusal. |
| +func TestTheModelSwitch(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + ai, key string |
| + wantAI bool |
| + wantErr string |
| + }{ |
| + {"unset is off", "", "", false, ""}, |
| + // Presence of a credential is not consent; the switch is. |
| + {"a key with AI unset is still off", "", "k", false, ""}, |
| + {"on with a key is on", "on", "k", true, ""}, |
| + {"on without a key is refused", "on", "", false, "MEMORIA_TENSORX_API_KEY"}, |
| + // A typo must not be read as either answer. |
| + {"'true' is refused, not on", "true", "k", false, "MEMORIA_AI"}, |
| + {"'On' is refused, not on", "On", "k", false, "MEMORIA_AI"}, |
| + {"'off' is refused, not off", "off", "k", false, "MEMORIA_AI"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + env := baseEnv() |
| + env["MEMORIA_AI"], env["MEMORIA_TENSORX_API_KEY"] = c.ai, c.key |
| + cfg, err := LoadConfig(func(k string) string { return env[k] }) |
| + if c.wantErr != "" { |
| + if err == nil || !strings.Contains(err.Error(), c.wantErr) { |
| + t.Fatalf("err = %v, want one naming %s", err, c.wantErr) |
| + } |
| + // The credential must never be in the refusal. |
| + if c.key != "" && strings.Contains(err.Error(), c.key+"\"") { |
| + t.Errorf("refusal carries the key: %v", err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if cfg.AI != c.wantAI { |
| + t.Errorf("AI = %v, want %v", cfg.AI, c.wantAI) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// WireModel sets an extractor exactly when AI is on, and it is TensorX. |
| +func TestWireModelOnlyWhenOn(t *testing.T) { |
| + a := &app{store: &Store{}, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| + if err := WireModel(a, Config{AI: false, TensorXKey: "k"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a.store.extractor != nil { |
| + t.Fatal("AI off, but an extractor was wired: content could leave") |
| + } |
| + if err := WireModel(a, Config{AI: true, TensorXKey: "k"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := a.store.extractor.(*provider.Client); !ok { |
| + t.Fatalf("AI on wired %T, want *provider.Client", a.store.extractor) |
| + } |
| +} |
diff --git a/internal/memoria/provider/provider.go b/internal/memoria/provider/provider.go| index ed324bc..53bbe2b 100644 |
| --- a/internal/memoria/provider/provider.go |
| +++ b/internal/memoria/provider/provider.go |
| @@ -62,6 +62,9 @@ var ( |
| // silent omission §8.9 says nothing can detect, and here, for once, |
| // something can. |
| ErrTruncated = errors.New("provider: response was truncated") |
| + // ErrForbiddenHost is a request to anywhere but the host the client |
| + // was built for, or any redirect at all. |
| + ErrForbiddenHost = errors.New("provider: refused a host or redirect other than the pinned one") |
| ) |
| // Pointer is one span a model says is an address: which sent passage, |
| @@ -82,15 +85,54 @@ type Client struct { |
| // New returns a client for TensorX. It takes a key and nothing else: |
| // there is no exported way to change where it dials. |
| -func New(key string) *Client { |
| - return &Client{hc: &http.Client{}, base: BaseURL, key: key} |
| +func New(key string) (*Client, error) { |
| + return newClient(key, BaseURL) |
| } |
| -// newForTest is the one seam that changes the destination, and it is |
| -// unexported so only this package's tests can reach it — the AppForTest |
| -// pattern, and for the reason decision 13 gives about BaseURL. |
| -func newForTest(hc *http.Client, base, key string) *Client { |
| - return &Client{hc: hc, base: base, key: key} |
| +// newClient builds a client pinned to base's host and scheme. |
| +// |
| +// **The pin is applied in tests too**, against the test server's host — |
| +// the notion package's rule, for its reason: a seam that swapped in an |
| +// unpinned client would leave the refusal path untested while every |
| +// test passed. Tests reach this; production reaches it only through New. |
| +// |
| +// This, and not the source grep, is what pays for this package's place |
| +// on TestNoOutboundNetworkInAppCode's list. A grep cannot tell an |
| +// http.Client pointed at TensorX from one pointed anywhere else; the |
| +// transport below refuses every other host, and TestClientDialsOnlyTensorX |
| +// asserts it against this constructor. |
| +func newClient(key, base string) (*Client, error) { |
| + probe, err := http.NewRequest(http.MethodPost, base, nil) |
| + if err != nil || probe.URL.Hostname() == "" { |
| + return nil, ErrForbiddenHost |
| + } |
| + return &Client{ |
| + base: base, |
| + key: key, |
| + hc: &http.Client{ |
| + Transport: &pinnedTransport{host: probe.URL.Hostname(), scheme: probe.URL.Scheme, next: http.DefaultTransport}, |
| + // Refuse every redirect. There is no legitimate one on this |
| + // API, and following one hands the bearer key — and every |
| + // passage in the body — to wherever it points. Go drops the |
| + // Authorization header across hosts, but not across a |
| + // same-host redirect, and never drops the body's intent. |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return ErrForbiddenHost }, |
| + }, |
| + }, nil |
| +} |
| + |
| +// pinnedTransport refuses every host and scheme but the one it was built |
| +// for, on every request, before anything is dialled. |
| +type pinnedTransport struct { |
| + host, scheme string |
| + next http.RoundTripper |
| +} |
| + |
| +func (p *pinnedTransport) RoundTrip(r *http.Request) (*http.Response, error) { |
| + if r.URL.Hostname() != p.host || r.URL.Scheme != p.scheme { |
| + return nil, ErrForbiddenHost |
| + } |
| + return p.next.RoundTrip(r) |
| } |
| // instructions are the whole of what memoria asks of the model. |
diff --git a/internal/memoria/provider/provider_test.go b/internal/memoria/provider/provider_test.go| index ffee493..72be326 100644 |
| --- a/internal/memoria/provider/provider_test.go |
| +++ b/internal/memoria/provider/provider_test.go |
| @@ -53,7 +53,13 @@ func newStub(t *testing.T, status int, body string) *stub { |
| return s |
| } |
| -func (s *stub) client() *Client { return newForTest(s.srv.Client(), s.srv.URL, "test-key") } |
| +func (s *stub) client() *Client { |
| + c, err := newClient("test-key", s.srv.URL) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return c |
| +} |
| // reply wraps model content in an OpenAI-compatible envelope. |
| func reply(content, finish string) string { |
| @@ -183,13 +189,80 @@ func TestCancellationReachesTheProvider(t *testing.T) { |
| } |
| } |
| -// Decision 13: New dials TensorX and nowhere else. The only way to |
| -// change the destination is the unexported test seam. |
| -func TestNewDialsTensorXOnly(t *testing.T) { |
| - if c := New("k"); c.base != BaseURL { |
| - t.Errorf("New dials %q, want %q", c.base, BaseURL) |
| - } |
| - if !strings.HasPrefix(BaseURL, "https://api.tensorx.ai/") { |
| - t.Errorf("BaseURL = %q is not TensorX", BaseURL) |
| - } |
| +// **What pays for this package's exemption from the outbound-network |
| +// gate.** Decision 13 makes TensorX the only provider; this makes the |
| +// client unable to reach anything else, whatever its base URL says — a |
| +// mis-set constant, a future config field, a redirect. |
| +// |
| +// Each subtest counts requests at the stub, so "refused" means nothing |
| +// reached the listener, and not merely that an error came back from |
| +// somewhere after a request had already left. |
| +// |
| +// The different-host case uses "localhost" against a server pinned to |
| +// "127.0.0.1": same scheme, same port, a live listener, so ONLY the host |
| +// pin can refuse it. notion's first version of this test used an address |
| +// that also failed the scheme check, and deleting the host pin left it |
| +// green — a neighbouring error path supplying the failure. |
| +func TestClientDialsOnlyTensorX(t *testing.T) { |
| + t.Run("New is pinned to TensorX over https", func(t *testing.T) { |
| + c, err := New("k") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pin := c.hc.Transport.(*pinnedTransport) |
| + if c.base != BaseURL || pin.host != "api.tensorx.ai" || pin.scheme != "https" { |
| + t.Errorf("New dials %q pinned to %s://%s", c.base, pin.scheme, pin.host) |
| + } |
| + }) |
| + |
| + t.Run("another host is refused before dialling", func(t *testing.T) { |
| + s := newStub(t, 200, reply(`{"addresses":[]}`, "stop")) |
| + c := s.client() |
| + c.base = strings.Replace(s.srv.URL, "127.0.0.1", "localhost", 1) |
| + if c.base == s.srv.URL { |
| + t.Fatal("could not build a different-host URL; this subtest proves nothing") |
| + } |
| + if _, err := c.ExtractAddresses(context.Background(), []string{"x"}); !errors.Is(err, ErrForbiddenHost) { |
| + t.Fatalf("err = %v, want ErrForbiddenHost", err) |
| + } |
| + if n := s.calls.Load(); n != 0 { |
| + t.Errorf("the stub received %d requests; the passages left", n) |
| + } |
| + }) |
| + |
| + t.Run("another scheme is refused before dialling", func(t *testing.T) { |
| + s := newStub(t, 200, reply(`{"addresses":[]}`, "stop")) |
| + c := s.client() |
| + c.base = strings.Replace(s.srv.URL, "http://", "https://", 1) |
| + if _, err := c.ExtractAddresses(context.Background(), []string{"x"}); !errors.Is(err, ErrForbiddenHost) { |
| + t.Fatalf("err = %v, want ErrForbiddenHost", err) |
| + } |
| + if n := s.calls.Load(); n != 0 { |
| + t.Errorf("the stub received %d requests", n) |
| + } |
| + }) |
| + |
| + // A redirect is refused outright, even to the same host: following it |
| + // would resend the body — every passage — and, same-host, the key. |
| + t.Run("a redirect is refused", func(t *testing.T) { |
| + var hits atomic.Int32 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if hits.Add(1) == 1 { |
| + http.Redirect(w, r, "/elsewhere", http.StatusTemporaryRedirect) |
| + return |
| + } |
| + _, _ = io.WriteString(w, reply(`{"addresses":[]}`, "stop")) |
| + })) |
| + t.Cleanup(srv.Close) |
| + c, err := newClient("k", srv.URL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.ExtractAddresses(context.Background(), []string{"x"}); !errors.Is(err, ErrForbiddenHost) { |
| + t.Fatalf("err = %v, want ErrForbiddenHost", err) |
| + } |
| + if n := hits.Load(); n != 1 { |
| + t.Errorf("server saw %d requests; the redirect was followed", n) |
| + } |
| + }) |
| } |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index 5f8fa23..88f811c 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -30,6 +30,10 @@ type Store struct { |
| // breaker that quietly stops counting reads as "no anomaly" forever, |
| // which is the failure §8.1 spends a paragraph on. |
| logger *slog.Logger |
| + // extractor is §8.9's model. **nil means the model layer is off**, which |
| + // is decision 5's floor and the default: nothing is sent anywhere. Set |
| + // only by WireModel, only when the owner said MEMORIA_AI=on. |
| + extractor Extractor |
| // custody is who memoria asks about a sibling's passages. |
| // |
| // **nil is a configuration, not a bug**, and it is slice 0's: no |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index f8ac0b4..ee7341f 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -201,7 +201,7 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) { |
| for _, f := range forbidden { |
| pats = append(pats, regexp.MustCompile(f)) |
| } |
| - // EXACTLY TWO packages may reach the network, and this is the list. |
| + // EXACTLY THREE packages may reach the network, and this is the list. |
| // |
| // It is a list rather than a deleted gate because the connector had |
| // to dial api.notion.com and the cheapest way to make this test pass |
| @@ -228,12 +228,36 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) { |
| // |
| // A third entry should be harder still. Two packages that may dial is |
| // two places to read when §3.2's leak list is next revised. |
| + // |
| + // **The third entry, 2026-09-27: the model provider.** Harder to |
| + // justify than either before it, because what it sends is the team's |
| + // passages to a party outside the suite — §3.2's largest leak class, |
| + // not a sibling inside the trust domain. It is admitted on three |
| + // conditions, and each is a thing a reviewer can check rather than a |
| + // sentence to take on trust: |
| + // |
| + // - Paul ruled the egress in (decision 15 as amended, §8.9) under |
| + // decision 4's terms: per-team opt-in, one named provider, a working |
| + // floor when it is off. memoria.TestTheModelSwitch holds the opt-in. |
| + // - The same runtime guard the other two carry: provider.pinnedTransport |
| + // refuses every host and scheme but TensorX's and every redirect, |
| + // before dialling, asserted against the real constructor by |
| + // provider.TestClientDialsOnlyTensorX with requests counted at the |
| + // listener. Mutation-tested 2026-09-27: passing everything through, |
| + // dropping the scheme check, and following redirects each go red. |
| + // - Nothing leaves when AI is off, asserted as an absence of traffic |
| + // WITH its presence half (gate 39). |
| + // |
| + // **A fourth entry should need a spec section of its own.** Three is |
| + // three places to read when §3.2 is next revised, and the third is the |
| + // one that sends content outside the suite. |
| allowedNetworkDirs := []string{ |
| filepath.Join("..", "memoria", "notion"), |
| filepath.Join("..", "memoria", "sibling"), |
| + filepath.Join("..", "memoria", "provider"), |
| } |
| - if len(allowedNetworkDirs) != 2 { |
| - t.Fatalf("%d packages may reach the network, want exactly 2: %v", |
| + if len(allowedNetworkDirs) != 3 { |
| + t.Fatalf("%d packages may reach the network, want exactly 3: %v", |
| len(allowedNetworkDirs), allowedNetworkDirs) |
| } |
| exempt := func(p string) bool { |
| @@ -496,8 +520,9 @@ func TestProductionDoesNotUseTheTestConstructor(t *testing.T) { |
| // only has to notice the call disappearing. The names below are |
| // the ones whose absence is silent — UseAuth serves every route |
| // unauthenticated, WireSiblings leaves every sibling passage |
| - // withheld while blaming apps that are fine. |
| - for _, call := range []string{"UseAuth", "WireSiblings"} { |
| + // withheld while blaming apps that are fine, and WireModel leaves |
| + // MEMORIA_AI=on doing nothing, with nothing to say so. |
| + for _, call := range []string{"UseAuth", "WireSiblings", "WireModel"} { |
| if !callsFunction(t, p, raw, call) { |
| t.Errorf("%s builds the app but never calls %s", p, call) |
| } |