@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/packages/memory/openclaw-plugin/index.js +7 -0
  3. package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
  4. package/packages/memory/openclaw-plugin/package.json +1 -1
  5. package/packages/memory/src/__tests__/engine.test.js +142 -0
  6. package/packages/memory/src/engine.js +65 -0
  7. package/packages/memory-engine/compat/server.py +90 -5
  8. package/packages/memory-engine/docker-compose.yml +18 -8
  9. package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
  10. package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
  11. package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
  12. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
  13. package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
  14. package/packages/memory-engine/engine/services/l4/server.py +19 -57
  15. package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
  16. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
  17. package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
  18. package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
  19. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
  20. package/packages/memory-engine/tests/e2e_arena.sh +28 -4
  21. package/packages/memory-engine/tests/test_aggregate.py +333 -0
  22. package/packages/memory-engine/tests/test_arena_safety.py +232 -0
  23. package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
  24. package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
  25. package/packages/memory-engine/tests/test_embed_provider.py +354 -0
  26. package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
@@ -0,0 +1,333 @@
1
+ """Tests for the /aggregate endpoint and its L2 proxy backend.
2
+
3
+ Two flavours:
4
+
5
+ - Pure-unit tests around the request validation + group_by
6
+ whitelisting logic. Hermetic, fast, no Neo4j needed. Run on
7
+ every PR.
8
+
9
+ - Neo4j-backed integration tests (run when ``NEO4J_TEST_URI`` and
10
+ ``NEO4J_TEST_PASSWORD`` env vars are set). These prove the
11
+ aggregate Cypher actually returns the right buckets for typed-
12
+ Person + COMMUNICATED graphs and stays inside the caller's
13
+ arena.
14
+
15
+ Run:
16
+
17
+ cd packages/memory-engine
18
+ .venv/bin/python -m pytest tests/test_aggregate.py -v
19
+
20
+ Run with Neo4j:
21
+
22
+ NEO4J_TEST_URI=bolt://localhost:17687 \\
23
+ NEO4J_TEST_PASSWORD=testpassword \\
24
+ .venv/bin/python -m pytest tests/test_aggregate.py -v
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import sys
30
+ import uuid
31
+ from pathlib import Path
32
+
33
+ import pytest
34
+
35
+
36
+ ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
37
+ sys.path.insert(0, str(ENGINE_ROOT))
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Unit tests — validation surface around the public shape.
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def test_group_by_keys_whitelist_keeps_supported_keys_in_order() -> None:
46
+ """The L2 proxy templates group_by keys directly into Cypher; the
47
+ whitelist is the safety rail. Pin its contents + ordering
48
+ behaviour so a future change can't accidentally accept arbitrary
49
+ property names."""
50
+ # Import lazily so the unit tests don't pull pymilvus etc.
51
+ import importlib.util
52
+
53
+ spec = importlib.util.spec_from_file_location(
54
+ "l2_proxy_module",
55
+ ENGINE_ROOT / "l2-hybridrag-proxy.py",
56
+ )
57
+ assert spec and spec.loader
58
+ # The module imports fastapi/neo4j/etc. at import time. Skip when
59
+ # those aren't available — the whitelist is also asserted via the
60
+ # integration tests.
61
+ try:
62
+ mod = importlib.util.module_from_spec(spec)
63
+ spec.loader.exec_module(mod)
64
+ except ImportError:
65
+ pytest.skip("l2 proxy deps unavailable in this venv (fine for unit-only runs)")
66
+
67
+ # Public contract: only channel + direction are supported today.
68
+ # Adding more is a deliberate decision; this assertion is a guard
69
+ # against adding without thinking.
70
+ assert mod._AGGREGATE_GROUP_BY_KEYS == {"channel", "direction"}
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Neo4j-backed integration tests.
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ _NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
79
+ _NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
80
+ _NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
81
+
82
+ _skip_no_neo4j = pytest.mark.skipif(
83
+ not (_NEO4J_URI and _NEO4J_PASSWORD),
84
+ reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
85
+ )
86
+
87
+
88
+ @pytest.fixture
89
+ def neo4j_driver():
90
+ """Open a Neo4j driver and clean test data on teardown.
91
+
92
+ Uses a randomised arena pair so concurrent test runs don't trample
93
+ each other; tears down by deleting nodes scoped to those arenas
94
+ (never a global wipe — must stay safe against a populated dev
95
+ database).
96
+ """
97
+ from neo4j import GraphDatabase
98
+
99
+ driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
100
+ arenas = [f"agg_a_{uuid.uuid4().hex[:8]}", f"agg_b_{uuid.uuid4().hex[:8]}"]
101
+ yield driver, arenas
102
+ with driver.session() as session:
103
+ for arena in arenas:
104
+ session.run(
105
+ "MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
106
+ arena=arena,
107
+ )
108
+ driver.close()
109
+
110
+
111
+ def _seed_person_communications(
112
+ session,
113
+ arena: str,
114
+ email: str,
115
+ edges: list[tuple[str, str, str]],
116
+ ) -> None:
117
+ """Materialise (:Person {arena, email})-[:COMMUNICATED {channel,
118
+ direction, occurred_at}]->(:Chunk {arena}) edges from a list of
119
+ (channel, direction, occurred_at) tuples. Mirrors the exact shape
120
+ that engine/services/l2/l2-hybridrag-proxy.py:_index-internal-batch
121
+ writes on STORE_MEMORY ingest."""
122
+ for i, (channel, direction, occurred_at) in enumerate(edges):
123
+ session.run(
124
+ """
125
+ MERGE (c:Chunk {id: $cid})
126
+ SET c.arena = $arena, c.text = 't', c.path = 'p',
127
+ c.created_at = $occurred_at
128
+ MERGE (p:Entity:Person {arena: $arena, email: $email})
129
+ ON CREATE SET p.created_at = $occurred_at
130
+ MERGE (p)-[r:COMMUNICATED]->(c)
131
+ ON CREATE SET r.channel = $channel,
132
+ r.direction = $direction,
133
+ r.occurred_at = $occurred_at,
134
+ r.weight = 1.0
135
+ """,
136
+ cid=f"chunk_{arena}_{i}", arena=arena, email=email,
137
+ channel=channel, direction=direction, occurred_at=occurred_at,
138
+ )
139
+
140
+
141
+ def _aggregate_request(
142
+ session,
143
+ arena: str,
144
+ contact_email: str,
145
+ group_by: list[str] | None = None,
146
+ ) -> dict:
147
+ """Run the same Cypher that /aggregate-internal would. Built
148
+ inline so tests stay free of FastAPI plumbing."""
149
+ # Mirror the real endpoint: an explicit empty list means "no
150
+ # group_by" (one global bucket), None means default to channel.
151
+ requested = ["channel"] if group_by is None else group_by
152
+ safe_group_by = []
153
+ seen = set()
154
+ for k in requested:
155
+ if k in {"channel", "direction"} and k not in seen:
156
+ seen.add(k)
157
+ safe_group_by.append(k)
158
+
159
+ if safe_group_by:
160
+ with_keys = ", ".join(f"r.{k} AS {k}" for k in safe_group_by)
161
+ return_keys = ", ".join(safe_group_by)
162
+ cypher = (
163
+ "MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
164
+ "WHERE p.email = $contact_email\n"
165
+ f"WITH {with_keys}, r.direction AS _direction, r.occurred_at AS _occurred_at\n"
166
+ f"RETURN {return_keys},\n"
167
+ "count(*) AS count,\n"
168
+ "sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
169
+ "sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
170
+ "max(_occurred_at) AS last_seen,\n"
171
+ "min(_occurred_at) AS first_seen\n"
172
+ "ORDER BY count DESC\n"
173
+ )
174
+ else:
175
+ cypher = (
176
+ "MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
177
+ "WHERE p.email = $contact_email\n"
178
+ "WITH r.direction AS _direction, r.occurred_at AS _occurred_at\n"
179
+ "RETURN count(*) AS count,\n"
180
+ "sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
181
+ "sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
182
+ "max(_occurred_at) AS last_seen,\n"
183
+ "min(_occurred_at) AS first_seen\n"
184
+ )
185
+
186
+ buckets = []
187
+ total = 0
188
+ latest = None
189
+ for rec in session.run(cypher, arena=arena, contact_email=contact_email):
190
+ count = int(rec["count"] or 0)
191
+ total += count
192
+ last_seen = rec["last_seen"]
193
+ if last_seen and (latest is None or str(last_seen) > latest):
194
+ latest = str(last_seen)
195
+ keys = {k: rec[k] for k in safe_group_by} if safe_group_by else {}
196
+ buckets.append({
197
+ "keys": keys,
198
+ "count": count,
199
+ "inbound": int(rec["inbound"] or 0),
200
+ "outbound": int(rec["outbound"] or 0),
201
+ "last_seen": str(last_seen) if last_seen else None,
202
+ "first_seen": str(rec["first_seen"]) if rec["first_seen"] else None,
203
+ })
204
+ return {"arena": arena, "total": total, "last_seen": latest, "buckets": buckets}
205
+
206
+
207
+ @_skip_no_neo4j
208
+ def test_aggregate_groups_by_channel_with_direction_counters(neo4j_driver) -> None:
209
+ """5 emails (3 inbound, 2 outbound) + 2 slack (1/1) yields two
210
+ buckets ordered by count, each with correct inbound/outbound
211
+ splits and time bounds."""
212
+ driver, (arena_a, _) = neo4j_driver
213
+ email = "alex.tong@pentatonic.com"
214
+
215
+ with driver.session() as session:
216
+ _seed_person_communications(
217
+ session, arena_a, email,
218
+ [
219
+ ("email", "inbound", "2026-05-08T09:00:00Z"),
220
+ ("email", "outbound", "2026-05-09T10:00:00Z"),
221
+ ("email", "inbound", "2026-05-07T08:00:00Z"),
222
+ ("email", "outbound", "2026-05-09T11:00:00Z"),
223
+ ("email", "inbound", "2026-05-06T12:00:00Z"),
224
+ ("slack", "inbound", "2026-05-09T15:00:00Z"),
225
+ ("slack", "outbound", "2026-05-08T11:00:00Z"),
226
+ ],
227
+ )
228
+
229
+ out = _aggregate_request(session, arena_a, email, ["channel"])
230
+ assert out["total"] == 7
231
+ assert out["last_seen"] == "2026-05-09T15:00:00Z"
232
+ assert len(out["buckets"]) == 2
233
+
234
+ # Busiest first (5 > 2).
235
+ assert out["buckets"][0]["keys"] == {"channel": "email"}
236
+ assert out["buckets"][0]["count"] == 5
237
+ assert out["buckets"][0]["inbound"] == 3
238
+ assert out["buckets"][0]["outbound"] == 2
239
+
240
+ assert out["buckets"][1]["keys"] == {"channel": "slack"}
241
+ assert out["buckets"][1]["count"] == 2
242
+ assert out["buckets"][1]["inbound"] == 1
243
+ assert out["buckets"][1]["outbound"] == 1
244
+
245
+
246
+ @_skip_no_neo4j
247
+ def test_aggregate_arena_isolation(neo4j_driver) -> None:
248
+ """A's aggregate never sees B's edges, even when both arenas have
249
+ a Person with the same email — the shared name was the bug
250
+ pattern that motivated the typed-Person work in #28."""
251
+ driver, (arena_a, arena_b) = neo4j_driver
252
+ email = "shared@example.com"
253
+
254
+ with driver.session() as session:
255
+ _seed_person_communications(
256
+ session, arena_a, email,
257
+ [("email", "inbound", "2026-05-09T09:00:00Z")],
258
+ )
259
+ _seed_person_communications(
260
+ session, arena_b, email,
261
+ [
262
+ ("email", "inbound", "2026-05-09T10:00:00Z"),
263
+ ("slack", "outbound", "2026-05-09T11:00:00Z"),
264
+ ],
265
+ )
266
+
267
+ out_a = _aggregate_request(session, arena_a, email)
268
+ assert out_a["total"] == 1
269
+ assert len(out_a["buckets"]) == 1
270
+
271
+ out_b = _aggregate_request(session, arena_b, email)
272
+ assert out_b["total"] == 2
273
+ assert len(out_b["buckets"]) == 2
274
+
275
+
276
+ @_skip_no_neo4j
277
+ def test_aggregate_with_no_group_by_returns_single_bucket(neo4j_driver) -> None:
278
+ """No group_by → one global bucket with overall totals only."""
279
+ driver, (arena_a, _) = neo4j_driver
280
+ email = "alex@x.io"
281
+
282
+ with driver.session() as session:
283
+ _seed_person_communications(
284
+ session, arena_a, email,
285
+ [
286
+ ("email", "inbound", "2026-05-09T09:00:00Z"),
287
+ ("slack", "outbound", "2026-05-09T11:00:00Z"),
288
+ ("email", "outbound", "2026-05-09T15:00:00Z"),
289
+ ],
290
+ )
291
+
292
+ out = _aggregate_request(session, arena_a, email, group_by=[])
293
+ assert out["total"] == 3
294
+ assert len(out["buckets"]) == 1
295
+ assert out["buckets"][0]["keys"] == {}
296
+ assert out["buckets"][0]["inbound"] == 1
297
+ assert out["buckets"][0]["outbound"] == 2
298
+
299
+
300
+ @_skip_no_neo4j
301
+ def test_aggregate_returns_empty_when_person_node_missing(neo4j_driver) -> None:
302
+ """When the typed-Person nodes don't exist for this contact yet
303
+ (older memories, tenants pre-#28), the response is total: 0 with
304
+ no buckets — caller falls back to whatever it had before. This is
305
+ the deliberate non-fallback at this layer; the over-fetch v1
306
+ lives in TES."""
307
+ driver, (arena_a, _) = neo4j_driver
308
+ out = _aggregate_request(
309
+ driver.session().__enter__(), arena_a, "no-one@example.com",
310
+ )
311
+ assert out["total"] == 0
312
+ assert out["buckets"] == []
313
+
314
+
315
+ @_skip_no_neo4j
316
+ def test_aggregate_unknown_group_by_keys_are_silently_dropped(neo4j_driver) -> None:
317
+ """Defence-in-depth: even if a future caller passes
318
+ group_by=['evil_property_name'], we never template that string
319
+ into Cypher. Aggregate falls back to no group_by (one global
320
+ bucket) rather than failing — the whitelist is already on the
321
+ real endpoint, but the test pins the behaviour at the helper
322
+ level too."""
323
+ driver, (arena_a, _) = neo4j_driver
324
+ email = "alex@x.io"
325
+ with driver.session() as session:
326
+ _seed_person_communications(
327
+ session, arena_a, email,
328
+ [("email", "inbound", "2026-05-09T09:00:00Z")],
329
+ )
330
+ out = _aggregate_request(session, arena_a, email, ["evil; DROP TABLE"])
331
+ # No supported keys remained → single global bucket.
332
+ assert len(out["buckets"]) == 1
333
+ assert out["buckets"][0]["keys"] == {}
@@ -0,0 +1,232 @@
1
+ """Static safety check: every Cypher node pattern that targets a
2
+ tenant-data label must scope by `arena` — not just somewhere in the
3
+ surrounding block, but on the same variable.
4
+
5
+ Run with:
6
+ cd packages/memory-engine
7
+ python -m pytest tests/test_arena_safety.py -v
8
+
9
+ How the check works:
10
+
11
+ 1. Walk the live engine source and pull out every Cypher block (both
12
+ triple-quoted strings and inline ``session.run("…")`` calls).
13
+
14
+ 2. For each block, find every node pattern that names one of the
15
+ tenant labels — patterns like ``(p:Person {...})`` or
16
+ ``(e:Entity:Concept {...})``.
17
+
18
+ 3. For each such pattern, the variable bound by that pattern (e.g.
19
+ ``p`` / ``e``) must be tied to ``arena`` somewhere in the block:
20
+ either inside the pattern's own property bag (``{arena: $arena,
21
+ …}``) or via a WHERE clause that references ``<var>.arena``.
22
+
23
+ The earlier weaker version of this lint checked "the block contains
24
+ the string `arena` *somewhere*", which let a Person MERGE without
25
+ arena slip through if any neighbouring chunk-join in the same block
26
+ referenced `arena`. The bug-day repro was injecting
27
+ ``MERGE (p:Entity:Person {email: $email})`` while the rest of the
28
+ block kept ``MATCH (c:Chunk {arena: $arena, …})`` — block contained
29
+ "arena", lint was happy, the Person node was global.
30
+
31
+ If a future change introduces a Cypher pattern on these labels without
32
+ arena (e.g. a debug helper that genuinely needs to span all tenants),
33
+ allow-list it via ``_ALLOWED_NO_ARENA_REASONS`` with a justification.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from pathlib import Path
39
+
40
+ import pytest
41
+
42
+
43
+ REPO_ROOT = Path(__file__).resolve().parent.parent
44
+ ENGINE_LIVE = REPO_ROOT / "engine" / "services" / "l2" / "l2-hybridrag-proxy.py"
45
+ COMPAT_SHIM = REPO_ROOT / "compat" / "server.py"
46
+
47
+ # Labels that carry tenant data. Any Cypher pattern naming these MUST
48
+ # bind the variable to `arena` — either as a property in the pattern
49
+ # itself or via a WHERE clause on the same variable.
50
+ TENANT_LABELS = ("Entity", "Person", "Concept", "Channel", "Chunk", "ChannelStat")
51
+ _LABEL_ALT = "|".join(TENANT_LABELS)
52
+
53
+ # Triple-quoted strings.
54
+ _TRIPLE_STRING = re.compile(
55
+ r'"""(?P<body>[^"]*?(?:"(?!"")[^"]*?)*?)"""',
56
+ re.DOTALL,
57
+ )
58
+
59
+ # Inline `session.run("…")` calls that aren't already in a triple-quote.
60
+ _SINGLELINE_RUN = re.compile(
61
+ r'session\.run\(\s*"((?:[^"\\]|\\.)+)"',
62
+ re.MULTILINE,
63
+ )
64
+
65
+ # Anything that smells like Cypher inside a string literal.
66
+ _OP_PATTERN = re.compile(r"\b(MERGE|MATCH|DETACH\s+DELETE)\b", re.IGNORECASE)
67
+
68
+ # Node pattern: (var:Label1:Label2 {props}) or (var:Label)
69
+ # The var is optional in Cypher, but anonymous patterns can't carry a
70
+ # WHERE clause anyway — flag them as unsafe unless the inline property
71
+ # bag scopes by arena.
72
+ _NODE_PATTERN = re.compile(
73
+ r"""
74
+ \(
75
+ \s*(?P<var>[A-Za-z_][A-Za-z0-9_]*)? # optional variable
76
+ \s*(?P<labels>(?::(?:""" + _LABEL_ALT + r"""))+)\b # one+ tenant labels
77
+ \s*(?P<props>\{[^{}]*\})? # optional property bag
78
+ """,
79
+ re.VERBOSE,
80
+ )
81
+
82
+ # Allow-list: cross-tenant Cypher that we deliberately want to keep.
83
+ # Map a unique substring of the offending pattern to a justification.
84
+ _ALLOWED_NO_ARENA_REASONS: dict[str, str] = {
85
+ # /index-internal-stats — global ops counters that return ints.
86
+ "MATCH (c:Chunk) RETURN count(c) AS n":
87
+ "ops counter — returns a single int, no tenant data exposed",
88
+ "MATCH (e:Entity) RETURN count(e) AS n":
89
+ "ops counter — returns a single int, no tenant data exposed",
90
+ # /forget-internal global-wipe path — gated by confirm: GLOBAL_WIPE.
91
+ "MATCH (c:Chunk) DETACH DELETE c RETURN count(c) AS n":
92
+ "global-wipe, gated by explicit confirm: GLOBAL_WIPE",
93
+ "MATCH (e:Entity) DETACH DELETE e RETURN count(e) AS n":
94
+ "global-wipe, gated by explicit confirm: GLOBAL_WIPE",
95
+ # Migration target — pre-arena legacy entities have no arena.
96
+ "MATCH (e:Entity) WHERE e.arena IS NULL DETACH DELETE e":
97
+ "legacy-wipe migration target (entities pre-arena scoping)",
98
+ }
99
+
100
+
101
+ # Cypher line comments. Strip these from extracted blocks before
102
+ # running the tenant-label scan so that prose mentions of pattern
103
+ # syntax (e.g. "Person-COMMUNICATED edges") inside `// …` comments
104
+ # don't get parsed as real node patterns.
105
+ _CYPHER_LINE_COMMENT = re.compile(r"//[^\n]*")
106
+
107
+
108
+ def _strip_cypher_comments(block: str) -> str:
109
+ return _CYPHER_LINE_COMMENT.sub("", block)
110
+
111
+
112
+ def _extract_cypher_blocks(source: str) -> list[tuple[int, str]]:
113
+ """Return [(approx_line_no, body)] for every Cypher block."""
114
+ blocks: list[tuple[int, str]] = []
115
+ for m in _TRIPLE_STRING.finditer(source):
116
+ body = m.group("body")
117
+ if _OP_PATTERN.search(body):
118
+ line_no = source[: m.start()].count("\n") + 1
119
+ # Strip Cypher // comments so prose patterns inside
120
+ # comments don't get scanned as actual queries.
121
+ blocks.append((line_no, _strip_cypher_comments(body)))
122
+ # session.run("…") matches are skipped if they fell inside a triple
123
+ # string already covered above. Cheap dedup: if the body of a
124
+ # singleline match is a substring of any triple body, skip it.
125
+ triple_bodies = [b for _, b in blocks]
126
+ for m in _SINGLELINE_RUN.finditer(source):
127
+ body = m.group(1)
128
+ if not _OP_PATTERN.search(body):
129
+ continue
130
+ if any(body in tb for tb in triple_bodies):
131
+ continue
132
+ line_no = source[: m.start()].count("\n") + 1
133
+ blocks.append((line_no, body))
134
+ return blocks
135
+
136
+
137
+ def _is_allowed(block: str) -> str | None:
138
+ for fragment, reason in _ALLOWED_NO_ARENA_REASONS.items():
139
+ if fragment in block:
140
+ return reason
141
+ return None
142
+
143
+
144
+ def _pattern_scopes_arena(block: str, var: str | None, props: str | None) -> bool:
145
+ """True if this specific pattern is arena-scoped.
146
+
147
+ A pattern is arena-scoped when EITHER:
148
+ - The inline property bag contains `arena:`, OR
149
+ - A `WHERE` clause in the surrounding block references
150
+ `<var>.arena`.
151
+ """
152
+ if props and re.search(r"\barena\s*:", props):
153
+ return True
154
+ if var is None:
155
+ # Anonymous pattern with no property bag — there's no way to
156
+ # scope it via WHERE since there's no var to reference.
157
+ return False
158
+ # Look for `<var>.arena` anywhere in the block. Crude but the
159
+ # variable name is unambiguous within a single Cypher block.
160
+ if re.search(rf"\b{re.escape(var)}\.arena\b", block):
161
+ return True
162
+ return False
163
+
164
+
165
+ @pytest.mark.parametrize(
166
+ "source_path",
167
+ [
168
+ pytest.param(ENGINE_LIVE, id="l2-hybridrag-proxy"),
169
+ pytest.param(COMPAT_SHIM, id="compat-shim"),
170
+ ],
171
+ )
172
+ def test_every_tenant_pattern_is_arena_scoped(source_path: Path) -> None:
173
+ """Each tenant-label node pattern is scoped by arena."""
174
+ if not source_path.exists():
175
+ pytest.skip(f"{source_path} not present")
176
+ source = source_path.read_text()
177
+ offenders: list[str] = []
178
+ for line_no, block in _extract_cypher_blocks(source):
179
+ # Honour block-level allow-list before per-pattern checks; that
180
+ # way an entire global-wipe block can be allow-listed once.
181
+ if _is_allowed(block):
182
+ continue
183
+ for m in _NODE_PATTERN.finditer(block):
184
+ var = m.group("var")
185
+ props = m.group("props")
186
+ if _pattern_scopes_arena(block, var, props):
187
+ continue
188
+ offenders.append(
189
+ f"{source_path.name}:~{line_no} pattern `{m.group(0).strip()}` "
190
+ f"in block:\n{block.strip()[:240]}"
191
+ )
192
+
193
+ assert not offenders, (
194
+ f"{len(offenders)} tenant-labelled Cypher pattern(s) miss arena scoping:\n\n"
195
+ + "\n\n---\n\n".join(offenders)
196
+ + "\n\nAdd `arena` to the pattern (e.g. `{arena: $arena, …}`) or to a "
197
+ "WHERE clause on the same variable. If the pattern genuinely needs "
198
+ "to span tenants, add an entry to _ALLOWED_NO_ARENA_REASONS with a "
199
+ "justification."
200
+ )
201
+
202
+
203
+ # A self-test: the lint should fail when given a block that's clearly
204
+ # unscoped. This guards against future refactors of the lint silently
205
+ # turning into a no-op.
206
+ def test_lint_self_test_catches_obvious_bug() -> None:
207
+ """Inject an unscoped pattern into a fake source and assert lint flags it."""
208
+ bad_source = '''
209
+ def writer():
210
+ session.run("""
211
+ MERGE (p:Entity:Person {email: $email})
212
+ ON CREATE SET p.created_at = $now
213
+ MATCH (c:Chunk {arena: $arena, id: $cid})
214
+ MERGE (p)-[:MENTIONS]->(c)
215
+ """, email="x", arena="acme", cid="1", now="t")
216
+ '''
217
+ blocks = _extract_cypher_blocks(bad_source)
218
+ assert blocks, "lint helper failed to extract the test block"
219
+ block = blocks[0][1]
220
+ assert not _is_allowed(block)
221
+ flagged: list[str] = []
222
+ for m in _NODE_PATTERN.finditer(block):
223
+ var = m.group("var")
224
+ props = m.group("props")
225
+ if not _pattern_scopes_arena(block, var, props):
226
+ flagged.append(m.group(0))
227
+ # The Person MERGE has no arena anywhere on `p` — must be flagged.
228
+ # The Chunk MATCH has arena in the property bag — must NOT be flagged.
229
+ assert any("Person" in f for f in flagged), \
230
+ "self-test: unscoped Person pattern should have been flagged"
231
+ assert not any("Chunk" in f for f in flagged), \
232
+ "self-test: arena-scoped Chunk pattern should not have been flagged"