Skip to content

Data-egress matrix

Source of truth: scripts/guardrails/egress-matrix.json. One owned corpus × provider × purpose policy that every egress-enforcing surface reads, replacing per-tool hard-coded policy. It exists because four ad-hoc egress decisions (ollama-only → DeepSeek override → GLM-Clippings exception → Alibaba embeddings) accumulated across four documents with no single owner, producing a live contradiction (the graphify fence as specced denied Alibaba for luna content, while the embedding pilot sent luna content to Alibaba for embeddings). (That contradiction is now moot: DeepSeek and Alibaba were de-listed entirely — see the provider-policy note in Semantics.)

Semantics

  • Evaluation: first-match-wins over rules, * wildcards, then default (which is deny — fail-closed).
  • Verdicts: allow · allow+log (must append a ledger line) · conditional (allow only while the rule’s condition holds) · deny · pending-operator (evaluates as deny; a named operator decision flips the cell via a one-line PR on the JSON).
  • Reference implementation: the evaluate() function in scripts/guardrails/test-egress-matrix.mjs — consumers in other languages copy those semantics.
  • Load-bearing nuance: embedding, rerank, and vision-embedding send FULL content (text or images) to the provider. An “embedding lane” is content egress, not metadata egress, and is gated accordingly.
  • enrichment is content egress like extraction — full note bodies leave the machine, plus the vault-wide top-200 tag vocabulary (tag names only, derived from all vault markdown) sent with each request as the allowed-tags list. Its luna-personal × deepseek cell was ratified 2026-07-10 then REVERSED 2026-07-22: now deny — DeepSeek de-listed (bad results). The GLM enrichment cell that replaced it (luna-personal × zai-glm × enrichment) was itself REVERSED 2026-08-29: now deny — see the GLM de-listing below. No sanctioned CN enrichment lane remains; enrich-chat-notes.py ships --provider claude.
  • luna-personal × zai-glm × extraction was ratified 2026-07-17 (operator) as allow+log, and REVERSED 2026-08-29: now deny — the GLM lane was dropped (below). The ratification reasoning is kept here because the matrix records why a vendor was ever permitted, not only its current verdict; read the rest of this bullet as history. This WIDENS the permitted provider set — full luna non-Clippings note bodies may now leave the machine to a second, additional vendor (Z.ai) that could not receive them before. What it does not change is the corpus, purpose, region (CN), or content scope: those are identical to the 2026-07-05 DeepSeek extraction override it sits beside, so the kind of exposure is unchanged even though the number of vendors holding it goes from one to two. Both facts matter to a privacy review; neither cancels the other. Ratified because a measured A/B on an identical corpus put glm-5.2 ahead of the incumbent on every axis (link recall 63–79% vs DeepSeek’s 26%, file coverage 100% vs 95%) at flat-rate Coding-Plan cost, against $3.14 per full-vault DeepSeek run — the vault was paying for the weaker backend purely because the matrix had no GLM cell. Scope is deliberately narrow:
    • luna-personal × zai-glm × inference stayed deny — extraction only, and it still denies by default (the GLM de-listing added explicit deny rows only for the four cells that were open, it did not invent new rows).
    • salus × * × * stays deny, hard — no override can flip it. The one carve-out is the explicit salus × openai-codex × inference allow row ahead of it (operator ruling 2026-10-08).
    • Not a default. refresh-graph-map.sh keeps BACKEND=claude-cli (the claude-only adopter story). GLM is a per-run opt-in for an operator who has a Coding Plan; an adopter without one is never expected to have it.
  • Provider policy — DeepSeek + Alibaba de-listed (2026-07-22). The sanctioned provider set for vault/handover egress is GLM (zai-glm) + Claude (anthropic) + Codex (openai-codex). DeepSeek and Alibaba were dropped (bad results, operator judgment): every DeepSeek vault/handover extraction and enrichment cell (the 2026-07-05 extraction override, enrichment; the station opt-in inference cells behind HIMMEL_DEEPSEEK_INFERENCE_OK=1 stay conditional) and every Alibaba cell (the five embedding/rerank pilot cells + the brief-scoped handover-state × alibaba × inference cell) is now explicit deny. Both providers stay allow on himmel-code via its * × wildcard — the de-listing is about private-content egress, not public code, so hard:true is NOT used (that stays reserved for salus + gemini-keys-unset). The reversal is legible (each cell’s why records it) and reversible (a future operator decision could re-add a cell). Salus PHI egress is untouched.
  • Provider policy — GLM (zai-glm) de-listed (2026-08-29). The GLM lane was resolved as DROP (Coding Plan auto-renew cancelled 2026-08-12; the plan lapsed 2026-08-17) but its reversal branch never landed, leaving four cells live for 12 days. They are now explicit deny on the same pattern as the DeepSeek/Alibaba de-listing: luna-personal × zai-glm × extraction, luna-personal × zai-glm × enrichment, luna-clippings × zai-glm × extraction (the narrow G2.3 ladder exception) and handover-state × zai-glm × inference (the brief-scoped worker cell). (At the time, Kimi (moonshot) was adopted as the replacement CN extraction lane — see the kimi/moonshot retirement bullet below for why that no longer holds.) Resist the urge to compress what remains into one provider list: the matrix is keyed by corpus x provider x purpose, and after this reversal no provider is broadly authorized across both corpora. Cell by cell, what stayed open at the time was: luna-personal and luna-clippings extraction — moonshot (allow+log, since retired) plus anthropic/local-ollama; enrichment on either corpus — anthropic/local-ollama only, no CN lane at all; handover-state inference — openai-codex (conditional, brief-scoped) and openrouter plus anthropic/local-ollama. Kimi held NO handover-state cell and no enrichment cell; Codex holds NO vault cell. This was a record-integrity fix, not an incident: nothing routed to GLM — no GLM lane appears in scripts/lanes/resolve.mjs — but a live allow+log cell is exactly what a later worker would cite as pre-existing authorization. As with DeepSeek/Alibaba the rows are deny, not deleted (a deleted row falls through to default: deny and loses the history), not hard, and himmel-code × zai-glm stays allow via the wildcard — public code, not private content, so refresh-graph-map.sh’s --backend glm remap still legitimately serves it. Consumer sweep: graphify-fence.sh loses the luna-clippings × zai-glm conditional branch and its GRAPHIFY_CLIPPINGS_GLM_OK opt-in (the cell it opened is now deny, so the flag opened nothing); enrich-chat-notes.py keeps its glm provider entry but its egress gate now refuses it, exactly as it already refuses deepseek.
  • Provider policy — kimi/moonshot retired (operator ruling 2026-08-24/2026-09-15: there is no kimi backend). Unlike the DeepSeek/Alibaba/GLM de-listings above, the moonshot provider entry and its two allow+log rows (luna-personal × moonshot × extraction, luna-clippings × moonshot × extraction) are DELETED outright, not reversed to explicit deny — the operator retired the backend itself, not just its egress authorization, and kimi was never de-listed for bad results the way DeepSeek/Alibaba/GLM were, so there is no “why a vendor was ever permitted” history worth preserving as a live row. moonshot now falls through to default: deny exactly like any other unclassified provider. No sanctioned CN/cloud extraction lane remains for luna-personal or luna-clippings — anthropic (the operating substrate) is the only live allow cell either corpus has for extraction, and it is a plain allow, not allow+log. graphmap-cadence.sh’s weekly semantic refresh reverts to BACKEND="claude-cli" (its earlier default), drawing the operator’s own interactive 5h/weekly subscription usage budget — gated by bank-preflight.sh inside refresh-graph-map.sh, not by this matrix. graphify-fence.sh drops _map_kimi_endpoint and its kimi -> moonshot mapping entirely; --backend kimi now hits the same undeclared-literal-provider fail-closed path any other unrecognized backend name does.

