robo-cortex 0.4.2__tar.gz → 0.4.3__tar.gz

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 (85) hide show
  1. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/CHANGELOG.md +10 -0
  2. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/PKG-INFO +4 -3
  3. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/README.md +3 -2
  4. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/__init__.py +1 -1
  5. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/evidence.py +8 -11
  6. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/link.py +6 -2
  7. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/status.py +3 -2
  8. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/errors.py +4 -0
  9. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/evidence.py +40 -1
  10. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/memory.py +28 -13
  11. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/store.py +38 -0
  12. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_scope_b.py +56 -9
  13. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_evidence.py +47 -2
  14. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_mcp_contracts.py +25 -8
  15. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_scope_b.py +69 -14
  16. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_smoke.py +1 -1
  17. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/.gitignore +0 -0
  18. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/LICENSE +0 -0
  19. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/pyproject.toml +0 -0
  20. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/schema.sql +0 -0
  21. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/__init__.py +0 -0
  22. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/__main__.py +0 -0
  23. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/_common.py +0 -0
  24. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/_output.py +0 -0
  25. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/app.py +0 -0
  26. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/__init__.py +0 -0
  27. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/affected.py +0 -0
  28. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/completion.py +0 -0
  29. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/hooks.py +0 -0
  30. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/init.py +0 -0
  31. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/list_cmd.py +0 -0
  32. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/mcp.py +0 -0
  33. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/record.py +0 -0
  34. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/retrieve.py +0 -0
  35. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/search.py +0 -0
  36. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/show.py +0 -0
  37. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/transfer.py +0 -0
  38. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/__init__.py +0 -0
  39. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/assumptions.py +0 -0
  40. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/db.py +0 -0
  41. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/git.py +0 -0
  42. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/hooks.py +0 -0
  43. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/init.py +0 -0
  44. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/invalidate.py +0 -0
  45. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/lifecycle.py +0 -0
  46. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/retrieve.py +0 -0
  47. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/semantic.py +0 -0
  48. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/text.py +0 -0
  49. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/transfer.py +0 -0
  50. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/gitea.py +0 -0
  51. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/mcp_server.py +0 -0
  52. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0001_init.sql +0 -0
  53. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0002_add_usage_tracking.sql +0 -0
  54. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0003_add_meta_table.sql +0 -0
  55. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/sdk.py +0 -0
  56. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/__init__.py +0 -0
  57. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/conftest.py +0 -0
  58. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/fixtures.py +0 -0
  59. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_add_path.py +0 -0
  60. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_affected.py +0 -0
  61. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_assumptions.py +0 -0
  62. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_affected.py +0 -0
  63. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_init.py +0 -0
  64. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_lifecycle.py +0 -0
  65. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_record.py +0 -0
  66. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_retrieve.py +0 -0
  67. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_completion.py +0 -0
  68. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_curation.py +0 -0
  69. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_db.py +0 -0
  70. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_gitea.py +0 -0
  71. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_hooks.py +0 -0
  72. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_init.py +0 -0
  73. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_invalidate.py +0 -0
  74. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_lifecycle.py +0 -0
  75. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_mcp_stdio.py +0 -0
  76. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_memory.py +0 -0
  77. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_output.py +0 -0
  78. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_record_batch.py +0 -0
  79. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_retrieve.py +0 -0
  80. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_reverify.py +0 -0
  81. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_sdk.py +0 -0
  82. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_search.py +0 -0
  83. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_semantic.py +0 -0
  84. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_transfer.py +0 -0
  85. {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_update_path.py +0 -0
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format loosely follows
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.3] — 2026-07-31
8
+
9
+ ### Fixed
10
+
11
+ - **Silent wrong-store mutation on id collision.** Repo-local and global stores are independent autoincrement sequences, so a repo memory and a global memory can share the same numeric id. `roco status`/`link`/`evidence verify` (and the SDK/MCP equivalents), when called without `--scope`, used to silently pick whichever store hit first (local, by convention) — so e.g. `roco status 10 activate` could flip the wrong, unrelated memory to `active` with no error and no indication anything was off. `find_memory_store`/the new `find_evidence_store` now detect the collision and raise a clear `id N exists in both repo and global stores -- pass --scope repo or --scope global to disambiguate` error instead of guessing. `status`/`link` also now echo which store they acted on (`Memory 10 (global) -> active`) so a resolved-but-unintended target is visible immediately. Found live during normal usage.
12
+
13
+ ### Added
14
+
15
+ - **Global store id partitioning.** New global memories/evidence/links now start at id 1,000,000,000+ (bumped in place, no renumbering of existing rows) so future ids can no longer collide with any repo-local store's ids — the same bug class above, closed structurally going forward. Ids assigned before this change can still collide with existing repo-local ids; the collision-detection fix above is what catches those.
16
+
7
17
  ## [0.4.2] — 2026-07-26
8
18
 
9
19
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robo-cortex
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Long-term memory system for LLM coding agents working inside a Git repository
5
5
  Project-URL: Homepage, https://robotel.top
6
6
  Project-URL: Repository, https://github.com/robotel-limited/robo-cortex
