cortexdb-mcp 0.3.3__tar.gz → 0.4.0__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.
@@ -77,3 +77,6 @@ blog/
77
77
  sales/
78
78
  videos/
79
79
  local-instance/
80
+
81
+ # doc-claims verifier scratch output
82
+ tools/verifier_out*/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-mcp
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: MCP Server for CortexDB — expose memory operations to AI agents
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -180,6 +180,16 @@ On Windows, MCP clients sometimes need the absolute path:
180
180
  | `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
181
181
  | `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
182
182
 
183
+ ### Bi-temporal Conflicts & Claim History
184
+
185
+ | Tool | Maps to | Description |
186
+ |------|---------|-------------|
187
+ | `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
188
+ | `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
189
+ | `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
190
+
191
+ Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
192
+
183
193
  ### Admin & Observability
184
194
 
185
195
  | Tool | Maps to | Description |
@@ -167,6 +167,16 @@ On Windows, MCP clients sometimes need the absolute path:
167
167
  | `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
168
168
  | `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
169
169
 
170
+ ### Bi-temporal Conflicts & Claim History
171
+
172
+ | Tool | Maps to | Description |
173
+ |------|---------|-------------|
174
+ | `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
175
+ | `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
176
+ | `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
177
+
178
+ Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
179
+
170
180
  ### Admin & Observability
171
181
 
172
182
  | Tool | Maps to | Description |
@@ -87,6 +87,68 @@ def fact_text(f: dict[str, Any]) -> str:
87
87
  return " ".join(p for p in (subj_text, predicate, obj_text) if p)
88
88
 
89
89
 
90
+ def _boundary_date(iso: str) -> str:
91
+ """Trim an RFC3339 boundary to its date part — validity boundaries are
92
+ day-grained after date grounding; the clock adds noise, not signal."""
93
+ return (iso or "")[:10]
94
+
95
+
96
+ def record_validity_text(r: dict[str, Any]) -> str:
97
+ """Render a RecordView's valid interval honoring ``BoundaryBasis``.
98
+
99
+ THE rendering rule (BITEMPORAL_CONFLICT_DESIGN §2): a boundary whose
100
+ basis is ``"observed"`` is only a knowledge bound — it must read as
101
+ "by <date>" (we knew it held/ended by then), NEVER "on/since <date>".
102
+ Stated boundaries render as "since <date>" / "until <date>".
103
+ """
104
+ parts: list[str] = []
105
+ vf, vt = r.get("valid_from"), r.get("valid_to")
106
+ if vf:
107
+ d = _boundary_date(str(vf))
108
+ parts.append(
109
+ f"held by {d}" if r.get("valid_from_basis") == "observed" else f"since {d}"
110
+ )
111
+ if vt:
112
+ d = _boundary_date(str(vt))
113
+ parts.append(
114
+ f"ended by {d}" if r.get("valid_to_basis") == "observed" else f"until {d}"
115
+ )
116
+ return ", ".join(parts) if parts else "undated"
117
+
118
+
119
+ def record_text(r: dict[str, Any]) -> str:
120
+ """Render one bi-temporal RecordView to a readable line:
121
+ ``<value> (<validity>; recorded <date>[; <status>][; conf NN%])``."""
122
+ value = "[redacted]" if r.get("redacted") else str(r.get("object") or "?")
123
+ bits: list[str] = [record_validity_text(r)]
124
+ recorded = _boundary_date(str(r.get("recorded_from") or ""))
125
+ if recorded:
126
+ bits.append(f"recorded {recorded}")
127
+ status = r.get("status")
128
+ if status and status != "active":
129
+ bits.append(str(status))
130
+ conf = r.get("confidence")
131
+ if isinstance(conf, (int, float)):
132
+ bits.append(f"conf {conf:.0%}")
133
+ return f"{value} ({'; '.join(bits)})"
134
+
135
+
136
+ def conflict_lines(c: dict[str, Any]) -> list[str]:
137
+ """Render a ConflictView as a header line plus one indented line per
138
+ member record (each honoring the observed→"by" boundary rule)."""
139
+ header = (
140
+ f"{c.get('id', '?')} [{c.get('kind', '?')}] "
141
+ f"{c.get('subject', '?')} {c.get('predicate', '?')} — {c.get('status', '?')}"
142
+ )
143
+ lines = [header]
144
+ for r in c.get("records", []) or []:
145
+ lines.append(f" - {record_text(r)}")
146
+ resolved_by = c.get("resolved_by")
147
+ if resolved_by:
148
+ lines.append(f" resolved by {resolved_by} at {c.get('resolved_at', '?')}")
149
+ return lines
150
+
151
+
90
152
  def belief_statement(b: dict[str, Any]) -> str:
91
153
  """The human-readable statement a belief asserts (claim.object.value). The
92
154
  claim subject is an internal topic key and is deliberately not used."""
@@ -33,9 +33,11 @@ from cortexdb_mcp.config import CortexMCPConfig
33
33
  from cortexdb_mcp.insights import InsightsEngine
34
34
  from cortexdb_mcp.render import (
35
35
  belief_text as _belief_text,
36
+ conflict_lines as _conflict_lines,
36
37
  fact_text as _fact_text,
37
38
  is_synthetic_subject as _is_synthetic_subject,
38
39
  memories_from_context as _memories_from_context,
40
+ record_text as _record_text,
39
41
  typed_value_text as _typed_value_text,
40
42
  )
41
43
 
@@ -722,6 +724,186 @@ async def entity_link(
722
724
  )
723
725
 
724
726
 
727
+ # ===========================================================================
728
+ # TOOLS — Bi-temporal conflicts & claim history
729
+ # ===========================================================================
730
+ #
731
+ # Rendering honors BoundaryBasis (BITEMPORAL_CONFLICT_DESIGN §2): boundaries
732
+ # whose basis is "observed" render as "by <date>" (via render.record_text),
733
+ # never "on/since <date>".
734
+
735
+ # Per-action argument contract for POST /v1/conflicts/{id}/resolve.
736
+ _RESOLVE_ACTIONS: dict[str, dict[str, tuple[str, ...]]] = {
737
+ "pick": {"required": ("winner",), "optional": ("reason",)},
738
+ "split": {"required": ("boundary", "earlier", "later"), "optional": ()},
739
+ "new_information": {"required": ("record",), "optional": ()},
740
+ "dismiss": {"required": (), "optional": ("reason",)},
741
+ }
742
+
743
+
744
+ def _resolve_body(action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
745
+ """Validate + build the resolve request body; raises ValueError with a
746
+ message the agent can act on (which field is missing/extraneous)."""
747
+ spec = _RESOLVE_ACTIONS.get(action)
748
+ if spec is None:
749
+ raise ValueError(
750
+ f"unknown action {action!r}; expected one of {sorted(_RESOLVE_ACTIONS)}"
751
+ )
752
+ allowed = set(spec["required"]) | set(spec["optional"])
753
+ provided = {k for k, v in kwargs.items() if v is not None}
754
+ missing = [k for k in spec["required"] if k not in provided]
755
+ if missing:
756
+ raise ValueError(f"action {action!r} requires {missing}")
757
+ extraneous = sorted(provided - allowed)
758
+ if extraneous:
759
+ raise ValueError(
760
+ f"action {action!r} does not accept {extraneous} (allowed: {sorted(allowed)})"
761
+ )
762
+ body: dict[str, Any] = {"action": action}
763
+ for k in allowed:
764
+ if kwargs.get(k) is not None:
765
+ body[k] = kwargs[k]
766
+ if action in ("pick", "dismiss"):
767
+ body.setdefault("reason", "")
768
+ return body
769
+
770
+
771
+ @mcp.tool()
772
+ async def list_conflicts(
773
+ tenant_id: str | None = None,
774
+ status: str = "open",
775
+ limit: int = 20,
776
+ ) -> str:
777
+ """List detected memory conflicts (contradicting values for the same
778
+ claim). Maps to GET /v1/conflicts.
779
+
780
+ Parameters
781
+ ----------
782
+ tenant_id:
783
+ Scope override (defaults to the configured scope).
784
+ status:
785
+ "open" (default) for unresolved conflicts only, or "all".
786
+ limit:
787
+ Max conflicts to return.
788
+ """
789
+ scope = _scope_for(tenant_id)
790
+ result = await _request(
791
+ "GET",
792
+ "/v1/conflicts",
793
+ params={"scope": scope, "status": status, "limit": str(limit)},
794
+ )
795
+ conflicts = result.get("conflicts", [])
796
+ total = result.get("total", len(conflicts))
797
+ if not conflicts:
798
+ qualifier = "open " if status != "all" else ""
799
+ return f"No {qualifier}conflicts in this scope."
800
+ parts: list[str] = [f"Showing {len(conflicts)} of {total} conflict(s):\n"]
801
+ for c in conflicts:
802
+ parts.extend(_conflict_lines(c))
803
+ parts.append("")
804
+ next_cursor = result.get("next_cursor")
805
+ if next_cursor is not None:
806
+ parts.append(f"More available (cursor={next_cursor}).")
807
+ return "\n".join(parts).rstrip()
808
+
809
+
810
+ @mcp.tool()
811
+ async def resolve_conflict(
812
+ conflict_id: str,
813
+ action: str,
814
+ winner: str | None = None,
815
+ reason: str | None = None,
816
+ boundary: str | None = None,
817
+ earlier: str | None = None,
818
+ later: str | None = None,
819
+ record: str | None = None,
820
+ ) -> str:
821
+ """Resolve a memory conflict. Maps to POST /v1/conflicts/{id}/resolve.
822
+
823
+ Parameters
824
+ ----------
825
+ conflict_id:
826
+ The conflict UUID (from list_conflicts).
827
+ action:
828
+ One of:
829
+ - "pick": one record wins outright — requires `winner` (record
830
+ UUID); `reason` optional.
831
+ - "split": both values were true at different times — requires
832
+ `boundary` (absolute date, e.g. "2024-03"), `earlier` and
833
+ `later` (record UUIDs).
834
+ - "new_information": the record supersedes the older value —
835
+ requires `record` (record UUID).
836
+ - "dismiss": not a real conflict — `reason` optional.
837
+ """
838
+ try:
839
+ body = _resolve_body(
840
+ action,
841
+ {
842
+ "winner": winner,
843
+ "reason": reason,
844
+ "boundary": boundary,
845
+ "earlier": earlier,
846
+ "later": later,
847
+ "record": record,
848
+ },
849
+ )
850
+ except ValueError as exc:
851
+ return f"Invalid resolution: {exc}"
852
+ result = await _request(
853
+ "POST", f"/v1/conflicts/{conflict_id}/resolve", json_body=body
854
+ )
855
+ if not result.get("id"):
856
+ return f"Conflict {conflict_id} resolved ({action})."
857
+ parts = [f"Conflict resolved ({action}):", ""]
858
+ parts.extend(_conflict_lines(result))
859
+ return "\n".join(parts)
860
+
861
+
862
+ @mcp.tool()
863
+ async def claim_history(
864
+ subject: str,
865
+ predicate: str,
866
+ tenant_id: str | None = None,
867
+ ) -> str:
868
+ """Show the full bi-temporal change-log of one claim — every valid
869
+ interval and every record-time correction, plus any conflicts involving
870
+ it. Maps to GET /v1/claims/history.
871
+
872
+ Parameters
873
+ ----------
874
+ subject:
875
+ The claim's subject entity (e.g. "Alice").
876
+ predicate:
877
+ The claim's predicate (e.g. "works_at").
878
+ tenant_id:
879
+ Scope override.
880
+ """
881
+ scope = _scope_for(tenant_id)
882
+ result = await _request(
883
+ "GET",
884
+ "/v1/claims/history",
885
+ params={"scope": scope, "subject": subject, "predicate": predicate},
886
+ )
887
+ claim = result.get("claim", {}) or {}
888
+ history = result.get("history", []) or []
889
+ conflicts = result.get("conflicts", []) or []
890
+ if not history:
891
+ return f"No recorded history for claim '{subject} {predicate}'."
892
+ parts: list[str] = [
893
+ f"History of {claim.get('subject', subject)} "
894
+ f"{claim.get('predicate', predicate)} "
895
+ f"({len(history)} record(s)):\n"
896
+ ]
897
+ for r in history:
898
+ parts.append(f" {_record_text(r)}")
899
+ if conflicts:
900
+ parts.append(f"\n{len(conflicts)} conflict(s) on this claim:")
901
+ for c in conflicts:
902
+ for line in _conflict_lines(c):
903
+ parts.append(f" {line}")
904
+ return "\n".join(parts)
905
+
906
+
725
907
  # ===========================================================================
726
908
  # TOOLS — Admin & Observability
727
909
  # ===========================================================================
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cortexdb-mcp"
7
- version = "0.3.3"
7
+ version = "0.4.0"
8
8
  description = "MCP Server for CortexDB — expose memory operations to AI agents"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -15,14 +15,18 @@ import pytest
15
15
 
16
16
  from cortexdb_mcp.config import CortexMCPConfig
17
17
  from cortexdb_mcp.insights import Insight, InsightsEngine, InsightType, Severity
18
+ from cortexdb_mcp.render import record_validity_text
18
19
  from cortexdb_mcp.server import (
19
20
  _belief_text,
20
21
  _config,
22
+ _conflict_lines,
21
23
  _fact_text,
22
24
  _is_synthetic_subject,
23
25
  _memories_from_context,
26
+ _record_text,
24
27
  _typed_value_text,
25
28
  advanced_search,
29
+ claim_history,
26
30
  entity_edges,
27
31
  entity_get,
28
32
  entity_link,
@@ -30,6 +34,7 @@ from cortexdb_mcp.server import (
30
34
  get_context,
31
35
  get_insights,
32
36
  health_check,
37
+ list_conflicts,
33
38
  memory_bulk_delete,
34
39
  memory_delete,
35
40
  memory_forget,
@@ -37,6 +42,7 @@ from cortexdb_mcp.server import (
37
42
  memory_list,
38
43
  memory_search,
39
44
  memory_store,
45
+ resolve_conflict,
40
46
  )
41
47
 
42
48
 
@@ -432,6 +438,252 @@ class TestEntities:
432
438
  assert "evt_link" in out
433
439
 
434
440
 
441
+ # ---------------------------------------------------------------------------
442
+ # Bi-temporal conflicts & claim history
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def _record(
447
+ obj="Google",
448
+ *,
449
+ valid_from="2020-01-01T00:00:00Z",
450
+ vf_basis="stated",
451
+ valid_to=None,
452
+ vt_basis="stated",
453
+ status="active",
454
+ rid="0197a3a0-0000-7000-8000-00000000000a",
455
+ ):
456
+ return {
457
+ "id": rid,
458
+ "subject": "alice",
459
+ "predicate": "works_at",
460
+ "object": obj,
461
+ "redacted": False,
462
+ "valid_from": valid_from,
463
+ "valid_from_basis": vf_basis,
464
+ "valid_to": valid_to,
465
+ "valid_to_basis": vt_basis,
466
+ "recorded_from": "2026-06-15T12:00:00Z",
467
+ "recorded_to": None,
468
+ "status": status,
469
+ "supersedes": None,
470
+ "source_authority": "extraction",
471
+ "extractor": "llm",
472
+ "confidence": 0.9,
473
+ }
474
+
475
+
476
+ def _conflict(status="open", records=None, cid="0197a3a0-0000-7000-8000-0000000000c1"):
477
+ return {
478
+ "id": cid,
479
+ "scope": "org:test/user:test",
480
+ "subject": "alice",
481
+ "predicate": "works_at",
482
+ "kind": "value_disagreement",
483
+ "status": status,
484
+ "records": records if records is not None else [
485
+ _record("Google"),
486
+ _record("Meta", rid="0197a3a0-0000-7000-8000-00000000000b"),
487
+ ],
488
+ "detected_at": "2026-06-15T12:00:00Z",
489
+ "resolved_at": None,
490
+ "resolved_by": None,
491
+ "resolution": None,
492
+ }
493
+
494
+
495
+ class TestRecordRendering:
496
+ """Lock the BoundaryBasis rule: observed boundaries render as
497
+ "by <date>", never "on <date>" / "since <date>" (§2)."""
498
+
499
+ def test_stated_boundaries_render_since_until(self):
500
+ r = _record(valid_from="2020-01-01T00:00:00Z",
501
+ valid_to="2022-06-01T00:00:00Z")
502
+ out = record_validity_text(r)
503
+ assert out == "since 2020-01-01, until 2022-06-01"
504
+
505
+ def test_observed_valid_from_renders_by_never_since(self):
506
+ r = _record(vf_basis="observed")
507
+ out = record_validity_text(r)
508
+ assert "held by 2020-01-01" in out
509
+ assert "since" not in out
510
+ assert "on 2020" not in out
511
+
512
+ def test_observed_valid_to_renders_ended_by_never_until(self):
513
+ r = _record(valid_to="2022-06-01T00:00:00Z", vt_basis="observed")
514
+ out = record_validity_text(r)
515
+ assert "ended by 2022-06-01" in out
516
+ assert "until" not in out
517
+
518
+ def test_unbounded_is_undated(self):
519
+ assert record_validity_text(_record(valid_from=None)) == "undated"
520
+
521
+ def test_record_text_value_and_metadata(self):
522
+ out = _record_text(_record("Google"))
523
+ assert out.startswith("Google (")
524
+ assert "recorded 2026-06-15" in out
525
+ assert "conf 90%" in out
526
+ assert "active" not in out # only non-active statuses shown
527
+
528
+ def test_record_text_redacted(self):
529
+ r = _record("secret")
530
+ r["redacted"] = True
531
+ assert _record_text(r).startswith("[redacted]")
532
+
533
+ def test_conflict_lines_header_and_members(self):
534
+ lines = _conflict_lines(_conflict())
535
+ assert "[value_disagreement] alice works_at — open" in lines[0]
536
+ assert any("Google" in line for line in lines)
537
+ assert any("Meta" in line for line in lines)
538
+
539
+
540
+ class TestListConflicts:
541
+ @pytest.mark.asyncio
542
+ async def test_list_hits_v1_conflicts_with_scope(self):
543
+ patcher, mock = _mock_request({
544
+ "conflicts": [_conflict()], "total": 1, "next_cursor": None,
545
+ })
546
+ with patcher:
547
+ out = await list_conflicts()
548
+ method, path = mock.call_args[0][:2]
549
+ assert method == "GET"
550
+ assert path == "/v1/conflicts"
551
+ params = mock.call_args[1]["params"]
552
+ assert params["scope"] == "org:test/user:test"
553
+ assert params["status"] == "open"
554
+ assert "value_disagreement" in out
555
+ assert "Google" in out and "Meta" in out
556
+
557
+ @pytest.mark.asyncio
558
+ async def test_observed_basis_renders_by_in_tool_output(self):
559
+ c = _conflict(records=[_record("Google", vf_basis="observed")])
560
+ patcher, _ = _mock_request({"conflicts": [c], "total": 1, "next_cursor": None})
561
+ with patcher:
562
+ out = await list_conflicts()
563
+ assert "by 2020-01-01" in out
564
+ assert "since 2020-01-01" not in out
565
+
566
+ @pytest.mark.asyncio
567
+ async def test_empty_queue(self):
568
+ patcher, _ = _mock_request({"conflicts": [], "total": 0, "next_cursor": None})
569
+ with patcher:
570
+ out = await list_conflicts()
571
+ assert "No open conflicts" in out
572
+
573
+ @pytest.mark.asyncio
574
+ async def test_pagination_hint(self):
575
+ patcher, mock = _mock_request({
576
+ "conflicts": [_conflict()], "total": 5, "next_cursor": 1,
577
+ })
578
+ with patcher:
579
+ out = await list_conflicts(status="all", limit=1)
580
+ assert mock.call_args[1]["params"]["status"] == "all"
581
+ assert "cursor=1" in out
582
+
583
+
584
+ class TestResolveConflict:
585
+ @pytest.mark.asyncio
586
+ async def test_pick_posts_tagged_body(self):
587
+ patcher, mock = _mock_request(_conflict(status="manually_resolved"))
588
+ with patcher:
589
+ out = await resolve_conflict(
590
+ conflict_id="c1", action="pick",
591
+ winner="0197a3a0-0000-7000-8000-00000000000b",
592
+ reason="user confirmed",
593
+ )
594
+ method, path = mock.call_args[0][:2]
595
+ assert method == "POST"
596
+ assert path == "/v1/conflicts/c1/resolve"
597
+ body = mock.call_args[1]["json_body"]
598
+ assert body == {
599
+ "action": "pick",
600
+ "winner": "0197a3a0-0000-7000-8000-00000000000b",
601
+ "reason": "user confirmed",
602
+ }
603
+ assert "manually_resolved" in out
604
+
605
+ @pytest.mark.asyncio
606
+ async def test_split_requires_all_fields(self):
607
+ patcher, mock = _mock_request({})
608
+ with patcher:
609
+ out = await resolve_conflict(
610
+ conflict_id="c1", action="split", boundary="2024-03",
611
+ )
612
+ assert "Invalid resolution" in out
613
+ assert "earlier" in out
614
+ mock.assert_not_called() # nothing hit the wire
615
+
616
+ @pytest.mark.asyncio
617
+ async def test_unknown_action_and_extraneous_args(self):
618
+ patcher, mock = _mock_request({})
619
+ with patcher:
620
+ out1 = await resolve_conflict(conflict_id="c1", action="merge")
621
+ out2 = await resolve_conflict(
622
+ conflict_id="c1", action="dismiss", winner="r1",
623
+ )
624
+ assert "unknown action" in out1
625
+ assert "does not accept" in out2
626
+ mock.assert_not_called()
627
+
628
+ @pytest.mark.asyncio
629
+ async def test_dismiss_defaults_reason(self):
630
+ patcher, mock = _mock_request(_conflict(status="dismissed"))
631
+ with patcher:
632
+ await resolve_conflict(conflict_id="c1", action="dismiss")
633
+ assert mock.call_args[1]["json_body"] == {"action": "dismiss", "reason": ""}
634
+
635
+
636
+ class TestClaimHistory:
637
+ @pytest.mark.asyncio
638
+ async def test_history_hits_endpoint_and_renders_records(self):
639
+ patcher, mock = _mock_request({
640
+ "claim": {"scope": "org:test/user:test", "subject": "alice",
641
+ "predicate": "works_at"},
642
+ "history": [
643
+ _record("Google", valid_to="2022-06-01T00:00:00Z",
644
+ vt_basis="observed"),
645
+ _record("Meta", valid_from="2022-06-01T00:00:00Z",
646
+ vf_basis="observed",
647
+ rid="0197a3a0-0000-7000-8000-00000000000b"),
648
+ ],
649
+ "conflicts": [],
650
+ })
651
+ with patcher:
652
+ out = await claim_history(subject="Alice", predicate="works_at")
653
+ method, path = mock.call_args[0][:2]
654
+ assert method == "GET"
655
+ assert path == "/v1/claims/history"
656
+ params = mock.call_args[1]["params"]
657
+ assert params == {
658
+ "scope": "org:test/user:test",
659
+ "subject": "Alice",
660
+ "predicate": "works_at",
661
+ }
662
+ # Observed boundaries render as "by", never "since"/"until".
663
+ assert "ended by 2022-06-01" in out
664
+ assert "held by 2022-06-01" in out
665
+ assert "2 record(s)" in out
666
+
667
+ @pytest.mark.asyncio
668
+ async def test_history_lists_conflicts(self):
669
+ patcher, _ = _mock_request({
670
+ "claim": {"subject": "alice", "predicate": "works_at"},
671
+ "history": [_record()],
672
+ "conflicts": [_conflict()],
673
+ })
674
+ with patcher:
675
+ out = await claim_history(subject="alice", predicate="works_at")
676
+ assert "1 conflict(s)" in out
677
+ assert "value_disagreement" in out
678
+
679
+ @pytest.mark.asyncio
680
+ async def test_history_empty(self):
681
+ patcher, _ = _mock_request({"claim": {}, "history": [], "conflicts": []})
682
+ with patcher:
683
+ out = await claim_history(subject="nobody", predicate="none")
684
+ assert "No recorded history" in out
685
+
686
+
435
687
  # ---------------------------------------------------------------------------
436
688
  # Admin tools
437
689
  # ---------------------------------------------------------------------------
File without changes