Corpus resolution (shared primitives, not new ones)

Corpus membership resolves through the SAME primitives the live guards already use: .salus root markers and the ~/.config/claude-glm/{phi-roots,egress-denylist} list files (scripts/telegram/phi-egress-guard.ts — renamed from glm-guard.ts — and scripts/hermes/assets/parity_guard.py), plus vault/state roots from env or config — never hardcoded absolute paths. The matrix defines policy; membership resolution stays with the guards.

List files are read line by line with surrounding whitespace (and a CRLF \r) trimmed and whole-line # comments skipped — in graphify-fence.sh (_under_any_list), its graph-refresh.sh twin, and refresh-graph-map.sh’s _corpus_is_salus_root alike. A line that trims to nothing is skipped, never compared (an empty root would prefix-match every path).

Staged-copy corpus declaration (.graphify-corpus)

The 621/622 plan runs extraction on scratchpad copies of corpus content, never live vaults — but a copy classifies as nothing, so an undeclared copy is denied unconditionally. A .graphify-corpus marker file at or above the target declares the copy’s origin corpus: its first line, trimmed, must be one of salus · luna-personal · luna-clippings · handover-state · himmel-code (anything else, or an unreadable marker, is a fail-closed deny). Precedence is strict: .salus/PHI roots and the real luna/handover/himmel roots BOTH beat the marker (a marker inside a real vault can never relax classification); the marker is consulted only for otherwise-unclassifiable paths. Because a copy is origin-blind, mis-declaration is possible and accepted — the load-bearing PHI guard stays parity_guard / the file-tool fence, not this command-text guard. As a backstop, any marker-derived invocation always appends a ledger line on every allow-family verdict (allow / allow+log / conditional — a deny already blocks the egress), carrying "declared":true, even on a plain allow cell, so a mis-declared marker cannot also dodge the audit trail. The marker is consulted only when a luna root is configured (LUNA_VAULT / LUNA_VAULT_PATH non-empty): the real-root-beats-marker guarantee depends on the fence seeing the real roots, so with no luna root the marker is inert and staged copies stay unclassifiable → deny. graphify-fence.sh also normalizes MSYS-form paths (/c/… → c:/…) for its lexical root comparisons (the filesystem marker walks use the original path form) so Windows Git-Bash candidates and the Bash tool’s always-MSYS $PWD match the drive-lettered corpus roots.