@@ -36,12 +36,13 @@ Description-Content-Type: text/markdown
36
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
37
  [![Smithery Calls](https://smithery.ai/badge/robotel-top/robo-cortex)](https://smithery.ai/servers/robotel-top/robo-cortex)
38
38
  [![robo-cortex MCP server](https://glama.ai/mcp/servers/robotel-limited/robo-cortex/badges/score.svg)](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
39
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21610869.svg)](https://doi.org/10.5281/zenodo.21610869)
39
40
 
40
41
  [![robo-cortex MCP server](https://glama.ai/mcp/servers/robotel-limited/robo-cortex/badges/card.svg)](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
41
42
 
42
43
  **Git-aware knowledge integrity for AI coding agents.**
43
44
 
44
- Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost.
45
+ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost. In other words: **drift detection and stale-memory detection for AI agent memory**, done deterministically from git history — not an LLM guess, not an embedding-similarity heuristic.
45
46
 
46
47
  ---
47
48
 
@@ -49,7 +50,7 @@ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — o
49
50
 
50
51
  Most agent-memory tools are embeddings + a vector database + a cloud dependency. robo-cortex is deliberately none of that. Four things it does that a generic key-value memory store doesn't:
51
52
 
52
- 1. **Git-aware staleness detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. No other memory tool ties itself to your actual code's history this way.
53
+ 1. **Git-aware staleness (drift) detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. Few memory tools tie themselves to your actual code's history this deterministically — no embedding similarity, no LLM judgment call, just a git blob hash matching or not.
53
54
  2. **Evidence, not vibes.** Memories can carry attached evidence (test output, a commit, a re-runnable command) with a mechanically computed strength — not a judgment call. A `provisional` memory only becomes `active` once real evidence backs it, or a human explicitly promotes it.
54
55
  3. **Zero dependencies, fully local.** Pure stdlib + SQLite (FTS5 for search). No embeddings, no API keys, no network calls unless you explicitly configure Gitea evidence verification. `.cortex/memory.db` is just a file — inspect it with `sqlite3` any time.
55
56
  4. **A real lifecycle with an audit trail.** Every status change (`active` → `superseded` → `archived`, etc.) requires a `--reason`, permanently recorded. Nothing disappears silently; wrong lessons are marked `invalidated`, not deleted.
@@ -7,12 +7,13 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
  [![Smithery Calls](https://smithery.ai/badge/robotel-top/robo-cortex)](https://smithery.ai/servers/robotel-top/robo-cortex)
9
9
  [![robo-cortex MCP server](https://glama.ai/mcp/servers/robotel-limited/robo-cortex/badges/score.svg)](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
10
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21610869.svg)](https://doi.org/10.5281/zenodo.21610869)
10
11
 
11
12
  [![robo-cortex MCP server](https://glama.ai/mcp/servers/robotel-limited/robo-cortex/badges/card.svg)](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
12
13
 
13
14
  **Git-aware knowledge integrity for AI coding agents.**
14
15
 
15
- Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost.
16
+ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost. In other words: **drift detection and stale-memory detection for AI agent memory**, done deterministically from git history — not an LLM guess, not an embedding-similarity heuristic.
16
17
 
17
18
  ---
18
19
 
@@ -20,7 +21,7 @@ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — o
20
21
 
21
22
  Most agent-memory tools are embeddings + a vector database + a cloud dependency. robo-cortex is deliberately none of that. Four things it does that a generic key-value memory store doesn't:
22
23
 
23
- 1. **Git-aware staleness detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. No other memory tool ties itself to your actual code's history this way.
24
+ 1. **Git-aware staleness (drift) detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. Few memory tools tie themselves to your actual code's history this deterministically — no embedding similarity, no LLM judgment call, just a git blob hash matching or not.
24
25
  2. **Evidence, not vibes.** Memories can carry attached evidence (test output, a commit, a re-runnable command) with a mechanically computed strength — not a judgment call. A `provisional` memory only becomes `active` once real evidence backs it, or a human explicitly promotes it.
25
26
  3. **Zero dependencies, fully local.** Pure stdlib + SQLite (FTS5 for search). No embeddings, no API keys, no network calls unless you explicitly configure Gitea evidence verification. `.cortex/memory.db` is just a file — inspect it with `sqlite3` any time.
26
27
  4. **A real lifecycle with an audit trail.** Every status change (`active` → `superseded` → `archived`, etc.) requires a `--reason`, permanently recorded. Nothing disappears silently; wrong lessons are marked `invalidated`, not deleted.
@@ -1,4 +1,4 @@
1
- __version__ = "0.4.2"
1
+ __version__ = "0.4.3"
2
2
 
3
3
  from robo_cortex.sdk import RoboCortex, retrieve, record, search
4
4
 
@@ -3,8 +3,12 @@
3
3
  import json
4
4
  import sys
5
5
 
6
- from robo_cortex.core.errors import NotFoundError, RoboCortexError
7
- from robo_cortex.core.evidence import EVIDENCE_KINDS, attach_evidence, verify_evidence
6
+ from robo_cortex.core.evidence import (
7
+ EVIDENCE_KINDS,
8
+ attach_evidence,
9
+ find_evidence_store,
10
+ verify_evidence,
11
+ )
8
12
  from robo_cortex.core.memory import find_memory_store
9
13
  from robo_cortex.cli._common import _get_cmd_name, _store, _global_store, cli_command
10
14
 
@@ -45,15 +49,8 @@ def run_verify(args) -> int:
45
49
  # lives in: it's this invocation's ambient repo context, used to
46
50
  # resolve the Gitea owner/repo via the local git remote
47
51
  # (Stage 10) for gitea_pr/gitea_issue evidence specifically.
48
- if args.scope == "repo":
49
- result = verify_evidence(conn, args.evidence_id, repo_root)
50
- elif args.scope == "global":
51
- result = verify_evidence(global_conn, args.evidence_id, repo_root)
52
- else:
53
- try:
54
- result = verify_evidence(conn, args.evidence_id, repo_root)
55
- except NotFoundError:
56
- result = verify_evidence(global_conn, args.evidence_id, repo_root)
52
+ store = find_evidence_store(conn, global_conn, args.evidence_id, scope=args.scope)
53
+ result = verify_evidence(store, args.evidence_id, repo_root)
57
54
 
58
55
  print(json.dumps(result) if args.json else json.dumps(result, indent=2))
59
56
  return 0
@@ -21,12 +21,16 @@ def run(args) -> int:
21
21
  file=sys.stderr,
22
22
  )
23
23
  return 1
24
+ resolved_scope = "global" if store1 is global_conn else "repo"
24
25
  result = create_link(store1, args.id1, args.id2, link_type)
25
26
 
26
27
  if args.json:
27
- print(json.dumps(result))
28
+ print(json.dumps({**result, "scope": resolved_scope}))
28
29
  else:
29
- print(f"Linked {result['from_id']} --{result['link_type']}--> {result['to_id']}")
30
+ print(
31
+ f"Linked {result['from_id']} --{result['link_type']}--> "
32
+ f"{result['to_id']} ({resolved_scope})"
33
+ )
30
34
  return 0
31
35
 
32
36
 
@@ -39,15 +39,16 @@ def run(args) -> int:
39
39
 
40
40
  new_status = STATUS_ACTIONS[args.action]
41
41
  store = find_memory_store(conn, global_conn, args.id, scope=args.scope)
42
+ resolved_scope = "global" if store is global_conn else "repo"
42
43
  result = change_status(
43
44
  store, repo_root, args.id, new_status, args.reason,
44
45
  supersedes_link_to=args.supersedes,
45
46
  )
46
47
 
47
48
  if args.json:
48
- print(json.dumps(result))
49
+ print(json.dumps({**result, "scope": resolved_scope}))
49
50
  else:
50
- print(f"Memory {result['id']} -> {result['status']}")
51
+ print(f"Memory {result['id']} ({resolved_scope}) -> {result['status']}")
51
52
  return 0
52
53
 
53
54
 
@@ -28,5 +28,9 @@ class NotFoundError(RoboCortexError):
28
28
  exit_code = 1
29
29
 
30
30
 
31
+ class AmbiguousIdError(RoboCortexError):
32
+ exit_code = 1
33
+
34
+
31
35
  class IllegalTransitionError(RoboCortexError):
32
36
  exit_code = 1
@@ -1,6 +1,6 @@
1
1
  from datetime import datetime, timezone
2
2
 
3
- from .errors import NotFoundError, ValidationError
3
+ from .errors import AmbiguousIdError, NotFoundError, ValidationError
4
4
  from .memory import get_memory, reverify
5
5
 
6
6
  EVIDENCE_KINDS = {
@@ -107,6 +107,45 @@ def _mark_unverifiable(conn, evidence_id: int, kind: str, ref: str | None, reaso
107
107
  }
108
108
 
109
109
 
110
+ def find_evidence_store(local_conn, global_conn, evidence_id: int, scope: str | None = None):
111
+ """Same shape as `memory.find_memory_store`, for evidence rows: evidence
112
+ ids are also a per-store autoincrement sequence, so a bare id can
113
+ collide between the repo and global stores just like memory ids do.
114
+ `scope=None` checks both and raises AmbiguousIdError on a double hit
115
+ instead of silently verifying whichever store's row happened to match
116
+ first -- the same failure mode reported for memory ids in
117
+ prompt-bug-roco.md, just not routed through find_memory_store because
118
+ evidence rows aren't looked up by `get_memory`.
119
+ """
120
+ def _has_evidence(conn) -> bool:
121
+ return conn.execute(
122
+ "SELECT 1 FROM evidence WHERE id = ?", (evidence_id,)
123
+ ).fetchone() is not None
124
+
125
+ if scope == "repo":
126
+ if not _has_evidence(local_conn):
127
+ raise NotFoundError(f"no evidence with id {evidence_id}")
128
+ return local_conn
129
+ if scope == "global":
130
+ if global_conn is None or not _has_evidence(global_conn):
131
+ raise NotFoundError(f"no evidence with id {evidence_id}")
132
+ return global_conn
133
+
134
+ local_hit = _has_evidence(local_conn)
135
+ global_hit = global_conn is not None and _has_evidence(global_conn)
136
+
137
+ if local_hit and global_hit:
138
+ raise AmbiguousIdError(
139
+ f"evidence id {evidence_id} exists in both repo and global stores -- "
140
+ "pass --scope repo or --scope global to disambiguate"
141
+ )
142
+ if local_hit:
143
+ return local_conn
144
+ if global_hit:
145
+ return global_conn
146
+ raise NotFoundError(f"no evidence with id {evidence_id}")
147
+
148
+
110
149
  def verify_evidence(conn, evidence_id: int, repo_root=None) -> dict:
111
150
  """The single explicit re-verification entry point (ARCHITECTURE.md §3,
112
151
  §9) -- the only place a network call to Gitea is ever allowed, and only
@@ -3,7 +3,7 @@ import sqlite3
3
3
  from pathlib import Path
4
4
 
5
5
  from . import db as dbmod
6
- from .errors import NotFoundError, ValidationError
6
+ from .errors import AmbiguousIdError, NotFoundError, ValidationError
7
7
  from .git import blob_hash_at_head
8
8
  from .text import fts_query_string, tokenize_without_stopwords
9
9
 
@@ -289,12 +289,15 @@ def find_memory_store(local_conn, global_conn, memory_id: int, scope: str | None
289
289
  proven live: a local id 1 makes global id 1 unreachable by id from that
290
290
  repo without a way to say which store you mean.
291
291
 
292
- `scope=None` (default): try local first, global second -- local wins by
293
- convention (`--repo` is the primary context of a CLI/MCP invocation),
294
- NotFoundError only if neither has it. `scope='repo'` / `scope='global'`:
295
- look only in that store, no fallback -- the explicit disambiguator for
296
- when the caller already knows which memory they mean and a same-id
297
- collision would otherwise resolve to the wrong one.
292
+ `scope=None` (default): look in both stores. Exactly one hit resolves
293
+ unambiguously. Both hit -> AmbiguousIdError instead of silently picking
294
+ one -- an earlier version silently preferred local here, which is what
295
+ let `roco status 10 activate` flip an unrelated repo-scoped memory
296
+ instead of the intended global one, live, with no error and no
297
+ indication anything had gone wrong (see prompt-bug-roco.md). Neither
298
+ hit -> NotFoundError. `scope='repo'` / `scope='global'`: look only in
299
+ that store, no fallback -- the explicit disambiguator for when the
300
+ caller already knows which memory they mean.
298
301
 
299
302
  Used by every ID-taking command (`show`, `status`, `evidence
300
303
  add/verify`, `link`) so a scope='global' memory recorded once is still
@@ -309,17 +312,29 @@ def find_memory_store(local_conn, global_conn, memory_id: int, scope: str | None
309
312
  get_memory(global_conn, memory_id)
310
313
  return global_conn
311
314
 
312
- try:
313
- get_memory(local_conn, memory_id)
315
+ local_hit = _has_memory(local_conn, memory_id)
316
+ global_hit = global_conn is not None and _has_memory(global_conn, memory_id)
317
+
318
+ if local_hit and global_hit:
319
+ raise AmbiguousIdError(
320
+ f"id {memory_id} exists in both repo and global stores -- "
321
+ "pass --scope repo or --scope global to disambiguate"
322
+ )
323
+ if local_hit:
314
324
  return local_conn
315
- except NotFoundError:
316
- pass
317
- if global_conn is not None:
318
- get_memory(global_conn, memory_id) # raises NotFoundError if absent there too
325
+ if global_hit:
319
326
  return global_conn
320
327
  raise NotFoundError(f"no memory with id {memory_id}")
321
328
 
322
329
 
330
+ def _has_memory(conn, memory_id: int) -> bool:
331
+ try:
332
+ get_memory(conn, memory_id)
333
+ return True
334
+ except NotFoundError:
335
+ return False
336
+
337
+
323
338
  def reverify(conn, repo_root: Path, memory_id: int) -> None:
324
339
  """Re-verification: recapture the current HEAD blob hash of every
325
340
  linked path and bump `last_verified_at` to now.
@@ -32,6 +32,21 @@ def open_store(repo_arg: str | None) -> tuple[Path, sqlite3.Connection]:
32
32
  GLOBAL_DIR = Path.home() / ".cortex"
33
33
  GLOBAL_DB_FILENAME = "global.db"
34
34
 
35
+ # Repo-local stores and the global store share one schema/migration chain,
36
+ # so each starts its own autoincrement id sequence at 1 -- meaning a repo
37
+ # memory #10 and a global memory #10 can (and did, live) coexist and refer
38
+ # to unrelated rows, with nothing to disambiguate a bare id between them
39
+ # (see prompt-bug-roco.md). Reserving this range for the global store makes
40
+ # new collisions structurally impossible: no realistic single-user repo
41
+ # will ever record a billion memories. This only pushes future ids in the
42
+ # global store's autoincrement tables above the floor -- it never touches
43
+ # already-assigned ids (that would renumber existing memory/evidence rows
44
+ # out from under their own FK references and any external reference to
45
+ # those ids), so ids assigned before this fix shipped can still collide;
46
+ # `find_memory_store`'s AmbiguousIdError is what catches those.
47
+ GLOBAL_ID_FLOOR = 1_000_000_000
48
+ _GLOBAL_AUTOINCREMENT_TABLES = ("memory", "evidence", "memory_link", "cold_storage")
49
+
35
50
  # Override for tests (and any user who wants a non-default location): a full
36
51
  # path to the global db file. Without this, every test process would read
37
52
  # and write the *real* ~/.cortex/global.db on whatever machine runs the
@@ -69,4 +84,27 @@ def open_global_store() -> sqlite3.Connection | None:
69
84
  db_path.parent.mkdir(parents=True, exist_ok=True)
70
85
  conn = connect(db_path)
71
86
  migrate(conn)
87
+ _ensure_global_id_floor(conn)
72
88
  return conn
89
+
90
+
91
+ def _ensure_global_id_floor(conn: sqlite3.Connection) -> None:
92
+ """Bump each autoincrement table's next id up to GLOBAL_ID_FLOOR if it
93
+ isn't already past it. Idempotent (only ever raises seq, never lowers
94
+ it) and a no-op once the floor has been crossed, so it's cheap to call
95
+ on every open rather than needing a one-time migration step.
96
+ """
97
+ for table in _GLOBAL_AUTOINCREMENT_TABLES:
98
+ row = conn.execute(
99
+ "SELECT seq FROM sqlite_sequence WHERE name = ?", (table,)
100
+ ).fetchone()
101
+ if row is None:
102
+ conn.execute(
103
+ "INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)",
104
+ (table, GLOBAL_ID_FLOOR),
105
+ )
106
+ elif row[0] < GLOBAL_ID_FLOOR:
107
+ conn.execute(
108
+ "UPDATE sqlite_sequence SET seq = ? WHERE name = ?",
109
+ (GLOBAL_ID_FLOOR, table),
110
+ )
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import os
3
+ import sqlite3
2
4
  import subprocess
3
5
  import sys
4
6
 
@@ -104,12 +106,14 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
104
106
  assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
105
107
  assert _run_cli("init", "--repo", str(repo_b)).returncode == 0
106
108
 
107
- assert _run_cli(
109
+ record_result = _run_cli(
108
110
  "record", "--repo", str(repo_a),
109
111
  "--type", "lesson", "--scope", "global", "--confidence", "high",
110
112
  "--statement", "Prefer SQLite over Postgres for local-first single-user tools.",
111
113
  "--assumptions", "single-user, local-first",
112
- ).returncode == 0
114
+ "--json",
115
+ )
116
+ assert record_result.returncode == 0
113
117
 
114
118
  retrieve_result = _run_cli(
115
119
  "retrieve", "--repo", str(repo_b),
@@ -120,7 +124,7 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
120
124
  assert "assumptions_gate=" in retrieve_result.stdout
121
125
  assert "passed=True" in retrieve_result.stdout
122
126
 
123
- memory_id = 1
127
+ memory_id = json.loads(record_result.stdout)["id"]
124
128
  show_result = _run_cli(
125
129
  "show", str(memory_id), "--repo", str(repo_b),
126
130
  "--explain-against", "database choice for a single-user local-first tool",
@@ -129,9 +133,11 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
129
133
  assert "assumptions_gate=" in show_result.stdout
130
134
 
131
135
 
132
- def test_cli_scope_disambiguates_a_colliding_id(tmp_path):
133
- """Proven live: a local id 1 shadows global id 1 for show/status/
134
- evidence without a way to say which store is meant. --scope fixes it."""
136
+ def test_cli_new_global_ids_no_longer_collide_with_repo_ids(tmp_path):
137
+ """Structural half of the id-collision fix (prompt-bug-roco.md option
138
+ 3): the global store's ids are now partitioned into a high range, so a
139
+ freshly recorded global memory no longer collides with a repo-local id
140
+ -- `show` resolves either one correctly with no --scope needed."""
135
141
  repo_a = build_fixture_repo_a(tmp_path / "a")
136
142
  assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
137
143
 
@@ -148,11 +154,52 @@ def test_cli_scope_disambiguates_a_colliding_id(tmp_path):
148
154
  )
149
155
  local_id = json.loads(local_record.stdout)["id"]
150
156
  global_id = json.loads(global_record.stdout)["id"]
157
+ assert local_id != global_id
158
+
159
+ local_show = _run_cli("show", str(local_id), "--repo", str(repo_a), "--json")
160
+ global_show = _run_cli("show", str(global_id), "--repo", str(repo_a), "--json")
161
+ assert json.loads(local_show.stdout)["statement"] == "a local fact"
162
+ assert json.loads(global_show.stdout)["statement"] == "a global lesson"
163
+
164
+
165
+ def test_cli_scope_disambiguates_a_legacy_colliding_id(tmp_path):
166
+ """Proven live (prompt-bug-roco.md): before the id-floor fix, a local
167
+ id could shadow a global id of the same number for show/status/evidence
168
+ with no way to say which store is meant. New installs no longer produce
169
+ this (ids are partitioned apart, re-enforced on every open -- resetting
170
+ the sequence between CLI invocations wouldn't stick), but rows already
171
+ assigned a low id before the floor existed still can collide forever
172
+ (the floor never renumbers existing rows). Simulated here by inserting
173
+ a global row directly with an explicit low id, bypassing the sequence
174
+ the same way pre-fix on-disk data would sit. Bare `show` must now
175
+ refuse instead of silently shadowing; --scope still reaches each one."""
176
+ repo_a = build_fixture_repo_a(tmp_path / "a")
177
+ assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
178
+ assert _run_cli("init", "--repo", str(repo_a), "--global").returncode == 0
179
+
180
+ local_record = _run_cli(
181
+ "record", "--repo", str(repo_a),
182
+ "--type", "fact", "--scope", "repo", "--confidence", "low",
183
+ "--statement", "a local fact", "--json",
184
+ )
185
+ local_id = json.loads(local_record.stdout)["id"]
186
+
187
+ global_db_path = os.environ["ROBO_CORTEX_GLOBAL_DB"]
188
+ global_conn = sqlite3.connect(global_db_path)
189
+ global_conn.execute(
190
+ "INSERT INTO memory (id, type, scope, statement, assumptions, confidence) "
191
+ "VALUES (?, 'lesson', 'global', 'a global lesson', 'single-user', 'high')",
192
+ (local_id,),
193
+ )
194
+ global_conn.commit()
195
+ global_conn.close()
196
+ global_id = local_id
151
197
  assert local_id == global_id # the collision this test is about
152
198
 
153
- # without disambiguation, the local one shadows the global one
154
- default_show = _run_cli("show", str(local_id), "--repo", str(repo_a), "--json")
155
- assert json.loads(default_show.stdout)["statement"] == "a local fact"
199
+ # without disambiguation, the ambiguous id is refused, not silently guessed
200
+ default_show = _run_cli("show", str(local_id), "--repo", str(repo_a))
201
+ assert default_show.returncode != 0
202
+ assert "exists in both repo and global stores" in default_show.stderr
156
203
 
157
204
  # --scope reaches each one unambiguously
158
205
  repo_show = _run_cli("show", str(local_id), "--repo", str(repo_a), "--scope", "repo", "--json")
@@ -1,9 +1,15 @@
1
1
  import pytest
2
2
 
3
3
  from robo_cortex.core.db import connect, migrate
4
- from robo_cortex.core.errors import NotFoundError, ValidationError
5
- from robo_cortex.core.evidence import attach_evidence, evidence_strength, verify_evidence
4
+ from robo_cortex.core.errors import AmbiguousIdError, NotFoundError, ValidationError
5
+ from robo_cortex.core.evidence import (
6
+ attach_evidence,
7
+ evidence_strength,
8
+ find_evidence_store,
9
+ verify_evidence,
10
+ )
6
11
  from robo_cortex.core.memory import get_memory, record_memory
12
+ from robo_cortex.core.store import open_global_store
7
13
 
8
14
  from .fixtures import build_fixture_repo_a
9
15
 
@@ -131,3 +137,42 @@ def test_verify_evidence_rejects_unknown_id(tmp_path):
131
137
  _repo, conn = _store(tmp_path)
132
138
  with pytest.raises(NotFoundError):
133
139
  verify_evidence(conn, 9999)
140
+
141
+
142
+ def test_find_evidence_store_raises_ambiguous_on_id_collision(tmp_path):
143
+ """Evidence ids are a per-store autoincrement sequence too -- same
144
+ shape as the memory-id collision (prompt-bug-roco.md), just not routed
145
+ through find_memory_store since evidence rows aren't memories. The
146
+ store.py id-floor fix stops *new* collisions, but ids assigned before
147
+ that floor existed can still collide; simulated here by resetting the
148
+ global store's evidence sequence back down, the way pre-fix on-disk
149
+ data would look."""
150
+ repo, local_conn = _store(tmp_path)
151
+ global_conn = open_global_store()
152
+
153
+ local_result = record_memory(local_conn, repo, type="fact", scope="repo", statement="x", confidence="low")
154
+ local_evidence = attach_evidence(
155
+ local_conn, repo, local_result["id"], kind="free_text", description="local evidence",
156
+ )
157
+
158
+ global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'evidence'")
159
+ global_result = record_memory(
160
+ global_conn, repo, type="lesson", scope="global", confidence="high",
161
+ statement="global lesson", assumptions="single-user",
162
+ )
163
+ global_evidence = attach_evidence(
164
+ global_conn, repo, global_result["id"], kind="free_text", description="global evidence",
165
+ )
166
+
167
+ assert local_evidence["evidence_id"] == global_evidence["evidence_id"] # the collision this test is about
168
+
169
+ with pytest.raises(AmbiguousIdError, match="exists in both repo and global stores"):
170
+ find_evidence_store(local_conn, global_conn, local_evidence["evidence_id"])
171
+
172
+ # explicit scope reaches each one unambiguously
173
+ assert find_evidence_store(
174
+ local_conn, global_conn, local_evidence["evidence_id"], scope="repo"
175
+ ) is local_conn
176
+ assert find_evidence_store(
177
+ local_conn, global_conn, global_evidence["evidence_id"], scope="global"
178
+ ) is global_conn
@@ -6,6 +6,7 @@ JSON-RPC, same as the stdout-purity tests.
6
6
 
7
7
  import json
8
8
  import os
9
+ import sqlite3
9
10
  import subprocess
10
11
  import sys
11
12
 
@@ -303,10 +304,14 @@ def test_verify_evidence_gitea_backed_degrades_gracefully(tmp_path):
303
304
  assert result["reason"] == "gitea_not_configured"
304
305
 
305
306
 
306
- def test_scope_disambiguator_reaches_shadowed_global_memory(tmp_path):
307
+ def test_scope_disambiguator_reaches_a_legacy_colliding_memory(tmp_path):
307
308
  """The second pre-Stage-9 fix, verified all the way through the MCP
308
- layer: a local id 1 shadows global id 1 by default; scope='global'
309
- reaches the intended memory."""
309
+ layer. A fresh global id no longer collides with a repo-local one at all
310
+ (store.py's id-floor partitioning) -- forced here by resetting the
311
+ global sequence back down, the way pre-fix on-disk data would look, so
312
+ this still exercises the collision path. Bare get_memory on the
313
+ ambiguous id must now fail instead of silently shadowing to local;
314
+ scope='repo'/'global' still reach each one."""
310
315
  repo = build_fixture_repo_a(tmp_path)
311
316
  _init(repo)
312
317
 
@@ -314,18 +319,30 @@ def test_scope_disambiguator_reaches_shadowed_global_memory(tmp_path):
314
319
  local_result = await _call(session, "record_memory", {
315
320
  "type": "fact", "scope": "repo", "confidence": "low", "statement": "a local fact",
316
321
  })
322
+
323
+ global_db_path = os.environ["ROBO_CORTEX_GLOBAL_DB"]
324
+ global_conn = sqlite3.connect(global_db_path)
325
+ global_conn.execute(
326
+ "UPDATE sqlite_sequence SET seq = ? WHERE name = 'memory'",
327
+ (local_result["id"] - 1,),
328
+ )
329
+ global_conn.commit()
330
+ global_conn.close()
331
+
317
332
  global_result = await _call(session, "record_memory", {
318
333
  "type": "lesson", "scope": "global", "confidence": "high",
319
334
  "statement": "a global lesson", "assumptions": "single-user",
320
335
  })
321
- assert local_result["id"] == global_result["id"]
336
+ assert local_result["id"] == global_result["id"] # the collision this test is about
322
337
 
323
- default_fetch = await _call(session, "get_memory", {"memory_id": local_result["id"]})
338
+ default_error = await _call_expect_error(
339
+ session, "get_memory", {"memory_id": local_result["id"]}
340
+ )
324
341
  repo_fetch = await _call(session, "get_memory", {"memory_id": local_result["id"], "scope": "repo"})
325
342
  global_fetch = await _call(session, "get_memory", {"memory_id": global_result["id"], "scope": "global"})
326
- return default_fetch, repo_fetch, global_fetch
343
+ return default_error, repo_fetch, global_fetch
327
344
 
328
- default_fetch, repo_fetch, global_fetch = run_scenario(repo, scenario)
329
- assert default_fetch["statement"] == "a local fact" # default shadows to local
345
+ default_error, repo_fetch, global_fetch = run_scenario(repo, scenario)
346
+ assert "exists in both repo and global stores" in default_error
330
347
  assert repo_fetch["statement"] == "a local fact"
331
348
  assert global_fetch["statement"] == "a global lesson"
@@ -5,7 +5,7 @@
5
5
  import pytest
6
6
 
7
7
  from robo_cortex.core.db import connect, migrate
8
- from robo_cortex.core.errors import NotFoundError, ValidationError
8
+ from robo_cortex.core.errors import AmbiguousIdError, NotFoundError, ValidationError
9
9
  from robo_cortex.core.memory import find_memory_store, get_memory, list_memories, record_memory
10
10
  from robo_cortex.core.retrieve import retrieve_context, search_memory
11
11
  from robo_cortex.core.store import open_global_store
@@ -212,45 +212,55 @@ def test_find_memory_store_falls_back_to_global_when_absent_locally(tmp_path):
212
212
  find_memory_store(local_conn_a, global_conn, 999999)
213
213
 
214
214
 
215
- def test_find_memory_store_prefers_local_on_id_collision(tmp_path):
216
- """Both stores are independent autoincrement sequences and both start
217
- at 1, so the same id can legitimately refer to two different memories.
218
- Documented resolution order: local wins."""
215
+ def test_find_memory_store_raises_ambiguous_on_id_collision(tmp_path):
216
+ """Both stores are independent autoincrement sequences, so the same id
217
+ can legitimately refer to two different memories -- proven live
218
+ (prompt-bug-roco.md): silently preferring local here let `roco status
219
+ 10 activate` flip an unrelated repo memory instead of the intended
220
+ global one, with no error and no indication anything was wrong.
221
+ scope=None must now refuse instead of guessing."""
219
222
  repo_a, local_conn_a = _repo_a(tmp_path)
220
223
  global_conn = open_global_store()
221
224
 
222
225
  local_result = record_memory(
223
226
  local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
224
227
  )
228
+ # the id-floor fix (store.py) stops *new* global ids from colliding with
229
+ # repo-local ones -- force the pre-fix scenario (ids assigned before the
230
+ # floor existed) by resetting the global sequence back down, since that
231
+ # residual case is exactly what AmbiguousIdError still needs to catch.
232
+ global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'memory'")
225
233
  global_result = record_memory(
226
234
  global_conn, repo_a, type="lesson", scope="global", confidence="high",
227
235
  statement="global lesson", assumptions="single-user",
228
236
  )
229
237
  assert local_result["id"] == global_result["id"] # the collision this test is about
230
238
 
231
- resolved = find_memory_store(local_conn_a, global_conn, local_result["id"])
232
- assert resolved is local_conn_a
233
- assert get_memory(resolved, local_result["id"])["statement"] == "local fact"
239
+ with pytest.raises(AmbiguousIdError, match="exists in both repo and global stores"):
240
+ find_memory_store(local_conn_a, global_conn, local_result["id"])
234
241
 
235
242
 
236
- def test_find_memory_store_explicit_scope_reaches_the_shadowed_global_memory(tmp_path):
237
- """Proven live: without a disambiguator, a colliding local id 1 makes
238
- global id 1 completely unreachable by id. --scope='global' (surfaced in
239
- the CLI and, per Gate B follow-up, as an MCP tool input) is the fix."""
243
+ def test_find_memory_store_explicit_scope_reaches_either_side_of_a_collision(tmp_path):
244
+ """Proven live: without a disambiguator, a colliding id is ambiguous
245
+ between stores. --scope='repo'/'global' (surfaced in the CLI and, per
246
+ Gate B follow-up, as an MCP tool input) reaches each one unambiguously."""
240
247
  repo_a, local_conn_a = _repo_a(tmp_path)
241
248
  global_conn = open_global_store()
242
249
 
243
250
  local_result = record_memory(
244
251
  local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
245
252
  )
253
+ # force a pre-id-floor-style collision -- see comment above
254
+ global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'memory'")
246
255
  global_result = record_memory(
247
256
  global_conn, repo_a, type="lesson", scope="global", confidence="high",
248
257
  statement="global lesson", assumptions="single-user",
249
258
  )
250
259
  assert local_result["id"] == global_result["id"]
251
260
 
252
- # default (no scope) still shadows the global one -- documented, not a bug
253
- assert find_memory_store(local_conn_a, global_conn, local_result["id"]) is local_conn_a
261
+ # default (no scope) is ambiguous and must be refused, not silently guessed
262
+ with pytest.raises(AmbiguousIdError):
263
+ find_memory_store(local_conn_a, global_conn, local_result["id"])
254
264
 
255
265
  # explicit scope reaches each one unambiguously
256
266
  local_store = find_memory_store(local_conn_a, global_conn, local_result["id"], scope="repo")
@@ -269,3 +279,48 @@ def test_find_memory_store_explicit_scope_raises_not_found_if_wrong(tmp_path):
269
279
 
270
280
  with pytest.raises(NotFoundError):
271
281
  find_memory_store(local_conn_a, global_conn, local_result["id"], scope="global")
282
+
283
+
284
+ def test_global_store_ids_are_partitioned_above_repo_ids(tmp_path):
285
+ """Structural half of the id-collision fix (prompt-bug-roco.md option
286
+ 3): new global memories start far above any realistic repo-local id, so
287
+ future recordings can no longer collide by construction -- unlike the
288
+ AmbiguousIdError guard, which only catches a collision after it exists."""
289
+ from robo_cortex.core.store import GLOBAL_ID_FLOOR
290
+
291
+ repo_a, local_conn_a = _repo_a(tmp_path)
292
+ global_conn = open_global_store()
293
+
294
+ local_result = record_memory(
295
+ local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
296
+ )
297
+ global_result = record_memory(
298
+ global_conn, repo_a, type="lesson", scope="global", confidence="high",
299
+ statement="global lesson", assumptions="single-user",
300
+ )
301
+
302
+ assert local_result["id"] < GLOBAL_ID_FLOOR
303
+ assert global_result["id"] >= GLOBAL_ID_FLOOR
304
+ assert local_result["id"] != global_result["id"]
305
+
306
+ # unambiguous by construction now -- no --scope needed
307
+ resolved = find_memory_store(local_conn_a, global_conn, global_result["id"])
308
+ assert resolved is global_conn
309
+
310
+
311
+ def test_global_store_id_floor_does_not_renumber_existing_rows(tmp_path):
312
+ """The floor only pushes the *next* id up; it must never touch ids
313
+ already assigned (that would break memory_link/evidence FK references,
314
+ and any external reference to a memory by id)."""
315
+ repo_a, local_conn_a = _repo_a(tmp_path)
316
+ global_conn = open_global_store()
317
+
318
+ first = record_memory(
319
+ global_conn, repo_a, type="lesson", scope="global", confidence="high",
320
+ statement="first global lesson", assumptions="single-user",
321
+ )
322
+ global_conn.close()
323
+
324
+ # simulate a second process/CLI invocation re-opening the same store
325
+ reopened = open_global_store()
326
+ assert get_memory(reopened, first["id"])["statement"] == "first global lesson"
@@ -2,4 +2,4 @@ from robo_cortex import __version__
2
2
 
3
3
 
4
4
  def test_version_is_set():
5
- assert __version__ == "0.4.2"
5
+ assert __version__ == "0.4.3"
File without changes
File without changes
File without changes
File without changes