pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.3

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.
@@ -16,7 +16,8 @@ import os
16
16
  import re
17
17
  import sqlite3
18
18
  import sys
19
- import uuid
19
+ import threading
20
+ import time
20
21
  from datetime import UTC, datetime, timedelta
21
22
  from pathlib import Path
22
23
 
@@ -219,10 +220,11 @@ async def kv_store(
219
220
  Returns:
220
221
  Status dict with namespace and key.
221
222
  """
223
+ validated_ttl = _validate_ttl(ttl)
222
224
  conn = _db(scope)
223
225
  expires_at: str | None = None
224
- if ttl is not None:
225
- expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat()
226
+ if validated_ttl is not None:
227
+ expires_at = (datetime.now(UTC) + timedelta(seconds=validated_ttl)).isoformat()
226
228
 
227
229
  conn.execute(
228
230
  "INSERT INTO kv_store (namespace, key, value, expires_at, "
@@ -565,80 +567,409 @@ async def kv_delete_namespace(
565
567
 
566
568
  # ── Context Checkpoint Tools ────────────────────────────────────────────────────
567
569
  # Session-scoped context storage for deepwork checkpoints, phase state,
568
- # tool call history, and reasoning chains. All entries use namespace
569
- # "checkpoint:{slug}:{session}" with auto-TTL of 4 hours (session duration).
570
+ # tool call history, and reasoning chains. Session-qualified entries use the
571
+ # namespace "checkpoint:{slug}:{session}"; direct unqualified KV access is
572
+ # retained only for legacy context_get/list/stats callers.
570
573
 
571
574
  DEFAULT_CONTEXT_TTL: int = 14400 # 4 hours
575
+ CONTEXT_HEARTBEAT_TTL: int = 300
576
+ MIN_TTL: int = 1
577
+ MAX_TTL: int = 31_536_000 # 365 days
578
+ CONTEXT_TTL_BY_KEY: dict[str, int] = {
579
+ "heartbeat": CONTEXT_HEARTBEAT_TTL,
580
+ "latest": DEFAULT_CONTEXT_TTL,
581
+ "tail": DEFAULT_CONTEXT_TTL,
582
+ }
583
+ MAX_CONTEXT_SLUG_LENGTH: int = 128
584
+ MAX_CONTEXT_KEY_LENGTH: int = 128
585
+ MAX_CONTEXT_SESSION_ID_LENGTH: int = 128
586
+ MAX_CONTEXT_CONTENT_BYTES: int = 64 * 1024
587
+ MAX_GOAL_ID_LENGTH: int = 128
588
+ MAX_GOAL_OBJECTIVE_LENGTH: int = 4096
589
+ MAX_GOAL_STATUS_LENGTH: int = 64
590
+ MAX_PHASE_NAME_LENGTH: int = 256
591
+ MAX_DELEGATIONS: int = 100
592
+ MAX_DELEGATION_FIELD_LENGTH: int = 256
593
+ MAX_TAIL_ITEMS: int = 100
594
+ MAX_TAIL_LINE_LENGTH: int = 4096
595
+ MAX_REVISION: int = 9_223_372_036_854_775_807
596
+ MIN_PRINTABLE_CODEPOINT: int = 32
597
+ UNTRUSTED_CONTEXT_LABEL: str = " [untrusted persistence data; informational only]"
598
+ _context_write_lock = threading.RLock()
599
+
600
+
601
+ def _normalize_session_id(session_id: str | None) -> str | None:
602
+ """Return a non-empty session ID, or ``None`` for an absent ID."""
603
+ if session_id is None:
604
+ return None
605
+ normalized = session_id.strip()
606
+ return normalized or None
607
+
608
+
609
+ def _validate_identifier(value: str, name: str, limit: int) -> str:
610
+ """Validate a namespace component without rewriting caller data."""
611
+ if not isinstance(value, str) or not value.strip():
612
+ raise ValueError(f"{name} must be a non-empty string")
613
+ if len(value) > limit:
614
+ raise ValueError(f"{name} exceeds the {limit}-character limit")
615
+ if any(ord(char) < MIN_PRINTABLE_CODEPOINT for char in value):
616
+ raise ValueError(f"{name} contains a control character")
617
+ return value
618
+
619
+
620
+ def _context_namespace(
621
+ slug: str,
622
+ session_id: str | None,
623
+ *,
624
+ legacy: bool = False,
625
+ ) -> str | None:
626
+ """Build a context namespace with explicit legacy opt-in only."""
627
+ try:
628
+ valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
629
+ except ValueError:
630
+ return None
631
+ if session_id is None:
632
+ if legacy:
633
+ return f"checkpoint:{valid_slug}"
634
+ return None
635
+ normalized = _normalize_session_id(session_id)
636
+ if normalized is None:
637
+ return None
638
+ try:
639
+ valid_session = _validate_identifier(
640
+ normalized, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
641
+ )
642
+ except ValueError:
643
+ return None
644
+ return f"checkpoint:{valid_slug}:{valid_session}"
645
+
646
+
647
+ def _validate_ttl(ttl: object) -> int | None:
648
+ """Validate an optional TTL before it reaches ``datetime.timedelta``."""
649
+ if ttl is None:
650
+ return None
651
+ if isinstance(ttl, bool) or not isinstance(ttl, int):
652
+ raise ValueError("ttl must be an integer or null")
653
+ if ttl < MIN_TTL or ttl > MAX_TTL:
654
+ raise ValueError(f"ttl must be between {MIN_TTL} and {MAX_TTL} seconds")
655
+ return ttl
656
+
657
+
658
+ def _context_ttl(key: str, ttl: int | None) -> int:
659
+ """Return a validated TTL or the explicit policy for a checkpoint key."""
660
+ validated_ttl = _validate_ttl(ttl)
661
+ return (
662
+ validated_ttl
663
+ if validated_ttl is not None
664
+ else CONTEXT_TTL_BY_KEY.get(key, DEFAULT_CONTEXT_TTL)
665
+ )
666
+
667
+
668
+ def _validate_revision(revision: int | None) -> int | None:
669
+ """Validate an optional caller revision used for stale-write rejection."""
670
+ if revision is None:
671
+ return None
672
+ if isinstance(revision, bool) or not isinstance(revision, int):
673
+ raise ValueError("revision must be an integer")
674
+ if revision < 1 or revision > MAX_REVISION:
675
+ raise ValueError(f"revision must be between 1 and {MAX_REVISION}")
676
+ return revision
677
+
678
+
679
+ def _current_context_revision(
680
+ conn: sqlite3.Connection, namespace: str, key: str
681
+ ) -> int:
682
+ """Read the persisted revision for one context entry."""
683
+ row = conn.execute(
684
+ "SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
685
+ (namespace, key),
686
+ ).fetchone()
687
+ return _stored_revision(row[0]) if row else 0
688
+
689
+
690
+ def _should_update_latest(key: str) -> bool:
691
+ """Keep operational metadata from clobbering the recovery checkpoint."""
692
+ return key not in {"heartbeat", "tail"}
693
+
694
+
695
+ def _validate_bounded_text(value: object, field: str, limit: int) -> None:
696
+ """Validate a string field without changing its contents."""
697
+ if not isinstance(value, str) or len(value.encode()) > limit:
698
+ raise ValueError(f"{field} exceeds its size limit")
699
+
700
+
701
+ def _validate_goal(goal: object) -> None:
702
+ """Validate the bounded goal section of a checkpoint."""
703
+ if not isinstance(goal, dict):
704
+ raise ValueError("goal must be an object")
705
+ for field, limit in (
706
+ ("id", MAX_GOAL_ID_LENGTH),
707
+ ("objective", MAX_GOAL_OBJECTIVE_LENGTH),
708
+ ("status", MAX_GOAL_STATUS_LENGTH),
709
+ ):
710
+ value = goal.get(field)
711
+ if value is not None:
712
+ _validate_bounded_text(value, f"goal.{field}", limit)
713
+
714
+
715
+ def _validate_phase(phase: object) -> None:
716
+ """Validate the bounded phase section of a checkpoint."""
717
+ if not isinstance(phase, dict):
718
+ raise ValueError("phase must be an object")
719
+ for field in ("current", "total"):
720
+ value = phase.get(field)
721
+ if value is not None and (
722
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
723
+ ):
724
+ raise ValueError(f"phase.{field} must be a non-negative integer")
725
+ name = phase.get("name")
726
+ if name is not None:
727
+ _validate_bounded_text(name, "phase.name", MAX_PHASE_NAME_LENGTH)
728
+
729
+
730
+ def _validate_delegations(delegations: object) -> None:
731
+ """Validate bounded in-flight delegation records."""
732
+ if not isinstance(delegations, dict):
733
+ raise ValueError("delegations must be an object")
734
+ in_flight = delegations.get("in_flight")
735
+ if in_flight is None:
736
+ return
737
+ if not isinstance(in_flight, list) or len(in_flight) > MAX_DELEGATIONS:
738
+ raise ValueError("delegations.in_flight exceeds its size limit")
739
+ for job in in_flight:
740
+ if not isinstance(job, dict):
741
+ raise ValueError("delegations.in_flight entries must be objects")
742
+ for field in ("alias", "agent", "task_id"):
743
+ value = job.get(field)
744
+ if value is not None:
745
+ _validate_bounded_text(
746
+ value, f"delegations.in_flight.{field}", MAX_DELEGATION_FIELD_LENGTH
747
+ )
748
+
749
+
750
+ def _validate_tail(tail: object, payload_bytes: int | None = None) -> list[str]:
751
+ """Validate bounded serialized tail lines."""
752
+ if not isinstance(tail, list) or len(tail) > MAX_TAIL_ITEMS:
753
+ raise ValueError("tail exceeds its size limit")
754
+ for line in tail:
755
+ if not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH:
756
+ raise ValueError("tail entries exceed their size limit")
757
+ if payload_bytes is not None and payload_bytes > MAX_CONTEXT_CONTENT_BYTES:
758
+ raise ValueError("tail payload exceeds its size limit")
759
+ if sum(len(line.encode()) for line in tail) > MAX_CONTEXT_CONTENT_BYTES:
760
+ raise ValueError("tail payload exceeds its size limit")
761
+ return tail
762
+
763
+
764
+ def _validate_heartbeat(heartbeat: object) -> None:
765
+ """Validate bounded heartbeat metadata."""
766
+ if not isinstance(heartbeat, dict):
767
+ raise ValueError("heartbeat must be an object")
768
+ status = heartbeat.get("status")
769
+ if status is not None:
770
+ _validate_bounded_text(status, "heartbeat.status", MAX_GOAL_STATUS_LENGTH)
771
+ turn_count = heartbeat.get("turn_count")
772
+ if turn_count is not None and (
773
+ isinstance(turn_count, bool)
774
+ or not isinstance(turn_count, int)
775
+ or turn_count < 0
776
+ ):
777
+ raise ValueError("heartbeat.turn_count must be a non-negative integer")
778
+
779
+
780
+ def _validate_checkpoint_shape(checkpoint: dict) -> None:
781
+ """Validate bounded structured checkpoint fields without sanitizing them."""
782
+ version = checkpoint.get("version")
783
+ if version is not None and (
784
+ isinstance(version, bool) or not isinstance(version, int) or version < 1
785
+ ):
786
+ raise ValueError("version must be a positive integer")
787
+ validators = (
788
+ ("goal", _validate_goal),
789
+ ("phase", _validate_phase),
790
+ ("delegations", _validate_delegations),
791
+ ("tail", _validate_tail),
792
+ ("heartbeat", _validate_heartbeat),
793
+ )
794
+ for field, validator in validators:
795
+ value = checkpoint.get(field)
796
+ if value is not None:
797
+ validator(value)
798
+
799
+
800
+ def _validate_context_content(content: str, key: str) -> None:
801
+ """Bound context content and validate structured JSON when supplied."""
802
+ if not isinstance(content, str):
803
+ raise ValueError("content must be a string")
804
+ if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
805
+ raise ValueError(f"content exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
806
+ try:
807
+ parsed = json.loads(content)
808
+ except (json.JSONDecodeError, TypeError, ValueError):
809
+ # Raw text remains a compatibility format, but rehydrate never treats
810
+ # it as a checkpoint. It is still bounded above.
811
+ return
812
+ if isinstance(parsed, list) and key == "tail":
813
+ if len(parsed) > MAX_TAIL_ITEMS or any(
814
+ not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
815
+ for line in parsed
816
+ ):
817
+ raise ValueError("tail exceeds its size limit")
818
+ return
819
+ if not isinstance(parsed, dict):
820
+ raise ValueError("structured context content must be a JSON object")
821
+ _validate_checkpoint_shape(parsed)
822
+
823
+
824
+ def _safe_untrusted_text(value: object) -> str:
825
+ """Escape control/markup delimiters while preserving untrusted content."""
826
+ text = str(value)
827
+ return (
828
+ text.replace("<", r"\u003c")
829
+ .replace(">", r"\u003e")
830
+ .replace("\r", r"\r")
831
+ .replace("\n", r"\n")
832
+ .replace("\x00", r"\u0000")
833
+ )
834
+
835
+
836
+ def _stored_revision(raw: object) -> int:
837
+ """Parse a context revision; legacy datetime values are revision zero."""
838
+ try:
839
+ revision = int(str(raw))
840
+ except (TypeError, ValueError):
841
+ return 0
842
+ return revision if 0 <= revision <= MAX_REVISION else 0
843
+
844
+
845
+ def _next_context_revision(
846
+ conn: sqlite3.Connection, namespace: str, key: str, latest_key: str | None = None
847
+ ) -> int:
848
+ """Choose a persisted monotonic revision while the write lock is held."""
849
+ if latest_key is None:
850
+ rows = conn.execute(
851
+ "SELECT updated_at FROM kv_store WHERE namespace = ? AND key = ?",
852
+ (namespace, key),
853
+ ).fetchall()
854
+ else:
855
+ rows = conn.execute(
856
+ "SELECT updated_at FROM kv_store WHERE namespace = ? AND key IN (?, ?)",
857
+ (namespace, key, latest_key),
858
+ ).fetchall()
859
+ current = max((_stored_revision(row[0]) for row in rows), default=0)
860
+ return min(MAX_REVISION, max(time.time_ns(), current + 1))
861
+
862
+
863
+ def _upsert_context_entry(
864
+ conn: sqlite3.Connection,
865
+ namespace: str,
866
+ key: str,
867
+ content: str,
868
+ expires_at: str,
869
+ revision: int,
870
+ ) -> None:
871
+ """Upsert one context row inside the caller's transaction."""
872
+ conn.execute(
873
+ "INSERT INTO kv_store (namespace, key, value, expires_at, "
874
+ "created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), ?)"
875
+ "ON CONFLICT(namespace, key) DO UPDATE SET "
876
+ " value = excluded.value, "
877
+ " expires_at = excluded.expires_at, "
878
+ " updated_at = excluded.updated_at",
879
+ (namespace, key, content, expires_at, str(revision)),
880
+ )
572
881
 
573
882
 
574
883
  @mcp.tool(
575
884
  name="context_save",
576
885
  description="Save a context checkpoint for a session/phase. "
577
- "Stores structured JSON in persistence KV with auto-TTL of 4h.",
886
+ "Stores structured JSON in persistence KV with a key-specific TTL.",
578
887
  )
579
888
  async def context_save(
580
889
  slug: str,
581
890
  key: str,
582
891
  content: str,
583
- session_id: str | None = None,
892
+ session_id: str,
584
893
  ttl: int | None = None,
585
894
  scope: str = "project",
895
+ revision: int | None = None,
586
896
  ) -> dict:
587
897
  """Save a context checkpoint with session-level isolation.
588
898
 
589
- If session_id is provided, namespace becomes "checkpoint:{slug}:{session_id}"
590
- preventing cross-session reads. If omitted, a random UUID is auto-generated
591
- and returned so the caller can pass it to subsequent calls.
899
+ Namespace is always "checkpoint:{slug}:{session_id}", preventing
900
+ cross-session reads. The session_id must be supplied by the autonomy
901
+ caller; this function never generates one.
592
902
 
593
903
  Args:
594
904
  slug: Session identifier (e.g. "auth-refactor").
595
905
  key: Checkpoint key (e.g. "phase:3", "latest", "heartbeat").
596
906
  content: JSON-serializable string or any text content.
597
- session_id: Unique session ID for namespace isolation (auto-generated if None).
598
- ttl: TTL in seconds (default 4h / 14400). None = session duration.
907
+ session_id: Required unique session ID for namespace isolation.
908
+ ttl: TTL in seconds (defaults to the key-specific context policy).
599
909
  scope: 'project' (default) or 'global'.
910
+ revision: Optional monotonic revision. Older/equal revisions are no-ops.
600
911
 
601
912
  Returns:
602
913
  Status dict with namespace, key, and session_id for subsequent calls.
603
914
  """
604
- actual_session = session_id or uuid.uuid4().hex[:12]
605
- ns = f"checkpoint:{slug}:{actual_session}"
606
- actual_ttl = ttl if ttl is not None else DEFAULT_CONTEXT_TTL
915
+ valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
916
+ valid_key = _validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
917
+ actual_session = _normalize_session_id(session_id)
918
+ if actual_session is None:
919
+ raise ValueError("session_id is required and must be non-empty")
920
+ valid_session = _validate_identifier(
921
+ actual_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
922
+ )
923
+ _validate_context_content(content, valid_key)
924
+ requested_revision = _validate_revision(revision)
925
+ ns = f"checkpoint:{valid_slug}:{valid_session}"
926
+ actual_ttl = _context_ttl(valid_key, ttl)
607
927
 
608
928
  conn = _db(scope)
609
929
  expires_at = (datetime.now(UTC) + timedelta(seconds=actual_ttl)).isoformat()
610
930
 
611
- conn.execute(
612
- "INSERT INTO kv_store (namespace, key, value, expires_at, "
613
- "created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
614
- "ON CONFLICT(namespace, key) DO UPDATE SET "
615
- " value = excluded.value, "
616
- " expires_at = excluded.expires_at, "
617
- " updated_at = datetime('now')",
618
- (ns, key, content, expires_at),
619
- )
620
- conn.commit()
621
-
622
- # Also always update "latest" pointer
623
- if key != "latest":
624
- # Use a larger TTL for "latest" to outlive individual phase entries
625
- conn.execute(
626
- "INSERT INTO kv_store (namespace, key, value, expires_at, "
627
- "created_at, updated_at) VALUES (?, 'latest', ?, ?, datetime('now'), datetime('now'))"
628
- "ON CONFLICT(namespace, key) DO UPDATE SET "
629
- " value = excluded.value, "
630
- " expires_at = excluded.expires_at, "
631
- " updated_at = datetime('now')",
632
- (ns, content, expires_at),
633
- )
634
- conn.commit()
931
+ # BEGIN IMMEDIATE serializes writers in this process and across SQLite
932
+ # connections. The checkpoint and raw-content latest pointer commit as one
933
+ # unit; a crash or failed second write therefore cannot expose half a save.
934
+ with _context_write_lock:
935
+ try:
936
+ conn.execute("BEGIN IMMEDIATE")
937
+ latest_key = "latest" if _should_update_latest(valid_key) else None
938
+ actual_revision = requested_revision or _next_context_revision(
939
+ conn, ns, valid_key, latest_key
940
+ )
941
+ current_revision = _current_context_revision(conn, ns, valid_key)
942
+ if actual_revision <= current_revision:
943
+ conn.rollback()
944
+ return {
945
+ "status": "stale",
946
+ "namespace": ns,
947
+ "key": valid_key,
948
+ "session_id": valid_session,
949
+ "ttl": actual_ttl,
950
+ "revision": current_revision,
951
+ }
952
+ _upsert_context_entry(
953
+ conn, ns, valid_key, content, expires_at, actual_revision
954
+ )
955
+ if latest_key is not None and actual_revision > _current_context_revision(
956
+ conn, ns, latest_key
957
+ ):
958
+ _upsert_context_entry(
959
+ conn, ns, latest_key, content, expires_at, actual_revision
960
+ )
961
+ conn.commit()
962
+ except Exception:
963
+ conn.rollback()
964
+ raise
635
965
 
636
966
  return {
637
967
  "status": "stored",
638
968
  "namespace": ns,
639
- "key": key,
640
- "session_id": actual_session,
969
+ "key": valid_key,
970
+ "session_id": valid_session,
641
971
  "ttl": actual_ttl,
972
+ "revision": actual_revision,
642
973
  }
643
974
 
644
975
 
@@ -651,6 +982,7 @@ async def context_get(
651
982
  slug: str,
652
983
  key: str = "latest",
653
984
  session_id: str | None = None,
985
+ legacy: bool = False,
654
986
  scope: str = "project",
655
987
  ) -> str | None:
656
988
  """Get a context checkpoint with session isolation.
@@ -658,14 +990,22 @@ async def context_get(
658
990
  Args:
659
991
  slug: Session identifier.
660
992
  key: Checkpoint key (default "latest").
661
- session_id: Required for isolation. If None, searches without session scope.
993
+ session_id: Required for session-scoped reads.
994
+ legacy: Explicitly opt into the unqualified legacy namespace when no
995
+ session_id is available.
662
996
  scope: 'project' (default) or 'global'.
663
997
 
664
998
  Returns:
665
999
  Content string or None if not found/expired.
666
1000
  """
667
1001
  conn = _db(scope)
668
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1002
+ try:
1003
+ _validate_identifier(key, "key", MAX_CONTEXT_KEY_LENGTH)
1004
+ except ValueError:
1005
+ return None
1006
+ ns = _context_namespace(slug, session_id, legacy=legacy)
1007
+ if ns is None:
1008
+ return None
669
1009
  row = conn.execute(
670
1010
  "SELECT value FROM kv_store "
671
1011
  "WHERE namespace = ? AND key = ? "
@@ -684,20 +1024,25 @@ async def context_get(
684
1024
  async def context_list(
685
1025
  slug: str,
686
1026
  session_id: str | None = None,
1027
+ legacy: bool = False,
687
1028
  scope: str = "project",
688
1029
  ) -> list[dict]:
689
1030
  """List all checkpoints for a session.
690
1031
 
691
1032
  Args:
692
1033
  slug: Session identifier.
693
- session_id: Optional. If provided, narrows to specific session.
1034
+ session_id: Required for session-scoped reads.
1035
+ legacy: Explicitly opt into the unqualified legacy namespace when no
1036
+ session_id is available.
694
1037
  scope: 'project' (default) or 'global'.
695
1038
 
696
1039
  Returns:
697
1040
  List of dicts with key, created_at, expires_at.
698
1041
  """
699
1042
  conn = _db(scope)
700
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1043
+ ns = _context_namespace(slug, session_id, legacy=legacy)
1044
+ if ns is None:
1045
+ return []
701
1046
  rows = conn.execute(
702
1047
  "SELECT key, created_at, expires_at FROM kv_store "
703
1048
  "WHERE namespace = ? "
@@ -724,36 +1069,52 @@ async def context_list(
724
1069
  async def context_stats(
725
1070
  slug: str,
726
1071
  session_id: str | None = None,
1072
+ legacy: bool = False,
727
1073
  scope: str = "project",
728
1074
  ) -> dict:
729
1075
  """Return storage statistics for a session's context.
730
1076
 
731
1077
  Args:
732
1078
  slug: Session identifier.
733
- session_id: Optional. If provided, narrows to specific session.
1079
+ session_id: Required for session-scoped reads.
1080
+ legacy: Explicitly opt into the unqualified legacy namespace when no
1081
+ session_id is available.
734
1082
  scope: 'project' (default) or 'global'.
735
1083
 
736
1084
  Returns:
737
1085
  Dict with entry_count, total_bytes, ttl_remaining.
738
1086
  """
739
1087
  conn = _db(scope)
740
- ns = f"checkpoint:{slug}:{session_id}" if session_id else f"checkpoint:{slug}"
1088
+ ns = _context_namespace(slug, session_id, legacy=legacy)
1089
+ if ns is None:
1090
+ return {
1091
+ "slug": slug,
1092
+ "namespace": None,
1093
+ "entry_count": 0,
1094
+ "expired_entries": 0,
1095
+ "total_bytes": 0,
1096
+ "ttl_remaining_seconds": None,
1097
+ }
741
1098
 
742
1099
  count = conn.execute(
743
- "SELECT COUNT(*) FROM kv_store WHERE namespace = ? AND deleted_at IS NULL",
1100
+ "SELECT COUNT(*) FROM kv_store "
1101
+ "WHERE namespace = ? AND deleted_at IS NULL "
1102
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
744
1103
  (ns,),
745
1104
  ).fetchone()[0]
746
1105
 
747
1106
  expired = conn.execute(
748
1107
  "SELECT COUNT(*) FROM kv_store "
749
- "WHERE namespace = ? AND datetime(expires_at) < datetime('now') "
1108
+ "WHERE namespace = ? AND expires_at IS NOT NULL "
1109
+ "AND datetime(expires_at) <= datetime('now') "
750
1110
  "AND deleted_at IS NULL",
751
1111
  (ns,),
752
1112
  ).fetchone()[0]
753
1113
 
754
1114
  size = conn.execute(
755
1115
  "SELECT COALESCE(SUM(LENGTH(value)), 0) FROM kv_store "
756
- "WHERE namespace = ? AND deleted_at IS NULL",
1116
+ "WHERE namespace = ? AND deleted_at IS NULL "
1117
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))",
757
1118
  (ns,),
758
1119
  ).fetchone()[0]
759
1120
 
@@ -784,7 +1145,8 @@ async def context_stats(
784
1145
 
785
1146
  # ── Post-Compaction Injector (WS2 PR #94) ─────────────────────────────────────
786
1147
  # Deterministic rehydration after a native compaction event: a long session
787
- # reads ``latest`` + serialized ``tail`` from ``checkpoint:<slug>`` and
1148
+ # reads ``latest`` + serialized ``tail`` from
1149
+ # ``checkpoint:<slug>:<session_id>`` and
788
1150
  # rebuilds its critical context (active goal, in-flight delegations,
789
1151
  # current phase) WITHOUT any LLM call or generative embedding.
790
1152
  #
@@ -797,9 +1159,9 @@ async def context_stats(
797
1159
  # "heartbeat": {...}}
798
1160
  # ``tail`` may also live under a separate ``tail`` key (JSON array).
799
1161
  #
800
- # Guarantees: kill-switch PANTHEON_COMPACTION=off, TTL-respecting reads,
801
- # heartbeat TTL refresh (extended, never shortened), idempotent (pure reads
802
- # + TTL touch — no checkpoint rows are added or duplicated).
1162
+ # Guarantees: required session_id, kill-switch PANTHEON_COMPACTION=off,
1163
+ # TTL-respecting reads, no sibling fallback, conditional heartbeat TTL refresh,
1164
+ # and idempotent recovery (no checkpoint rows are added or duplicated).
803
1165
 
804
1166
  COMPACTION_KILL_SWITCH_ENV: str = "PANTHEON_COMPACTION"
805
1167
  SESSION_END_SUMMARY_ENV: str = "PANTHEON_SESSION_END_SUMMARY"
@@ -826,26 +1188,39 @@ def _read_checkpoint_value(
826
1188
  return row[0] if row else None
827
1189
 
828
1190
 
829
- def _parse_checkpoint(content: str) -> dict:
830
- """Parse checkpoint JSON; unparsable content degrades to {} (fail-open)."""
1191
+ def _parse_checkpoint(content: str) -> dict | None:
1192
+ """Parse and validate one checkpoint; invalid JSON fails closed."""
1193
+ if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
1194
+ return None
831
1195
  try:
832
1196
  data = json.loads(content)
833
1197
  except (json.JSONDecodeError, TypeError, ValueError):
834
- return {}
835
- return data if isinstance(data, dict) else {}
1198
+ return None
1199
+ if not isinstance(data, dict):
1200
+ return None
1201
+ try:
1202
+ _validate_checkpoint_shape(data)
1203
+ except ValueError:
1204
+ return None
1205
+ return data
836
1206
 
837
1207
 
838
1208
  def _checkpoint_tail(checkpoint: dict, tail_raw: str | None) -> list[str]:
839
1209
  """Tail lines from embedded ``tail`` with fallback to the ``tail`` key."""
840
1210
  tail = checkpoint.get("tail")
841
1211
  if isinstance(tail, list):
842
- lines = [str(line) for line in tail]
1212
+ try:
1213
+ lines = _validate_tail(tail)
1214
+ except ValueError:
1215
+ return []
843
1216
  elif isinstance(tail_raw, str):
1217
+ if len(tail_raw.encode()) > MAX_CONTEXT_CONTENT_BYTES:
1218
+ return []
844
1219
  try:
845
1220
  parsed = json.loads(tail_raw)
1221
+ lines = _validate_tail(parsed, payload_bytes=len(tail_raw.encode()))
846
1222
  except (json.JSONDecodeError, TypeError, ValueError):
847
- parsed = None
848
- lines = [str(line) for line in parsed] if isinstance(parsed, list) else []
1223
+ return []
849
1224
  else:
850
1225
  lines = []
851
1226
  return [line for line in lines if line.strip()][:REHYDRATE_TAIL_CAP]
@@ -861,7 +1236,11 @@ def _mission_block(checkpoint: dict) -> str | None:
861
1236
  if status == "done" or not objective:
862
1237
  return None
863
1238
  gid = str(goal.get("id", "goal")).strip() or "goal"
864
- return f"<mission_context>\n [{gid}] {objective} — {status}"
1239
+ return (
1240
+ f"<mission_context>\n{UNTRUSTED_CONTEXT_LABEL}\n"
1241
+ f" [{_safe_untrusted_text(gid)}] {_safe_untrusted_text(objective)}"
1242
+ f" — {_safe_untrusted_text(status)}"
1243
+ )
865
1244
 
866
1245
 
867
1246
  def _phase_block(checkpoint: dict) -> str | None:
@@ -873,8 +1252,10 @@ def _phase_block(checkpoint: dict) -> str | None:
873
1252
  current = phase.get("current")
874
1253
  span = f"{current}/{total}" if total is not None else str(current)
875
1254
  name = str(phase.get("name", "")).strip()
876
- line = f" phase {span}" + (f" — {name}" if name else "")
877
- return f"<phase_context>\n{line}"
1255
+ line = f" phase {_safe_untrusted_text(span)}"
1256
+ if name:
1257
+ line += f" — {_safe_untrusted_text(name)}"
1258
+ return f"<phase_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{line}"
878
1259
 
879
1260
 
880
1261
  def _delegation_block(checkpoint: dict) -> str | None:
@@ -892,20 +1273,31 @@ def _delegation_block(checkpoint: dict) -> str | None:
892
1273
  alias = str(job.get("alias") or job.get("task_id") or "?").strip()
893
1274
  agent = str(job.get("agent", "?")).strip()
894
1275
  task_id = str(job.get("task_id", "")).strip()
895
- detail = f" — {task_id}" if task_id and task_id != alias else ""
896
- lines.append(f" [{alias}] {agent}{detail} [in-flight]")
1276
+ detail = (
1277
+ f" — {_safe_untrusted_text(task_id)}"
1278
+ if task_id and task_id != alias
1279
+ else ""
1280
+ )
1281
+ lines.append(
1282
+ f" [{_safe_untrusted_text(alias)}] {_safe_untrusted_text(agent)}"
1283
+ f"{detail} [in-flight]"
1284
+ )
897
1285
  if not lines:
898
1286
  return None
899
- return "<delegation_context>\n" + "\n".join(lines)
1287
+ return "<delegation_context>\n" + UNTRUSTED_CONTEXT_LABEL + "\n" + "\n".join(lines)
900
1288
 
901
1289
 
902
1290
  def _tail_block(checkpoint: dict) -> str | None:
903
1291
  """<tail_context> for the serialized tail (already capped on load)."""
904
1292
  tail_lines = checkpoint.get("_tail_raw")
905
- if not isinstance(tail_lines, list) or not tail_lines:
1293
+ try:
1294
+ validated_lines = _validate_tail(tail_lines)
1295
+ except ValueError:
1296
+ return None
1297
+ if not validated_lines:
906
1298
  return None
907
- quoted = "\n".join(f" - {line}" for line in tail_lines)
908
- return f"<tail_context>\n{quoted}"
1299
+ quoted = "\n".join(f" - {_safe_untrusted_text(line)}" for line in validated_lines)
1300
+ return f"<tail_context>\n{UNTRUSTED_CONTEXT_LABEL}\n{quoted}"
909
1301
 
910
1302
 
911
1303
  def build_rehydration_blocks(checkpoint: dict) -> list[str] | None:
@@ -944,7 +1336,10 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
944
1336
  if isinstance(goal, dict):
945
1337
  objective = str(goal.get("objective", "")).strip()
946
1338
  if objective:
947
- parts.append(f"Goal: {objective} ({goal.get('status', '?')})")
1339
+ parts.append(
1340
+ f"Goal: {_safe_untrusted_text(objective)} "
1341
+ f"({_safe_untrusted_text(goal.get('status', '?'))})"
1342
+ )
948
1343
 
949
1344
  phase = checkpoint.get("phase")
950
1345
  if isinstance(phase, dict) and phase.get("current") is not None:
@@ -955,14 +1350,17 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
955
1350
  else phase.get("current")
956
1351
  )
957
1352
  name = str(phase.get("name", "")).strip()
958
- parts.append(f"Phase: {span}" + (f" {name}" if name else ""))
1353
+ parts.append(
1354
+ f"Phase: {_safe_untrusted_text(span)}"
1355
+ + (f" {_safe_untrusted_text(name)}" if name else "")
1356
+ )
959
1357
 
960
1358
  delegations = checkpoint.get("delegations")
961
1359
  if isinstance(delegations, dict):
962
1360
  in_flight = delegations.get("in_flight")
963
1361
  if isinstance(in_flight, list):
964
1362
  aliases = [
965
- str(job.get("alias") or job.get("task_id") or "?")
1363
+ _safe_untrusted_text(str(job.get("alias") or job.get("task_id") or "?"))
966
1364
  for job in in_flight
967
1365
  if isinstance(job, dict)
968
1366
  ]
@@ -971,7 +1369,7 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
971
1369
 
972
1370
  tail_raw = checkpoint.get("_tail_raw")
973
1371
  if isinstance(tail_raw, list) and tail_raw:
974
- kept = [str(line) for line in tail_raw[:SUMMARY_TAIL_CAP]]
1372
+ kept = [_safe_untrusted_text(str(line)) for line in tail_raw[:SUMMARY_TAIL_CAP]]
975
1373
  parts.append("Tail: " + " | ".join(kept))
976
1374
 
977
1375
  if not parts:
@@ -984,68 +1382,16 @@ def build_session_end_summary(checkpoint: dict) -> str | None:
984
1382
  def _load_checkpoint_for_rehydrate(
985
1383
  conn: sqlite3.Connection, namespace: str
986
1384
  ) -> dict | None:
987
- """Load ``latest`` (+ ``tail`` fallback) or None when expired/absent.
988
-
989
- Resilient to pointer clobbering: ``context_save`` refreshes ``latest``
990
- on every write, so a later heartbeat can overwrite the checkpoint
991
- pointer. When ``latest`` is not a checkpoint, scan sibling keys
992
- newest-first and use the first value that rebuilds critical blocks.
993
- Deterministic (created_at DESC, bounded scan).
994
- """
1385
+ """Load exactly ``latest``; expired, malformed, or empty data fails closed."""
995
1386
  tail_raw = _read_checkpoint_value(conn, namespace, "tail")
996
-
997
- def _with_tail(raw: str | None) -> dict | None:
998
- if raw is None:
999
- return None
1000
- checkpoint = _parse_checkpoint(raw)
1001
- if not checkpoint:
1002
- return None
1003
- checkpoint["_tail_raw"] = _checkpoint_tail(checkpoint, tail_raw)
1004
- if build_rehydration_blocks(checkpoint) is None:
1005
- return None
1006
- return checkpoint
1007
-
1008
- found = _with_tail(_read_checkpoint_value(conn, namespace, "latest"))
1009
- if found is not None:
1010
- return found
1011
- rows = conn.execute(
1012
- "SELECT value FROM kv_store "
1013
- "WHERE namespace = ? AND key != 'latest' "
1014
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1015
- "AND deleted_at IS NULL "
1016
- "ORDER BY created_at DESC, key DESC LIMIT 20",
1017
- (namespace,),
1018
- ).fetchall()
1019
- for row in rows:
1020
- found = _with_tail(row[0] if row else None)
1021
- if found is not None:
1022
- return found
1023
- return None
1024
-
1025
-
1026
- def _checkpoint_namespaces(
1027
- conn: sqlite3.Connection, slug: str, session_id: str | None
1028
- ) -> list[str]:
1029
- """Resolve checkpoint namespaces while preserving session isolation.
1030
-
1031
- ``context_save`` always creates a session-qualified namespace, including
1032
- when the caller omits ``session_id`` (it generates one and returns it).
1033
- Runtime callers may not retain that return value after compaction, so an
1034
- unqualified rehydrate/summary must discover the newest matching session.
1035
- The prefix comparison is parameterized and avoids LIKE wildcard leakage.
1036
- """
1037
- if session_id:
1038
- return [f"checkpoint:{slug}:{session_id}"]
1039
- prefix = f"checkpoint:{slug}:"
1040
- rows = conn.execute(
1041
- "SELECT namespace FROM kv_store "
1042
- "WHERE substr(namespace, 1, ?) = ? "
1043
- "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1044
- "AND deleted_at IS NULL "
1045
- "GROUP BY namespace ORDER BY MAX(updated_at) DESC LIMIT 20",
1046
- (len(prefix), prefix),
1047
- ).fetchall()
1048
- return [str(row[0]) for row in rows if row and row[0]]
1387
+ raw = _read_checkpoint_value(conn, namespace, "latest")
1388
+ if raw is None:
1389
+ return None
1390
+ checkpoint = _parse_checkpoint(raw)
1391
+ if checkpoint is None:
1392
+ return None
1393
+ checkpoint["_tail_raw"] = _checkpoint_tail(checkpoint, tail_raw)
1394
+ return checkpoint if build_rehydration_blocks(checkpoint) is not None else None
1049
1395
 
1050
1396
 
1051
1397
  def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
@@ -1066,15 +1412,19 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1066
1412
  now = datetime.now(UTC)
1067
1413
  if current_exp.tzinfo is None:
1068
1414
  current_exp = current_exp.replace(tzinfo=UTC)
1069
- fresh_exp = now + timedelta(seconds=DEFAULT_CONTEXT_TTL)
1415
+ fresh_exp = now + timedelta(seconds=CONTEXT_HEARTBEAT_TTL)
1070
1416
  if fresh_exp <= current_exp:
1071
1417
  return
1072
- conn.execute(
1073
- "UPDATE kv_store SET expires_at = ?, updated_at = datetime('now') "
1074
- "WHERE namespace = ? AND key = 'heartbeat'",
1075
- (fresh_exp.isoformat(), namespace),
1418
+ revision = _next_context_revision(conn, namespace, "heartbeat")
1419
+ cursor = conn.execute(
1420
+ "UPDATE kv_store SET expires_at = ?, updated_at = ? "
1421
+ "WHERE namespace = ? AND key = 'heartbeat' AND expires_at = ? "
1422
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
1423
+ "AND deleted_at IS NULL",
1424
+ (fresh_exp.isoformat(), str(revision), namespace, row[0]),
1076
1425
  )
1077
- conn.commit()
1426
+ if cursor.rowcount:
1427
+ conn.commit()
1078
1428
 
1079
1429
 
1080
1430
  @mcp.tool(
@@ -1083,32 +1433,32 @@ def _refresh_heartbeat_ttl(conn: sqlite3.Connection, namespace: str) -> None:
1083
1433
  "Reads 'latest' + serialized 'tail' from a session-qualified checkpoint "
1084
1434
  "namespace and rebuilds "
1085
1435
  "goal/phase/delegation blocks deterministically (no LLM). Refreshes the "
1086
- "heartbeat TTL. Idempotent. Honors PANTHEON_COMPACTION=off.",
1436
+ "heartbeat TTL. Requires session_id and honors PANTHEON_COMPACTION=off.",
1087
1437
  )
1088
1438
  async def context_rehydrate(
1089
1439
  slug: str,
1090
- session_id: str | None = None,
1440
+ session_id: str,
1091
1441
  scope: str = "project",
1092
1442
  ) -> list[str] | None:
1093
- """Post-compaction injector: deterministic rehydration, fail-open."""
1443
+ """Rehydrate one known session; missing IDs fail closed."""
1094
1444
  if _compaction_disabled():
1095
1445
  return None
1446
+ normalized_session = _normalize_session_id(session_id)
1447
+ if normalized_session is None:
1448
+ raise ValueError("session_id is required and must be non-empty")
1449
+ valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1450
+ valid_session = _validate_identifier(
1451
+ normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1452
+ )
1096
1453
  conn = _db(scope)
1097
- namespaces = _checkpoint_namespaces(conn, slug, session_id)
1098
- checkpoint = None
1099
- selected_namespace = None
1100
- for namespace in namespaces:
1101
- checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1102
- if checkpoint is not None:
1103
- selected_namespace = namespace
1104
- break
1454
+ namespace = f"checkpoint:{valid_slug}:{valid_session}"
1455
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1105
1456
  if checkpoint is None:
1106
1457
  return None
1107
1458
  blocks = build_rehydration_blocks(checkpoint)
1108
1459
  if blocks is None:
1109
1460
  return None
1110
- if selected_namespace is not None:
1111
- _refresh_heartbeat_ttl(conn, selected_namespace)
1461
+ _refresh_heartbeat_ttl(conn, namespace)
1112
1462
  return blocks
1113
1463
 
1114
1464
 
@@ -1117,23 +1467,26 @@ async def context_rehydrate(
1117
1467
  description="Tier 2 session-end summary without Themis approval. "
1118
1468
  "Compresses the checkpoint (goal, phase, in-flight, tail) into a "
1119
1469
  "memory-bank-ready summary deterministically (no LLM). "
1120
- "Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
1470
+ "Requires session_id. Opt-out via PANTHEON_SESSION_END_SUMMARY=off.",
1121
1471
  )
1122
1472
  async def context_session_summary(
1123
1473
  slug: str,
1124
- session_id: str | None = None,
1474
+ session_id: str,
1125
1475
  scope: str = "project",
1126
1476
  ) -> str | None:
1127
- """Session-end Tier 2 trigger: automatic, opt-out instead of opt-in."""
1477
+ """Summarize one known session; missing IDs fail closed."""
1128
1478
  if os.environ.get(SESSION_END_SUMMARY_ENV, "").lower() == "off":
1129
1479
  return None
1480
+ normalized_session = _normalize_session_id(session_id)
1481
+ if normalized_session is None:
1482
+ raise ValueError("session_id is required and must be non-empty")
1483
+ valid_slug = _validate_identifier(slug, "slug", MAX_CONTEXT_SLUG_LENGTH)
1484
+ valid_session = _validate_identifier(
1485
+ normalized_session, "session_id", MAX_CONTEXT_SESSION_ID_LENGTH
1486
+ )
1130
1487
  conn = _db(scope)
1131
- namespaces = _checkpoint_namespaces(conn, slug, session_id)
1132
- checkpoint = None
1133
- for namespace in namespaces:
1134
- checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1135
- if checkpoint is not None:
1136
- break
1488
+ namespace = f"checkpoint:{valid_slug}:{valid_session}"
1489
+ checkpoint = _load_checkpoint_for_rehydrate(conn, namespace)
1137
1490
  if checkpoint is None:
1138
1491
  return None
1139
1492
  return build_session_end_summary(checkpoint)