.graphify-backend file-declared backend

graphify update (and other LLM-free subcommands) take no --backend on the CLI, so a non-himmel corpus needs a declared provider from elsewhere to satisfy the fence’s no-backend policy. GRAPHIFY_DECLARED_BACKEND covers this, but it is launching-shell-only by design — a per-call VAR=x prefix or an in-session export never reaches the PreToolUse hook process — so an in-session or headless graphify update run could never satisfy it without a session restart. A .graphify-backend file in the exact same directory as the .graphify-corpus marker (not any other ancestor directory) is a second, per-run way to supply the same declaration: single-line, trimmed, non-empty, safe-charset ([A-Za-z0-9._-]+) backend name; empty, multiline, unreadable, or invalid content is a fail-closed deny, same as an invalid .graphify-corpus marker. Precedence: GRAPHIFY_DECLARED_BACKEND wins when set; the file is consulted only when the env var is unset. Scoped identically to the env var (LLM-free subcommand + non-himmel corpus only). The file’s value flows through the same matrix eval as a real --backend token — not a bypass — and any declared-backend substitution (env or file) always leaves a ledger line on every allow-family verdict — even a plain allow matrix cell on a real (non-marker) root — carrying a declared_backend_source field ("env" or "file") so audits can tell the two paths apart and no declaration-reached run goes unrecorded. All-dirs-must-agree: when one invocation carries multiple marker-derived targets, every .graphify-corpus marker directory is consulted (not only the rank-winning target’s — equal-rank argument ordering must not pick which declaration gets read): each dir must carry a valid .graphify-backend and all files must declare the same value; a missing, unreadable, or invalid file in any dir, or two differing values, is a fail-closed deny regardless of argument order. Staged-only: file declarations are honored only when every classified target in the invocation is a marker-declared staged copy — any target that classifies via a real configured root disables the file path outright (a staged copy’s declaration must not vouch for a real vault path listed beside it); mixed staged+real invocations need --backend or the launching-shell env var.

Direct-eval mode (--eval)

The fence is wired only as a PreToolUse hook, which never sees graphify calls a script makes itself (cron/schtasks). Non-hook callers — currently scripts/graphify/refresh-graph-map.sh, before both of its graphify dispatches — run the same evaluation directly instead of keeping a private copy of the provider map, matrix eval and ledger:

graphify-fence.sh --eval <corpus> <backend> <abs-target> <tool-label>

Exit 0 = allow (ledger written where the verdict requires it); exit 2 = deny, reason on stderr. The verdict is exactly what an agent-typed invocation of the same corpus × provider gets from the matrix.

  • Validation (fail-closed, exit 2): <corpus> must be one of the five classes (salus, luna-personal, luna-clippings, handover-state, himmel-code); <backend> and <tool-label> must match [A-Za-z0-9._-]+ (they land in the ledger JSON unescaped, so the charset is the escaping); <abs-target> must be an absolute path. Any operand count other than four after --eval exits 2 — a malformed direct call is never let fall through to hook-mode parsing, where a bare --eval is not a graphify clause and would exit 0.
  • Class tightening: <corpus> is the caller’s asserted class, a declaration. The fence also classifies <abs-target> by path (.salus markers, the PHI root lists, configured real roots) and the more restrictive of the two wins — classification can only tighten the asserted class, never relax it.
  • Ledger: allow-family lines go to the same ledger file as hook mode, with tool=<tool-label>, "declared":true (the asserted class is a declaration, like a .graphify-corpus marker) and "purpose":"extraction". Hook-mode lines keep tool=graphify and carry no purpose field.

This section documents the contract only. It deliberately restates no matrix verdicts — scripts/guardrails/egress-matrix.json is authoritative.

Salus ollama-cloud opt-in and its trust root

The one persisted opt-in that lets the fence send a salus (PHI) corpus to ollama-cloud is graphify.salus_ollama_cloud_ok in $HOME/.himmel/config.json. Granting it is an operator call. Defense in depth around it:

  • Symlink refusal. The fence ignores the opt-in (treats it as not granted) when $HOME/.himmel or $HOME/.himmel/config.json is a symlink — a link to a file elsewhere that holds true is not an operator grant.
  • Write guard (soft block (speed bump)). scripts/hooks/block-himmel-config-writes.sh denies tool-driven writes to that file (file tools, and Bash commands that name it other than plain reads). It is text matching, not a sandbox; its header lists what it cannot see. It is not wired yet — wiring it into .claude/settings.json, .codex/hooks.json and wire-hook-bash.mjs is a planned follow-up, so until that lands the symlink refusal is the only live layer.
  • $HOME is the trust root, in hook mode and in --eval mode alike. The fence reads the caller’s $HOME and does not pin the passwd-database home; a caller that controls its own $HOME controls the opt-in. That is by decision, pinned by a test row, not an oversight.
  • Loopback means exactly localhost, 127.0.0.1 and [::1]. Other 127.x addresses and 0.0.0.0 are not loopback for the fence and are refused fail-closed; widening the set is a matrix-review change, not a config one.
  • Cloud-model detection is a name heuristic: surrounding whitespace is trimmed and a :latest suffix ignored before testing *-cloud / *:cloud. A local alias of a cloud model (made with ollama cp) is not detected — the upgrade path is asking the daemon (ollama show) for the remote host.

Consumers

SurfaceWhat it reads
scripts/guardrails/graphify-fence.shmaps graphify --backend → provider, target path → corpus, purpose = extraction; verdict via scripts/guardrails/egress-matrix-eval.mjs (the reference semantics as a CLI); wired as the narrow block-graphify-egress PreToolUse hook
parity_guard.py PHI/egress fencethe salus row (hard deny) — already enforced; the matrix documents the policy it implements
phi-egress-guard.ts (renamed from glm-guard.ts)same salus/denylist row
scripts/hermes/egress-gate.sh — the Hermes dispatch sanctioned script (invoke.sh, which dispatch-trusted.sh execs)target = the --prompt-file path → corpus (salus / luna-personal / luna-clippings / handover-state), --provider → provider (required for a gated corpus), purpose = inference; verdict via egress-matrix-eval.mjs. Refuses with rc 4 before hermes spawns; conditional is honored only for handover-state × openai-codex (brief-scoped: exactly ONE regular file per dispatch — a directory / non-regular path, or a path under the handover root that resolves outside it, is refused), every other conditional cell fails closed. Permitted gated dispatches append ~/.himmel/hermes-egress.jsonl (resolved path + byte size + sha256 of the dispatched snapshot — the “chokepoint audit is the ledger”). invoke.sh dispatches an immutable private mode-0600 snapshot the gate copied into a private directory it creates itself (mktemp -d, mode 0700, holding a 0600 file written exclusively under umask 077; the caller passes no destination and removes the directory on every exit path, so the gated bytes never sit in a caller-chosen or umask-perm file and the prompt is never opened for writing; the file identity and the resolved path are re-checked around the copy; a change detected there, or any copy/stat error, refuses), never the gated path again — so a swap or symlink retarget after the copy cannot change what hermes reads. Residual window a shell cannot close (the ponytail: note in the gate): a same-inode in-place edit before the copy, a swap undone before the post-copy checks, or a plain-file replacement landing before the first identity read is not detected. Public-code prompts are not gated, so qwen/alibaba stays a valid --model for himmel-code work. Limit: text/stdin prompts carry no path and are not classified
marketplace/plugins/himmel-ops/hooks/block-agent-native-egress.shthe salus × builder-io row (hard deny): refuses an mcp__*agent-native* call (the hosted Builder.io MCP that builder-visual@himmel registers) when the session cwd, its git toplevel or remotes, a .salus marker or a phi-roots/egress-denylist root marks salus, or the tool payload names salus. Plugin-shipped so it also fires in a salus session; it does not read the matrix at runtime, and test-egress-matrix.mjs pins the row
embedding/rerank pilot clientthe alibaba × embedding/rerank/vision-embedding cells — all now explicit deny (Alibaba de-listed; the pilot is not being pursued)
scripts/eval/worker-digest/failure_router.pythe worker-failure-metadata × jira × ticketing row (conditional): the router files and comments only a summary and body its own closed-alphabet check (check-body) passes, through the Jira CLI by absolute path, and logs one decision line per send. It does not read the matrix at runtime; test-egress-matrix.mjs 5d pins the conditional row
Atlassian MCP catalog capture (a one-off station script, not a shipped consumer)the mcp-tool-catalog × atlassian-mcp × catalog-fetch row (allow, operator ruling 2026-10-08): an auth-only initialize + tools/list call to https://mcp.atlassian.com/v1/mcp, no ticket, vault or corpus content. Every tools/call, other purpose and other corpus stays default-deny; test-egress-matrix.mjs 6 pins the scope

Reviewed outside the matrix: the research path’s Reddit lane

scripts/web/research/research_path.py (HIMMEL-5862) fetches two hosts, www.reddit.com (Scrapling, keyless) and arctic-shift.photon-reddit.com (plain GET, keyless). They carry no row here: the matrix keys corpus content leaving the machine, and this lane sends none. Boundary: the only text sent is the research query the operator typed, URL-encoded into a search URL, plus public thread ids and permalinks read back from Reddit’s own results. Text taken from a vault or corpus (luna, salus, handover state) is never sent. The code enforces consistency, not provenance: reddit_rung() is built with one query and raises on any other, the two fetchers refuse any URL outside their own https host, and the browser may load Reddit-owned hosts only (a final URL off www.reddit.com is refused). That the bound query is the operator’s own is the caller’s contract: a caller that feeds the path from notes must still pass the operator’s typed query. Widening the lane to a third host or a derived query is a matrix review, not a code edit.

Invariants (enforced by the test)

  • default is deny; salus × any-cloud × anything is a hard deny with no recordable override, except salus × openai-codex × inference (an explicit allow row ahead of the wildcard, operator ruling 2026-10-08, which also allows luna-personal and luna-clippings for codex inference only); google-gemini is denied everywhere (keys stay unset); there are now zero pending-operator cells (the five Alibaba embedding/rerank cells were demoted to explicit deny); DeepSeek + Alibaba are de-listed for vault/handover egress (explicit deny), and stay allow on himmel-code only; no bulk pipelines over handover-state.
  • jina (EU, Berlin; Elastic-owned) is allowed for embedding and rerank only, on luna-personal, luna-clippings and himmel-code, via explicit rows (HIMMEL-5673, operator ruling 2026-10-14; Jina legal 10.4: no training on customer request data), and, since HIMMEL-5683, on every other qmd corpus except salus: operator-repos (ggs-local, ggs-bridge, agent-command-chain, station-ops, nvidia-steam-shader-cache), jira-mirror, roadmap-plan and skill-index. Every other purpose and corpus stays denied (default-deny, plus an explicit himmel-code × jina × * deny ahead of the himmel-code wildcard allow), and salus × jina is refused by the hard salus wildcard (the test pins that it resolves through that row). This only permits: the live qmd use stays off until the fork backend is installed. jina is distinct from jina-reader (US, keyless URL scrape).
  • openrouter (US) is the remote qmd embedder and reranker (HIMMEL-5756, operator ruling 2026-10-15, HIMMEL-5712 A/B winner qwen3-embedding-8b plus cohere/rerank-4-fast): embedding and rerank only, via explicit rows, on every non-salus qmd corpus (himmel-code, luna-personal, luna-clippings, operator-repos, jira-mirror, roadmap-plan, skill-index). Every other purpose keeps its prior verdict. Query expansion (qmd’s generate role, HIMMEL-6125, operator ruling 2026-10-17) is its own purpose, query-expansion, so the inference cells (himmel-code, handover-state) are not widened: the same seven corpora get explicit allow rows and the raw query is the content sent. salus × openrouter × embedding|rerank|query-expansion are explicit HARD denies placed ahead of every openrouter allow and of the non-hard salus × openrouter × * cell; the test pins both the verdict and the row order. Salus stays on its own local-only qmd index and daemon. The qmd gate maps the host openrouter.ai (exact) to provider openrouter, accepts only the pinned models (openai:qwen/qwen3-embedding-8b embed, openai:cohere/rerank-4-fast rerank, openai:google/gemini-2.5-flash-lite generate; the matrix is per provider, so the consumer pins the model), refuses an unpinned model in any role, an unrecognised host and models.openai_api_key in the yaml; a missing or unparseable base URL is refused like an unrecognised one. The gate also refuses by path: a collection whose path overlaps a PHI root (graphify fence lists, a sibling salus config’s paths, a .salus vault) is PHI whatever it is called. The gate also refuses a collection that exists only in the index database, a directory holding a .salus vault below it, an unreadable claude-glm config dir, a base URL carrying credentials, and a salus index without explicit local embed and rerank models; refusal text never echoes a URL’s userinfo or an unparsed config line. The key lives only in the daemon environment (QMD_OPENAI_API_KEY); QMD_GATE_REQUIRE_KEY=1 (set by the launcher) refuses a remote role with no key. qmd_cmd (scripts/lib/qmd-bin.sh) runs the gate before any himmel-launched qmd process when the config names a remote model. A hand-typed qmd on PATH bypasses it: salus is protected by living in its own local-only index. Windows launchers are out of scope (Windows is dropped).
  • qmd enforcement (HIMMEL-5683). qmd’s model config is per index, not per collection, so salus lives in its own index (qmd --index salus, own salus.yml/db/daemon port, local models). scripts/qmd/qmd-jina-gate.mjs evaluates every remote model role (an openai: URI to a non-loopback endpoint) against every collection in an index through egress-matrix-eval.mjs, using scripts/qmd/qmd-collection-corpus.json (collection to corpora). A collection with no mapping is denied. The qmd plugin’s ensure-qmd-daemon.sh runs the gate before it starts a daemon whose config names a remote model and refuses (exit 1) on a denial, a missing gate or a missing node. The gate also reads the index database (sqlite3 read-only, active documents only): qmd embeds every active document whatever the config lists, so a collection dropped from index.yml but still active in the database counts, and an unreadable database fails closed. A role is remote when the config OR the environment names a remote model. The config writer (scripts/luna/qmd-embed-model.sh, before it writes or swaps) and the reindex runner (scripts/luna/qmd-reindex.sh, exit 10, before qmd update; the cadence runs it) call the same gate. Removing salus from a live index means qmd collection remove salus and a zero count from SELECT count(*) FROM documents WHERE collection='salus' AND active=1 before any model change. A new qmd collection must be added to the map, and to the matrix if it needs a corpus, before it can join a Jina index.
  • Run: node scripts/guardrails/test-egress-matrix.mjs.

Changing the matrix

Every change is a PR through the normal CR flow — the matrix is enforcement-path configuration. In particular the planned self-evolving loop is structurally denied writes here: a widened cell must always be a human-merged diff. Flipping a pending-operator cell: change its verdict (typically to allow+log), delete decision_needed, note the operator decision + date in why, update the test’s expectations, and cite the ratifying ticket in the commit.

Open operator decisions carried in the matrix

  • (none) — the former Alibaba embedding/rerank decision (five pending-operator cells) was closed: Alibaba de-listed for bad results, so those cells are now explicit deny and the pilot is not being pursued. Re-opening it would be a fresh operator ratification (a one-line PR flipping the relevant cells, per Changing the matrix above).