diff --git a/CHANGELOG.md b/CHANGELOG.md index 4801b6f28..c787c3468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Team decisions retain their project scope (#611, ADR-1083). A separate + `is_team_decision` marker grants visibility across project agents without + setting `is_global`. SessionStart and agent briefings enforce project scope, + ingestion preserves the directory, and initialization no longer promotes + team decisions to global scope. Existing global rows have a dry-run-first + reclassification script. + ## [4.23.0] - 2026-09-17 ### Added diff --git a/docs/adr/ADR-1083-separate-project-team-decisions-from-global-knowledge.md b/docs/adr/ADR-1083-separate-project-team-decisions-from-global-knowledge.md new file mode 100644 index 000000000..46bdbbbce --- /dev/null +++ b/docs/adr/ADR-1083-separate-project-team-decisions-from-global-knowledge.md @@ -0,0 +1,69 @@ + + +--- +created: 2026-09-17 +kind: adr +number: 1083 +status: proposed +tags: [memory-scoping, team-decisions, issue-611] +title: Separate project team decisions from global knowledge +--- +# ADR-1083: Separate project team decisions from global knowledge + +## Status + +Proposed for owner review in issue #611. Supersedes the team-to-global +mapping in ADR-0200 and the team-decision exception in ADR-1080. + +## Evidence + +Issue #611 records memory 4353879 from japonais-2027 injected into +anthropic-partnership by both session hooks. `team_scope.is_team_decision` +and `global_detector.resolve_global_scope` promote deliberate decisions +under an agent context to `is_global`, which bypasses project isolation. +The intended visibility across agents therefore also crosses projects. + +## Decision + +A team decision is visible to every agent within its project. Persist +`is_team_decision` independently of `is_global` on both backends. Team +visibility relaxes only the agent predicate. Project visibility continues +to require the recorded project root or an ancestor, as in ADR-1080. + +Reserve `is_global` for an explicit global write or a positive result from +the existing content detector. A row may carry both flags when justified +independently. No detector threshold changes are part of this correction. +The Team Decisions query applies project scope before ordering and limit. +Ordinary hook recall treats team decisions as ordinary project memories. + +Reclassify legacy global rows with an idempotent, explicit operator script. +Run a dry-run first and verify a PostgreSQL custom-format backup before +applying to production. Legacy rows do not persist the reason for the +global flag. Operators can preserve IDs known to have been explicitly +global through `keep_global_ids`, and the content detector also preserves a +row. Otherwise `is_global` is cleared only for the rows ADR-0200 promoted: a +decision written under an agent context, which receives the team marker. Any +other global row is kept and reported, because an explicit act the script +cannot see made it global; the owner can clear one by ID through +`clear_global_ids`. Preserve row IDs, history and supersession. + +An empty directory context is never a project wildcard. Resolve it only +from a domain with an unambiguous, verified project-directory mapping, +or an explicit owner-approved memory-ID mapping recorded in the run report. +Leave unresolved rows global and enumerate their IDs for owner review. +Do not derive project ownership by guessing from prose or path suffixes. + +## Verification + +Both backends must exclude a project-A team decision from project B and +include it in project A for another agent. Explicit globals remain visible +in both. A second reclassification run changes no rows. Production signal: +4353879 is absent under anthropic-partnership and present under japonais-2027; +the former's Team Decisions block contains no japonais-2027 rows. + +## Consequences + +Schema migrations add a default-false team flag without rewriting legacy +scope automatically. The separate data operation is inspectable and +reversible from its backup. Unresolved legacy globals remain an explicit +owner-review list. Existing explicit global semantics are preserved. diff --git a/docs/validation/issue-611-team-scope.md b/docs/validation/issue-611-team-scope.md new file mode 100644 index 000000000..fba8bb870 --- /dev/null +++ b/docs/validation/issue-611-team-scope.md @@ -0,0 +1,160 @@ +# Issue 611: scope repair validation and operator procedure + +Base: `24c68c4a` (v4.23.0). Measured 2026-09-17. +Decision: ADR-1083, proposed for owner review. This change is not released. + +## Regression evidence + +The original `remember` regression failed because an agent decision returned +`is_global=True`. After separating the flags, the affected PostgreSQL suite +passes 182 tests. SQLite passes 175 tests, with seven PostgreSQL-only tests +skipped. The sandbox-only SQLite run reports PostgreSQL unavailable; the +PostgreSQL execution uses a separately created scratch database. The +reclassification script suite additionally passes 24 tests on PostgreSQL, +including transaction failure and archive validation paths. + +Independent review reproduced an ingestion privilege regression: an `auto` +write was persisted as `deliberate`, and initialization then granted team +visibility. Four of eight origin/class reopen cases failed before persisting +the class. All eight pass after the fix on both backends. SQLite also tests +an upgrade from the old schema. The PostgreSQL upgrade was exercised on a +full restore of the pre-change production archive, including the existing +`current_memories` view. + +## Production snapshot experiment + +The custom-format production archive was restored in full into an isolated +PostgreSQL database; `pg_restore` exited zero. Production was not reclassified. +The script also checks for memories table data and decodes the full archive +before allowing PostgreSQL apply. This validates readability, not that an +arbitrary supplied archive belongs to the selected database; the operator +must create and retain the target's own backup as below. + +| Measurement | Result | +| --- | --- | +| Current non-benchmark global candidates | 160 | +| Dry-run changes | 98 | +| Applied changes on the restored copy | 98 | +| Changes proposed on the second run | 0 | +| Retained globals | 62 | +| Retained globals with unresolved project | 47 | +| Total rows before and after repair | 47,490 | + +All fields other than `is_global`, `is_team_decision`, and `directory_context` +had identical ordered aggregate hashes before and after repair: +`f66cfb02e0c7996857ccfe74b311e667`. This includes content, IDs, and supersession. +The comparison ran before the acceptance hooks, which can record receipts. + +## Approved mapping and remaining owner decisions + +All 47 originally empty `directory_context` rows also had an empty `domain`. +On 2026-09-17 the owner explicitly approved assigning memory 4353879 to +`/Users/cdeust/Developments/japonais-2027`. The operator input is a mappings +file of the form `{"domains": {}, "memories": {"": ""}, +"keep_global_ids": []}`. It names rows of one private store, so it is kept +outside the repository. + +Applied to the already reclassified snapshot, this changes one additional +row to `is_global=false`, `is_team_decision=true`, with the approved directory. +The next pass changes zero rows. Across both passes, 99 of the original 160 +candidates lose global scope; 61 remain global, including 46 unresolved rows. +The actual auto-recall launcher now excludes the Score audit decision from +anthropic-partnership and includes it in japonais-2027. Both processes exit +zero. This verifies the specific 4353879 acceptance signal on the snapshot. + +Production remains unreclassified pending deployment of the reviewed code. +The parent Team Decisions query still returns unresolved row 4356519, whose +project requires separate owner review. The full SessionStart launcher +validation timed out after 60 seconds; direct query and subprocess fixture +tests do not replace final installed-plugin acceptance. + +Remaining unresolved IDs, left global without changing their project: + +4254394, 4342464, 4343116, 4347543, 4349020, 4349043, 4349560, 4349960, 4349976, 4350007, 4351552, 4351562, 4351771, 4352557, 4352618, 4353539, 4353601, 4353612, 4354421, 4354708, 4355514, 4355539, 4355775, 4356057, 4356262, 4356365, 4356519, 4356548, 4356730, 4357099, 4359407, 4359553, 4359753, 4359754, 4359755, 4359773, 4360000, 4361139, 4362817, 4365424, 4365650, 4367130, 4367241, 4367268, 4367557, 4367684 + +The legacy row does not store whether `is_global` was explicit or inferred. +Use `keep_global_ids` for rows independently known to have been explicitly +global. The script otherwise applies the existing content detector exactly +as requested by the reclassification plan. + +## Operator procedure + +1. Review ADR-1083 and the unresolved ID list. Start with the approved mapping + file described above. Additional mappings require owner approval. Use a private file + with verified `domains` and approved `memories` (decimal ID keys) maps + to existing canonical absolute project directories, plus `keep_global_ids`. + Empty mapping keys and inferred ownership from prose are rejected. +2. Deploy the reviewed code and schema before production reclassification. + Old installations still run the team-to-global backfill at initialization + and can reverse a data-only repair. Do not mix the repaired data with old + writers. The owner controls merge, release and installed-plugin updates. +3. Create a fresh `pg_dump -Fc -f cortex-before-611.dump cortex`. Restore it + to a separate scratch database and verify successful completion. +4. Run the script with the explicit target and inspect its JSON report: + + ```sh + python scripts/reclassify_team_scope.py --database-url postgresql:///cortex \ + --mappings /private/path/scope-mappings.json + ``` + +5. Apply that reviewed mapping with `--apply --backup /private/path/cortex-before-611.dump`. + Repeat the dry-run and require `change_count: 0`. Preserve the reports and + backup outside Git. SQLite uses `--sqlite-path` instead of `--database-url`. +6. Run both acceptance hooks from the updated installed plugin. Require + 4353879 absent in anthropic-partnership and present in japonais-2027, and + no japonais-2027 row in the parent's Team Decisions block. Do not claim + the issue fixed in a release before this. + +## Rerun after review (2026-09-17) + +The review of #613 found that `classify()` cleared `is_global` on any resolved +row the detector did not confirm, including rows the ADR-0200 promotion could +not have produced (no agent context, or no decision content). Those rows were +made global by an explicit act the script cannot see. The rule is now narrower: +only a decision written under an agent context loses global scope. Every other +global row is kept and listed under `unexplained_global_ids`, and the owner can +clear one through `clear_global_ids` in the mappings file. + +The figures above predate that change. The same archive was restored into a +fresh database and the whole sequence repeated with the approved mapping of +4353879 supplied from the first pass: + +| Measurement | Result | +| --- | --- | +| Global candidates | 160 | +| Dry-run changes | 93 | +| Applied changes | 93 | +| Changes proposed on the second run | 0 | +| Retained globals | 67 | +| of which unresolved project | 46 | +| of which kept as not produced by the defect | 6 | +| Non-benchmark rows before and after | 47,041 | + +The six kept rows are 4349818, 4353551, 4353795, 4353809, 4353915 and 4361406. +Each has an agent context and content the decision cue does not match, so the +script leaves the call to the owner. The apply step refused to run until the +`is_team_decision` column existed on the restored copy, as designed. + +## Python compatibility + +CI Python 3.10 exposed a collection error from importing `typing.Self`, which +is unavailable in that supported version. The database context manager now +uses its concrete class as a postponed return annotation, with no new dependency. + +## Completion ledger + +| Changed behavior or failure path | Evidence | +| --- | --- | +| Separate global resolution and team classification | core global-scope and handler team-scope regressions | +| Trusted ingest class/origin survives reopening | eight-case matrix, both backends | +| Persist marker and upgrade existing schema/view | SQLite schema-upgrade test; restored PostgreSQL archive | +| Initialization does not promote team rows to global | backfill tests, including zero second-run row count | +| Session hooks enforce project predicate before limit | team-project hook tests and real subprocess tests | +| Agent briefing scopes both selection passes | PostgreSQL team-project regression | +| Explicit global and detector behavior preserved | global detector tests and cross-project hook fixtures | +| Domain mapping, approved ID mapping, unresolved rows | script classifier and mapping validation tests | +| Dry-run, second-run idempotence, history/benchmark exclusion | SQLite script tests and restored snapshot experiment | +| Transaction rollback and marker refusal | real PostgreSQL and SQLite script tests | +| Missing/invalid backup, missing table data, full decode | script backup validation tests and full archive restore | + +No merge, release or production reclassification is performed by this change. diff --git a/mcp_server/core/global_detector.py b/mcp_server/core/global_detector.py index 5d714d004..0b580c5fe 100644 --- a/mcp_server/core/global_detector.py +++ b/mcp_server/core/global_detector.py @@ -262,18 +262,15 @@ def resolve_global_scope( tags: list[str], *, explicit: bool, - team_decision: bool, ) -> tuple[bool, str]: """Return (is_global, reason) for a new memory. - An explicit request wins, then team propagation of decisions (the caller - evaluates team_scope.is_team_decision), then the content detector. + Only explicit cross-project scope or the content detector sets global scope. + source: ADR-1083 source: ADR-0200 source: ADR-0184""" if explicit: return True, "explicit" - if team_decision: - return True, "team_decision" detected, _score, reason = detect_global(content, tags) return detected, reason diff --git a/mcp_server/core/memory_ingest.py b/mcp_server/core/memory_ingest.py index bccca5dd7..db00c8877 100644 --- a/mcp_server/core/memory_ingest.py +++ b/mcp_server/core/memory_ingest.py @@ -12,7 +12,7 @@ ) from mcp_server.observability import silent_failure from mcp_server.core import knowledge_graph, write_post_store -from mcp_server.core.team_scope import propagates_to_team +from mcp_server.core.team_scope import is_team_decision def ingest_memory( @@ -88,8 +88,11 @@ def ingest_memory( # source: ADR-0200 agent_ctx = memory.get("agent_context", "") - is_global = memory.get("is_global", False) or propagates_to_team( - auto_protect, agent_ctx + team_decision = not is_benchmark and is_team_decision( + chunk_content, + memory.get("capture_origin", "unknown"), + memory.get("write_class", "deliberate"), + agent_ctx, ) mid = store.insert_memory( @@ -108,7 +111,10 @@ def ingest_memory( "is_benchmark": is_benchmark, "is_protected": auto_protect, "agent_context": agent_ctx, - "is_global": is_global, + "is_global": memory.get("is_global", False), + "is_team_decision": team_decision, # source: ADR-1083 + "directory_context": memory.get("directory_context", ""), + "write_class": memory.get("write_class", "deliberate"), # source: ADR-0200 "capture_origin": memory.get("capture_origin", "unknown"), } diff --git a/mcp_server/core/team_scope.py b/mcp_server/core/team_scope.py index 5f816c935..9311f8b49 100644 --- a/mcp_server/core/team_scope.py +++ b/mcp_server/core/team_scope.py @@ -1,7 +1,8 @@ """Team scope of decisions: Transactive Memory Systems (Wegner 1987). The team knows WHAT was decided regardless of WHO decided it, so a -decision written under an agent context is marked is_global. +decision written under an agent context is marked is_team_decision. +Project scope remains unchanged (source: ADR-1083). source: ADR-0200""" diff --git a/mcp_server/handlers/remember.py b/mcp_server/handlers/remember.py index 5d71ced36..fc81eb0a8 100644 --- a/mcp_server/handlers/remember.py +++ b/mcp_server/handlers/remember.py @@ -286,9 +286,6 @@ async def _handler_impl( content, tags, explicit=bool(is_global), - team_decision=is_team_decision( - content, resolved_origin, resolved_write_class, agent_topic or "" - ), ) mid: int | None @@ -335,6 +332,9 @@ async def _handler_impl( emb_engine, agent_context=agent_topic, is_global=is_global, + team_decision=is_team_decision( + content, resolved_origin, resolved_write_class, agent_topic or "" + ), created_at=created_at, write_class=resolved_write_class, origin=resolved_origin, diff --git a/mcp_server/handlers/remember_helpers.py b/mcp_server/handlers/remember_helpers.py index dfb77e5a6..032b59917 100644 --- a/mcp_server/handlers/remember_helpers.py +++ b/mcp_server/handlers/remember_helpers.py @@ -694,6 +694,7 @@ def insert_and_post_process( emb_engine: EmbeddingEngine, agent_context: str = "", is_global: bool = False, + team_decision: bool = False, created_at: str | None = None, write_class: str = "deliberate", origin: str = capture_origin.ORIGIN_UNKNOWN, @@ -742,6 +743,7 @@ def insert_and_post_process( ) record["agent_context"] = agent_context record["is_global"] = is_global + record["is_team_decision"] = team_decision # source: ADR-1083 record["write_class"] = write_class # source: ADR-0438 record["capture_origin"] = origin diff --git a/mcp_server/hooks/agent_briefing.py b/mcp_server/hooks/agent_briefing.py index f6d8ba667..8b2c59ce5 100644 --- a/mcp_server/hooks/agent_briefing.py +++ b/mcp_server/hooks/agent_briefing.py @@ -36,6 +36,7 @@ from __future__ import annotations import json +import os import re import sys from pathlib import Path @@ -56,6 +57,7 @@ _fetch_agent_context, ) from mcp_server.infrastructure.config import CLAUDE_DIR +from mcp_server.shared.project_scope import resolve_project_root __all__ = [ "_DATABASE_URL", @@ -168,7 +170,9 @@ def process_event(event: dict[str, Any]) -> None: sys.exit(0) try: - memories = _fetch_agent_context(conn, agent_name, keywords) + memories = _fetch_agent_context( + conn, agent_name, keywords, resolve_project_root(event, os.environ) + ) if not memories: _log(f"skip: no relevant memories for {agent_name}") sys.exit(0) diff --git a/mcp_server/hooks/agent_briefing_query.py b/mcp_server/hooks/agent_briefing_query.py index e8bbae832..4626df8c5 100644 --- a/mcp_server/hooks/agent_briefing_query.py +++ b/mcp_server/hooks/agent_briefing_query.py @@ -7,6 +7,7 @@ import os from mcp_server.hooks.agent_briefing_log import _log +from mcp_server.shared.project_scope import project_ancestors _DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://localhost:5432/cortex") _MAX_MEMORIES = 3 @@ -28,18 +29,21 @@ def _connect(): return None -def _fetch_agent_context(conn, agent_name: str, keywords: list[str]) -> list[dict]: +def _fetch_agent_context( + conn, agent_name: str, keywords: list[str], project_root: str | None = None +) -> list[dict]: """Fetch relevant memories for agent briefing. Two-pass query: 1. Agent-scoped memories (agent_context matches) — prior work by this specialist - 2. Team decisions (is_protected + is_global) — cross-agent knowledge (TMS directory) + 2. Project team decisions, regardless of the authoring agent. Uses FTS plainto_tsquery for speed (no embedding model needed). Each result keeps the memory ``id`` — the injection receipt (T2) records exactly which memories entered the agent's context. """ results = [] + ancestors = project_ancestors(project_root) # source: ADR-1083 # Pass 1: Agent-scoped memories matching keywords if keywords: @@ -55,6 +59,7 @@ def _fetch_agent_context(conn, agent_name: str, keywords: list[str]) -> list[dic JOIN current_memories cm ON cm.id = m.id WHERE m.agent_context = %s AND effective_heat(m, NOW()) >= %s + AND (m.is_global = TRUE OR m.directory_context = ANY(%s::TEXT[])) AND NOT m.is_benchmark AND m.superseded_by_id IS NULL AND m.content_tsv @@ plainto_tsquery('english', %s) @@ -62,7 +67,13 @@ def _fetch_agent_context(conn, agent_name: str, keywords: list[str]) -> list[dic LIMIT %s """ ), - (agent_name, _MIN_HEAT, " ".join(keywords[:5]), _MAX_MEMORIES), + ( + agent_name, + _MIN_HEAT, + ancestors, + " ".join(keywords[:5]), + _MAX_MEMORIES, + ), ).fetchall() for r in rows: results.append( @@ -76,7 +87,7 @@ def _fetch_agent_context(conn, agent_name: str, keywords: list[str]) -> list[dic except Exception as exc: # noqa: BLE001 — hook boundary — failure is logged to the hook log; the hook stays non-fatal _log(f"agent-scoped query failed: {exc}") - # Pass 2: Team decisions (protected + global) + # Pass 2: Decisions shared across agents within project scope remaining = _MAX_MEMORIES - len(results) if remaining > 0: try: @@ -89,16 +100,16 @@ def _fetch_agent_context(conn, agent_name: str, keywords: list[str]) -> list[dic m.agent_context FROM memories m JOIN current_memories cm ON cm.id = m.id - WHERE m.is_protected = TRUE - AND m.is_global = TRUE + WHERE m.is_team_decision = TRUE AND m.agent_context != %s + AND (m.is_global = TRUE OR m.directory_context = ANY(%s::TEXT[])) AND NOT m.is_benchmark AND m.superseded_by_id IS NULL ORDER BY effective_heat(m, NOW()) DESC LIMIT %s """ ), - (agent_name, remaining), + (agent_name, ancestors, remaining), ).fetchall() for r in rows: results.append( diff --git a/mcp_server/hooks/session_start.py b/mcp_server/hooks/session_start.py index 43d1aaeb6..f46bf2dc6 100644 --- a/mcp_server/hooks/session_start.py +++ b/mcp_server/hooks/session_start.py @@ -211,15 +211,10 @@ def _fetch_anchors(conn, project_root: str | None = None) -> list[dict]: return anchors -def _fetch_team_decisions(conn, exclude_ids: set) -> list[dict]: - """Fetch auto-protected decision memories visible across agents. - - Implements the directory layer of Transactive Memory Systems - (Wegner 1987): team members know WHAT was decided, regardless - of WHO decided it. Decisions auto-propagate via is_global=TRUE, - set at write time by team_scope.is_team_decision. - - source: ADR-0498""" +def _fetch_team_decisions( + conn, exclude_ids: set, project_root: str | None = None +) -> list[dict]: + """Fetch team decisions within the current project. source: ADR-1083""" try: rows = conn.execute( # source: ADR-0498 @@ -230,11 +225,13 @@ def _fetch_team_decisions(conn, exclude_ids: set) -> list[dict]: # for effective_heat(). "effective_heat(m, NOW()) AS heat " "FROM memories m JOIN current_memories cm ON cm.id = m.id " - "WHERE m.is_protected = TRUE AND m.is_global = TRUE " + "WHERE m.is_team_decision = TRUE AND NOT m.is_benchmark " + "AND (m.is_global = TRUE OR m.directory_context = ANY(%s::TEXT[])) " "AND m.agent_context != '' " # source: ADR-0498 "AND m.superseded_by_id IS NULL " "ORDER BY effective_heat(m, NOW()) DESC LIMIT 5", + (project_ancestors(project_root),), ).fetchall() except Exception as exc: # noqa: BLE001 — hook boundary; failure is logged to the hook log, the banner degrades _log(f"team-decision fetch failed (non-fatal): {exc}") @@ -1264,7 +1261,7 @@ def main() -> None: anchors = _fetch_anchors(conn, project_root) anchor_ids = {a["id"] for a in anchors} hot = _fetch_hot_memories(conn, anchor_ids, project_root) - team_decisions = _fetch_team_decisions(conn, anchor_ids) + team_decisions = _fetch_team_decisions(conn, anchor_ids, project_root) checkpoint = _fetch_checkpoint(conn) pending_curations = _count_pending_curations(conn) stale_grooming = _fetch_grooming_staleness(conn) diff --git a/mcp_server/infrastructure/pg_schema.py b/mcp_server/infrastructure/pg_schema.py index 4da01fe62..fbf4956f0 100644 --- a/mcp_server/infrastructure/pg_schema.py +++ b/mcp_server/infrastructure/pg_schema.py @@ -77,6 +77,7 @@ is_benchmark BOOLEAN DEFAULT FALSE, agent_context TEXT DEFAULT '', is_global BOOLEAN DEFAULT FALSE, + is_team_decision BOOLEAN NOT NULL DEFAULT FALSE, supersedes_id INTEGER REFERENCES memories(id) ON DELETE SET NULL, superseded_by_id INTEGER REFERENCES memories(id) ON DELETE SET NULL, -- source: ADR-0537 @@ -1387,6 +1388,9 @@ # ── Migrations ─────────────────────────────────────────────────────────── MIGRATIONS_DDL = """ +-- source: ADR-1083 +ALTER TABLE memories ADD COLUMN IF NOT EXISTS + is_team_decision BOOLEAN NOT NULL DEFAULT FALSE; -- source: ADR-0537 DO $$ BEGIN diff --git a/mcp_server/infrastructure/pg_store_write.py b/mcp_server/infrastructure/pg_store_write.py index 063e1b0ae..f696d387f 100644 --- a/mcp_server/infrastructure/pg_store_write.py +++ b/mcp_server/infrastructure/pg_store_write.py @@ -29,7 +29,7 @@ class PgWriteMixin(PgStoreHost): separation_index, interference_score, schema_match_score, schema_id, hippocampal_dependency, is_benchmark, agent_context, - is_global, stage_entered_at, + is_global, is_team_decision, stage_entered_at, arousal, dominant_emotion, supersedes_id, source_attribution, stimulus_signature, extinction_strength, write_class, capture_origin @@ -44,7 +44,7 @@ class PgWriteMixin(PgStoreHost): %(separation_index)s, %(interference_score)s, %(schema_match_score)s, %(schema_id)s, %(hippocampal_dependency)s, %(is_benchmark)s, %(agent_context)s, - %(is_global)s, %(stage_entered_at)s, + %(is_global)s, %(is_team_decision)s, %(stage_entered_at)s, %(arousal)s, %(dominant_emotion)s, %(supersedes_id)s, %(source_attribution)s, %(stimulus_signature)s, %(extinction_strength)s, %(write_class)s, %(capture_origin)s @@ -106,6 +106,7 @@ def _insert_signal_fields( "is_benchmark": data.get("is_benchmark", False), "agent_context": data.get("agent_context", ""), "is_global": data.get("is_global", False), + "is_team_decision": data.get("is_team_decision", False), "stage_entered_at": data.get("stage_entered_at") or created_at, "arousal": data.get("arousal", 0.0), "dominant_emotion": data.get("dominant_emotion", "neutral"), diff --git a/mcp_server/infrastructure/sqlite_schema.py b/mcp_server/infrastructure/sqlite_schema.py index c0fe91ed6..a8b32d4e4 100644 --- a/mcp_server/infrastructure/sqlite_schema.py +++ b/mcp_server/infrastructure/sqlite_schema.py @@ -68,7 +68,8 @@ hippocampal_dependency REAL DEFAULT 1.0, is_benchmark INTEGER DEFAULT 0, agent_context TEXT DEFAULT '', - is_global INTEGER DEFAULT 0 + is_global INTEGER DEFAULT 0, + is_team_decision INTEGER NOT NULL DEFAULT 0 ); """ @@ -378,6 +379,8 @@ def get_all_ddl() -> list[str]: ("memories", "is_benchmark", "INTEGER DEFAULT 0"), ("memories", "agent_context", "TEXT DEFAULT ''"), ("memories", "is_global", "INTEGER DEFAULT 0"), + # source: ADR-1083 (project visibility). + ("memories", "is_team_decision", "INTEGER NOT NULL DEFAULT 0"), ("memories", "stage_entered_at", "TEXT"), # source: ADR-0602 ("prospective_memories", "created_by", "TEXT NOT NULL DEFAULT ''"), diff --git a/mcp_server/infrastructure/sqlite_store.py b/mcp_server/infrastructure/sqlite_store.py index 925e4ffff..f814decca 100644 --- a/mcp_server/infrastructure/sqlite_store.py +++ b/mcp_server/infrastructure/sqlite_store.py @@ -340,12 +340,12 @@ def _insert_memory_rows(self, data: dict[str, Any]) -> int: separation_index, interference_score, schema_match_score, schema_id, hippocampal_dependency, is_benchmark, agent_context, - is_global, supersedes_id, source_attribution, + is_global, is_team_decision, supersedes_id, source_attribution, stimulus_signature, extinction_strength, write_class, capture_origin ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )""", ( content, @@ -374,6 +374,7 @@ def _insert_memory_rows(self, data: dict[str, Any]) -> int: int(data.get("is_benchmark", False)), data.get("agent_context", ""), int(data.get("is_global", False)), + int(data.get("is_team_decision", False)), data.get("supersedes_id"), data.get("source_attribution", "unknown"), data.get("stimulus_signature", ""), @@ -902,6 +903,7 @@ def _normalize_memory_row(self, row: dict | sqlite3.Row) -> dict[str, Any]: "compressed", "is_benchmark", "is_global", + "is_team_decision", "is_active", "is_causal", "archived", diff --git a/mcp_server/infrastructure/team_scope_backfill.py b/mcp_server/infrastructure/team_scope_backfill.py index f09b37eeb..953ec48ea 100644 --- a/mcp_server/infrastructure/team_scope_backfill.py +++ b/mcp_server/infrastructure/team_scope_backfill.py @@ -1,18 +1,8 @@ -"""One-shot backfill: team propagation of decisions written before the fix. +"""Mark legacy team decisions without granting cross-project visibility. -Until #561 the live ``remember`` path never applied ADR-0200's rule (a -decision written under an agent context is marked is_global), so decisions -already stored carry is_global = FALSE. ``is_protected`` was set at write -time from the same decision cue the rule reads, which makes it the stored -trace of that cue. The rule applies only to origins allowed to claim a -content-derived privilege (capture_origin: deliberate, local_action) and to -deliberate writes, as on the write path; legacy and unknown rows keep their -scope. Anchored rows are -excluded: ``anchor`` sets is_protected as an explicit act with its own -is_global argument, not as a decision cue. Idempotent: a second run matches -no row. - -source: ADR-0200""" +Global reclassification is a separate reviewed migration. +source: ADR-1083 +""" from __future__ import annotations @@ -21,11 +11,11 @@ if TYPE_CHECKING: from typing_extensions import LiteralString -# source: ADR-0200 +# source: ADR-1083 (project visibility). TEAM_DECISION_BACKFILL_PG: LiteralString = """ -UPDATE memories SET is_global = TRUE +UPDATE memories SET is_team_decision = TRUE WHERE is_protected = TRUE - AND is_global = FALSE + AND is_team_decision = FALSE AND is_benchmark = FALSE AND COALESCE(agent_context, '') <> '' AND superseded_by_id IS NULL @@ -34,11 +24,11 @@ AND NOT COALESCE(tags @> '["_anchor"]'::jsonb, FALSE); """ -# source: ADR-0200 +# source: ADR-1083 (project visibility). TEAM_DECISION_BACKFILL_SQLITE: LiteralString = """ -UPDATE memories SET is_global = 1 +UPDATE memories SET is_team_decision = 1 WHERE is_protected = 1 - AND COALESCE(is_global, 0) = 0 + AND COALESCE(is_team_decision, 0) = 0 AND COALESCE(is_benchmark, 0) = 0 AND COALESCE(agent_context, '') <> '' AND superseded_by_id IS NULL diff --git a/scripts/reclassify_team_scope.py b/scripts/reclassify_team_scope.py new file mode 100644 index 000000000..7fa9a2ab1 --- /dev/null +++ b/scripts/reclassify_team_scope.py @@ -0,0 +1,181 @@ +"""Reclassify legacy global rows; dry-run unless --apply. source: ADR-1083""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from mcp_server.core.global_detector import detect_global +from mcp_server.core.thermodynamics import is_decision_content +from mcp_server.hooks.wiring import wire_composition_root # noqa: E402 — source: issue #560 +from scripts.reclassify_team_scope_db import ScopeDatabase, verify_backup + +wire_composition_root() + + +@dataclass(frozen=True) +class ScopeRow: + id: int + content: str + tags: list[str] + domain: str + directory_context: str + agent_context: str + is_global: bool + is_team_decision: bool + + +@dataclass(frozen=True) +class ScopeChange: + id: int + directory_context: str + is_global: bool + is_team_decision: bool + reason: str + + +@dataclass(frozen=True) +class ScopeMappings: + domains: dict[str, str] + memories: dict[str, str] + keep_global_ids: frozenset[int] + clear_global_ids: frozenset[int] = frozenset() + + +def load_mappings(path: Path | None) -> ScopeMappings: + data = json.loads(path.read_text()) if path else {} + if not isinstance(data, dict) or set(data) - { + "domains", + "memories", + "keep_global_ids", + "clear_global_ids", + }: + raise ValueError( + "mappings accept domains, memories, keep_global_ids, clear_global_ids" + ) + domains, memories = data.get("domains", {}), data.get("memories", {}) + for mapping in (domains, memories): + if not isinstance(mapping, dict): + raise ValueError("project mappings must be objects") + for key, directory in mapping.items(): + if not isinstance(key, str) or not key.strip(): + raise ValueError("empty mapping key") + if not isinstance(directory, str): + raise ValueError("project directory must be a string") + project = Path(directory) + if not project.is_absolute() or not project.is_dir(): + raise ValueError(f"project directory must exist: {directory}") + if str(project.resolve()) != directory: + raise ValueError(f"project directory must be canonical: {directory}") + if any(not key.isdecimal() for key in memories): + raise ValueError("memory mapping keys must be decimal IDs") + id_lists = [] + for key in ("keep_global_ids", "clear_global_ids"): + ids = data.get(key, []) + if not isinstance(ids, list) or any(type(item) is not int for item in ids): + raise ValueError(f"{key} must be a list of integers") + id_lists.append(frozenset(ids)) + if id_lists[0] & id_lists[1]: + raise ValueError("an id cannot be both kept and cleared") + return ScopeMappings(domains, memories, id_lists[0], id_lists[1]) + + +def classify(row: ScopeRow, mappings: ScopeMappings) -> ScopeChange: + directory = row.directory_context + reason = "recorded_project" + if not directory: + directory = mappings.memories.get(str(row.id), "") + reason = "owner_memory_mapping" + if not directory: + directory = mappings.domains.get(row.domain, "") + reason = "verified_domain_mapping" + if not directory: + return ScopeChange( + row.id, "", row.is_global, row.is_team_decision, "unresolved_project" + ) + detected, _, _ = detect_global(row.content, row.tags) + # Only the ADR-0200 promotion is undone: a decision written under an agent + # context. Any other global row was made global by an explicit act this + # script cannot see, so it stays global. source: ADR-1083 + promoted = bool(row.agent_context) and is_decision_content(row.content) + kept = row.id in mappings.keep_global_ids or detected + cleared = row.id in mappings.clear_global_ids # the owner's explicit call + global_scope = kept or (row.is_global and not promoted and not cleared) + team = row.is_team_decision or promoted + if global_scope and not kept: + reason = "global_origin_not_the_defect" + return ScopeChange(row.id, directory, global_scope, team, reason) + + +def changes_scope(row: ScopeRow, change: ScopeChange) -> bool: + return ( + row.directory_context != change.directory_context + or row.is_global != change.is_global + or row.is_team_decision != change.is_team_decision + ) + + +def make_report(rows: list[ScopeRow], mappings: ScopeMappings) -> dict: + changes = [classify(row, mappings) for row in rows] + updates = [ + change + for row, change in zip(rows, changes, strict=True) + if changes_scope(row, change) + ] + return { + "candidate_count": len(rows), + "change_count": len(updates), + "clear_global_count": sum(not change.is_global for change in updates), + "unresolved_ids": [ + change.id for change in changes if change.reason == "unresolved_project" + ], + "retained_global_ids": [change.id for change in changes if change.is_global], + "unexplained_global_ids": [ + change.id + for change in changes + if change.reason == "global_origin_not_the_defect" + ], + "changes": [asdict(change) for change in updates], + } + + +def run(args: argparse.Namespace) -> dict: + mappings = load_mappings(args.mappings) + if args.apply and args.database_url: + verify_backup(args.backup) + with ScopeDatabase(args.database_url, args.sqlite_path) as database: + raw_rows = database.fetch_rows(require_marker=args.apply) + rows = [ScopeRow(**row) for row in raw_rows] + report = make_report(rows, mappings) + if args.apply: + database.apply_changes(report["changes"]) + report["applied"] = args.apply + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument( + "--database-url", help="Explicit PostgreSQL DSN; no env default" + ) + target.add_argument("--sqlite-path", type=Path) + parser.add_argument( + "--mappings", type=Path, help="Verified project/owner mappings JSON" + ) + parser.add_argument("--apply", action="store_true") + parser.add_argument( + "--backup", type=Path, help="Verified pg_dump -Fc archive for apply" + ) + args = parser.parse_args() + print(json.dumps(run(args), indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reclassify_team_scope_db.py b/scripts/reclassify_team_scope_db.py new file mode 100644 index 000000000..1c9a3adaf --- /dev/null +++ b/scripts/reclassify_team_scope_db.py @@ -0,0 +1,110 @@ +"""Explicit database boundary for the scope repair. source: ADR-1083""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import sqlite3 +import subprocess + + +def verify_backup(path: Path | None) -> None: + if path is None or not path.is_file(): + raise ValueError("PostgreSQL apply requires an existing --backup archive") + with path.open("rb") as stream: + if stream.read(len(b"PGDMP")) != b"PGDMP": + raise ValueError("backup must be a pg_dump custom-format archive") + catalog = subprocess.run( + ["pg_restore", "--list", str(path)], check=True, capture_output=True, text=True + ).stdout + if "TABLE DATA public memories " not in catalog: + raise ValueError("backup must contain public.memories table data") + subprocess.run(["pg_restore", "--file", os.devnull, str(path)], check=True) + + +class ScopeDatabase: + def __init__(self, database_url: str | None, sqlite_path: Path | None) -> None: + self.postgres = database_url is not None + if self.postgres: + import psycopg # noqa: PLC0415 -- optional PostgreSQL adapter + from psycopg.rows import dict_row # noqa: PLC0415 -- optional PostgreSQL adapter + + self.connection = psycopg.connect(database_url, row_factory=dict_row) + else: + if sqlite_path is None or not sqlite_path.is_file(): + raise ValueError("SQLite target must be an existing database") + self.connection = sqlite3.connect(sqlite_path) + self.connection.row_factory = sqlite3.Row + self.columns: set[str] = set() + + def __enter__(self) -> ScopeDatabase: + if self.postgres: + self.connection.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + rows = self.connection.execute( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = current_schema() AND table_name = 'memories'" + ).fetchall() + self.columns = {row["column_name"] for row in rows} + else: + self.connection.execute("BEGIN IMMEDIATE") + self.columns = { + row["name"] + for row in self.connection.execute("PRAGMA table_info(memories)") + } + return self + + def __exit__(self, *_exc: object) -> None: + self.connection.rollback() + self.connection.close() + + def fetch_rows(self, *, require_marker: bool) -> list[dict]: + marker = "is_team_decision" in self.columns + if require_marker and not marker: + raise ValueError( + "install the is_team_decision schema migration before apply" + ) + team_column = "m.is_team_decision" if marker else "FALSE AS is_team_decision" + query = ( + "SELECT m.id, m.content, m.tags, m.domain, m.directory_context, " # noqa: S608 -- fixed column choice, no user SQL + "m.agent_context, m.is_global, " + team_column + " FROM memories m " + "JOIN current_memories cm ON cm.id = m.id " + "WHERE m.is_global = TRUE AND m.is_benchmark = FALSE ORDER BY m.id" + ) + if self.postgres and require_marker: + query += " FOR UPDATE OF m" + rows = self.connection.execute(query).fetchall() + return [self._normalize(dict(row)) for row in rows] + + @staticmethod + def _normalize(row: dict) -> dict: + tags = row["tags"] or [] + row["tags"] = json.loads(tags) if isinstance(tags, str) else tags + for key in ("domain", "directory_context", "agent_context"): + row[key] = row[key] or "" + for key in ("is_global", "is_team_decision"): + row[key] = bool(row[key]) + return row + + def apply_changes(self, changes: list[dict]) -> None: + query = ( + "UPDATE memories SET directory_context = ?, is_global = ?, " + "is_team_decision = ? WHERE id = ?" + ) + if self.postgres: + query = query.replace("?", "%s") + for change in changes: + cursor = self.connection.execute( + query, + ( + change["directory_context"], + change["is_global"], + change["is_team_decision"], + change["id"], + ), + ) + if cursor.rowcount != 1: + raise RuntimeError( + f"scope update did not affect exactly row {change['id']}" + ) + self.connection.commit() diff --git a/tests_py/core/test_global_scope_resolution.py b/tests_py/core/test_global_scope_resolution.py index c858af79e..98dfbdcbc 100644 --- a/tests_py/core/test_global_scope_resolution.py +++ b/tests_py/core/test_global_scope_resolution.py @@ -1,6 +1,6 @@ -"""resolve_global_scope: explicit request, team propagation, detector (#561). +"""Global detection is independent of project team visibility (#611). -source: ADR-0200""" +source: ADR-1083""" from __future__ import annotations @@ -24,27 +24,28 @@ def test_non_decision_never_propagates(self): class TestResolveGlobalScope: def test_explicit_request_wins(self): - assert resolve_global_scope(_PLAIN, [], explicit=True, team_decision=False) == ( + assert resolve_global_scope(_PLAIN, [], explicit=True) == ( True, "explicit", ) - def test_team_decision_is_global(self): - assert resolve_global_scope( - _DECISION, [], explicit=False, team_decision=True - ) == (True, "team_decision") + def test_decision_content_stays_local(self): + assert resolve_global_scope(_DECISION, [], explicit=False) == ( + False, + "not_global", + ) def test_no_team_decision_falls_back_to_detector(self): - assert resolve_global_scope( - _DECISION, [], explicit=False, team_decision=False - ) == (False, "not_global") + assert resolve_global_scope(_DECISION, [], explicit=False) == ( + False, + "not_global", + ) def test_detector_still_marks_cross_project_content(self): is_global, reason = resolve_global_scope( "Coding standard: always use dependency injection and clean architecture", ["global"], explicit=False, - team_decision=False, ) assert is_global is True assert reason.startswith("global_") diff --git a/tests_py/core/test_memory_ingest_capture_origin.py b/tests_py/core/test_memory_ingest_capture_origin.py index 75c41c114..62de917f9 100644 --- a/tests_py/core/test_memory_ingest_capture_origin.py +++ b/tests_py/core/test_memory_ingest_capture_origin.py @@ -82,3 +82,22 @@ def test_all_chunks_of_a_decomposed_memory_share_the_parent_origin(self, store): assert len(ids) >= 1 origins = {_fetch_capture_origin(store, mid) for mid in ids} assert origins == {"deliberate"} + + +def test_ingested_team_decision_keeps_project(store): + ids = ingest_memory( + { + "content": "Decision: retain the ledger layout", + "agent_context": "engineer", + "directory_context": "/tmp/project-a", + "capture_origin": "deliberate", + }, + store, + _NullEmbeddings(), + domain="test", + decompose=False, + ) + row = store.get_memory(ids[0]) + assert row["directory_context"] == "/tmp/project-a" + assert row["is_team_decision"] + assert not row["is_global"] diff --git a/tests_py/core/test_memory_ingest_scope_persistence.py b/tests_py/core/test_memory_ingest_scope_persistence.py new file mode 100644 index 000000000..ed8a67aec --- /dev/null +++ b/tests_py/core/test_memory_ingest_scope_persistence.py @@ -0,0 +1,50 @@ +"""Ingested scope privileges survive schema initialization. source: ADR-1083""" + +import pytest + +from mcp_server.core.memory_ingest import ingest_memory +from mcp_server.handlers.forget import _get_store + + +@pytest.mark.parametrize("origin", ["local_action", "deliberate", "network", "unknown"]) +@pytest.mark.parametrize("write_class", ["auto", "deliberate"]) +def test_ingest_privileges_survive_reopen(origin, write_class, tmp_path): + store = _get_store() + sqlite_path = str(tmp_path / "scope.db") + if type(store).__name__ != "PgMemoryStore": + store = type(store)(sqlite_path) + ids = ingest_memory( + { + "content": "We decided to retain the ledger layout for this dossier.", + "agent_context": "engineer", + "directory_context": "/tmp/project-a", + "capture_origin": origin, + "write_class": write_class, + }, + store, + None, + decompose=True, + ) + assert ids + expected_team = write_class == "deliberate" and origin in { + "local_action", + "deliberate", + } + if type(store).__name__ == "PgMemoryStore": + # Force the schema-init path, including backfill, in this scratch DB. + store._conn.execute("DELETE FROM schema_meta") + reopened = type(store)(store._url) + else: + reopened = type(store)(sqlite_path) + try: + for mid in ids: + row = reopened.get_memory(mid) + assert row["write_class"] == write_class + assert row["capture_origin"] == origin + assert row["directory_context"] == "/tmp/project-a" + assert bool(row["is_team_decision"]) is expected_team + assert not row["is_global"] + finally: + reopened.close() + if type(store).__name__ != "PgMemoryStore": + store.close() diff --git a/tests_py/fixtures/w3_4/remember.py.txt b/tests_py/fixtures/w3_4/remember.py.txt index 19c993bac..f96fa9293 100644 --- a/tests_py/fixtures/w3_4/remember.py.txt +++ b/tests_py/fixtures/w3_4/remember.py.txt @@ -219,6 +219,9 @@ async def _handler_impl(args: dict[str, Any] | None = None) -> dict[str, Any]: emb_engine, agent_context=agent_topic, is_global=is_global, + team_decision=is_team_decision( + content, resolved_origin, resolved_write_class, agent_topic or "" + ), created_at=created_at, write_class=resolved_write_class, origin=resolved_origin, diff --git a/tests_py/handlers/test_remember_team_scope.py b/tests_py/handlers/test_remember_team_scope.py index 62ef1de91..e157a93e9 100644 --- a/tests_py/handlers/test_remember_team_scope.py +++ b/tests_py/handlers/test_remember_team_scope.py @@ -1,9 +1,9 @@ -"""remember propagates decisions written under an agent context (#561). +"""remember keeps team decisions project-scoped (#611). Through the real handler, on whichever backend conftest selected, so the row the SessionStart "Team Decisions" query reads is the one asserted on. -source: ADR-0200""" +source: ADR-1083""" from __future__ import annotations @@ -21,7 +21,8 @@ def _row(memory_id: int) -> dict: with _get_store()._conn.cursor() as cur: cur.execute( - "SELECT is_global, is_protected, agent_context FROM memories WHERE id = %s", + "SELECT is_team_decision, is_global, is_protected, agent_context " + "FROM memories WHERE id = %s", (memory_id,), ) row = cur.fetchone() @@ -30,17 +31,18 @@ def _row(memory_id: int) -> dict: class TestDecisionPropagation: - def test_decision_with_agent_topic_is_global(self): + def test_decision_with_agent_topic_stays_project_scoped(self): result = _remember( content="Decision: we keep the ledger layout because the dossier " "page reads better with ruled rows (team-scope test a)", agent_topic="cortex", ) assert result["stored"] is True, result - assert result.get("global_reason") == "team_decision" + assert not result.get("is_global", False) row = _row(result["memory_id"]) + assert bool(row["is_team_decision"]) is True assert bool(row["is_protected"]) is True - assert bool(row["is_global"]) is True + assert bool(row["is_global"]) is False def test_decision_without_agent_topic_stays_local(self): result = _remember( @@ -48,7 +50,9 @@ def test_decision_without_agent_topic_stays_local(self): "page reads better with ruled rows (team-scope test b)", ) assert result["stored"] is True, result - assert bool(_row(result["memory_id"])["is_global"]) is False + row = _row(result["memory_id"]) + assert not row["is_global"] + assert not row["is_team_decision"] def test_network_origin_decision_never_propagates(self): """A fetched page carrying a decision cue must not reach every @@ -62,7 +66,9 @@ def test_network_origin_decision_never_propagates(self): force=True, ) assert result["stored"] is True, result - assert bool(_row(result["memory_id"])["is_global"]) is False + row = _row(result["memory_id"]) + assert not row["is_global"] + assert not row["is_team_decision"] def test_auto_capture_decision_never_propagates(self): """Unattended tool-output capture is not a considered decision, even @@ -76,4 +82,6 @@ def test_auto_capture_decision_never_propagates(self): force=True, ) assert result["stored"] is True, result - assert bool(_row(result["memory_id"])["is_global"]) is False + row = _row(result["memory_id"]) + assert not row["is_global"] + assert not row["is_team_decision"] diff --git a/tests_py/hooks/test_agent_briefing.py b/tests_py/hooks/test_agent_briefing.py index e6ad9b3ba..b0391db0a 100644 --- a/tests_py/hooks/test_agent_briefing.py +++ b/tests_py/hooks/test_agent_briefing.py @@ -211,7 +211,7 @@ def test_no_keywords_skips_the_agent_scoped_query(): assert conn.execute.call_count == 1, "only the team-decisions query ran" sql = conn.execute.call_args[0][0] - assert "is_protected" in sql + assert "is_team_decision" in sql def test_memory_content_is_truncated_to_300_chars(): diff --git a/tests_py/hooks/test_hook_receipts.py b/tests_py/hooks/test_hook_receipts.py index cdc1067e2..1c794726f 100644 --- a/tests_py/hooks/test_hook_receipts.py +++ b/tests_py/hooks/test_hook_receipts.py @@ -89,14 +89,27 @@ def _seed( agent: str = "", tags: str = "[]", superseded_by: int | None = None, + directory: str = "", + team_decision: bool = False, ) -> int: row = conn.execute( "INSERT INTO memories (content, heat_base, heat_base_set_at, " "is_benchmark, plasticity, no_decay, is_protected, is_global, " - "agent_context, tags, superseded_by_id) " - "VALUES (%s, %s, NOW(), FALSE, 1.0, FALSE, %s, %s, %s, %s::jsonb, %s) " - "RETURNING id", - (content, heat, protected, is_global, agent, tags, superseded_by), + "agent_context, tags, superseded_by_id, directory_context, " + "is_team_decision) " + "VALUES (%s, %s, NOW(), FALSE, 1.0, FALSE, %s, %s, %s, %s::jsonb, %s, " + "%s, %s) RETURNING id", + ( + content, + heat, + protected, + is_global, + agent, + tags, + superseded_by, + directory, + team_decision, + ), ).fetchone() return int(row["id"]) @@ -277,20 +290,34 @@ def test_auto_recall_emits_receipt_with_marker(_db) -> None: # ── agent_briefing (subprocess, end-to-end) ─────────────────────────────── +def _seed_engineer(conn, content: str, **kwargs) -> int: + """An engineer-authored row in the project the briefing events name.""" + return _seed(conn, content, agent="engineer", directory="/tmp", **kwargs) + + def test_agent_briefing_emits_receipt_with_marker(_db) -> None: - mid = _seed( - _db, - "HOOKRCPT_TEST zephyrine quantalum brokerage reconciliation ledger", - agent="engineer", + mid = _seed_engineer( + _db, "HOOKRCPT_TEST zephyrine quantalum brokerage reconciliation ledger" ) - # Pass 2 (TMS directory layer): a protected global decision from + # Pass 2 (TMS directory layer): a team decision of the SAME project from # ANOTHER agent enters the briefing regardless of keywords — it must - # be attested by the same receipt, ranked after the agent-scoped pass. + # be attested by the same receipt, ranked after the agent-scoped pass + # (project scope per ADR-1083). team_id = _seed( _db, "HOOKRCPT_TEST team decision on rollout gates", protected=True, - is_global=True, + team_decision=True, + directory="/tmp", + agent="architect", + ) + # The same kind of row from ANOTHER project must stay out (issue #611). + _seed( + _db, + "HOOKRCPT_TEST foreignproject decision on rollout gates", + protected=True, + team_decision=True, + directory="/another-project", agent="architect", ) @@ -313,6 +340,7 @@ def test_agent_briefing_emits_receipt_with_marker(_db) -> None: assert "zephyrine" in result.stdout.lower(), ( f"expected briefing, stdout={result.stdout!r} stderr={result.stderr!r}" ) + assert "foreignproject" not in result.stdout.lower() row = _db.execute( "SELECT id FROM injection_receipts " @@ -348,10 +376,8 @@ def test_agent_briefing_falls_back_when_only_dispatch_agent_is_installed( pattern). source: ADR-0971""" - mid = _seed( - _db, - "HOOKRCPT_TEST corvidae plangent isotherm dossier archive", - agent="engineer", + mid = _seed_engineer( + _db, "HOOKRCPT_TEST corvidae plangent isotherm dossier archive" ) agents_dir = tmp_path / "agents" @@ -438,16 +464,9 @@ def test_channel_enum_migration_restores_dropped_constraint(_db) -> None: def test_agent_briefing_skips_superseded_prior_work(_db) -> None: # Correction 8 on the briefing path: the agent-scoped pass must not # brief with a corrected fact. - current = _seed( - _db, - "HOOKRCPT_TEST ombrelline daguerre synthesis current", - agent="engineer", - ) - stale = _seed( - _db, - "HOOKRCPT_TEST ombrelline daguerre synthesis stale", - agent="engineer", - superseded_by=current, + current = _seed_engineer(_db, "HOOKRCPT_TEST ombrelline daguerre synthesis current") + stale = _seed_engineer( + _db, "HOOKRCPT_TEST ombrelline daguerre synthesis stale", superseded_by=current ) result = _run_hook( diff --git a/tests_py/hooks/test_team_project_scope.py b/tests_py/hooks/test_team_project_scope.py new file mode 100644 index 000000000..7610ad708 --- /dev/null +++ b/tests_py/hooks/test_team_project_scope.py @@ -0,0 +1,85 @@ +"""Real store and hook project isolation for team decisions. source: ADR-1083""" + +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from mcp_server.handlers.forget import _get_store +from mcp_server.handlers.remember import handler +from mcp_server.hooks.agent_briefing_query import _fetch_agent_context +from mcp_server.hooks.session_start import _fetch_team_decisions + + +def _write(content, **kwargs): + result = asyncio.run(handler({"content": content, "force": True, **kwargs})) + assert result["stored"], result + return result["memory_id"] + + +def _hook(module, cwd): + env = os.environ.copy() + env.pop("CLAUDE_PROJECT_ROOT", None) + result = subprocess.run( + [sys.executable, "-m", f"mcp_server.hooks.{module}"], + input=json.dumps( + { + "cwd": cwd, + "prompt": "ledger dossier layout decision", + "source": "startup", + } + ), + capture_output=True, + text=True, + env=env, + cwd=Path(__file__).resolve().parents[2], + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +@pytest.mark.parametrize("module", ["auto_recall", "session_start"]) +def test_team_decision_hooks_remain_project_scoped(module): + content = "Decision: retain the ledger dossier layout for ORCHID_SCOPE_MARKER" + _write(content, agent_topic="engineer", directory="/tmp/project-a") + assert "ORCHID_SCOPE_MARKER" in _hook(module, "/tmp/project-a") + assert "ORCHID_SCOPE_MARKER" not in _hook(module, "/tmp/project-b") + + +@pytest.mark.parametrize("module", ["auto_recall", "session_start"]) +def test_explicit_global_hooks_cross_projects(module): + content = "Decision: retain the ledger dossier layout for GLOBAL_SCOPE_MARKER" + _write(content, agent_topic="engineer", directory="/tmp/project-a", is_global=True) + assert "GLOBAL_SCOPE_MARKER" in _hook(module, "/tmp/project-a") + assert "GLOBAL_SCOPE_MARKER" in _hook(module, "/tmp/project-b") + + +def test_pg_team_readers_scope_both_agent_passes(): + store = _get_store() + if type(store).__name__ != "PgMemoryStore": + pytest.skip("PostgreSQL backend required for raw PG hook queries") + own = _write( + "Decision: retain ledger dossier for local orchid", + agent_topic="dba", + directory="/tmp/project-a", + ) + foreign = _write( + "Decision: retain ledger dossier for foreign orchid", + agent_topic="engineer", + directory="/tmp/project-b", + ) + conn = store._conn + team_ids = {r["id"] for r in _fetch_team_decisions(conn, set(), "/tmp/project-a")} + assert own in team_ids and foreign not in team_ids + briefing_ids = { + r["id"] + for r in _fetch_agent_context(conn, "engineer", ["ledger"], "/tmp/project-a") + } + assert own in briefing_ids and foreign not in briefing_ids + assert not _fetch_team_decisions(conn, set(), None) diff --git a/tests_py/infrastructure/test_team_scope_backfill.py b/tests_py/infrastructure/test_team_scope_backfill.py index ea78f6899..2bc25dc00 100644 --- a/tests_py/infrastructure/test_team_scope_backfill.py +++ b/tests_py/infrastructure/test_team_scope_backfill.py @@ -1,6 +1,6 @@ -"""One-shot team-scope backfill for decisions stored before #561. +"""Initialization marks legacy team decisions without promoting global scope. -source: ADR-0200""" +source: ADR-1083""" from __future__ import annotations @@ -15,7 +15,7 @@ TEAM_DECISION_BACKFILL_SQLITE, ) -# (label, row overrides, expected is_global after the backfill) +# (label, row overrides, expected is_team_decision after the backfill) _CASES = [ ("decision", {}, True), ("anchored", {"tags": ["_anchor"]}, False), @@ -41,11 +41,12 @@ def _row(label: str, overrides: dict) -> dict: return data -def _is_global(conn, memory_id: int) -> bool: +def _is_team(conn, memory_id: int) -> bool: row = conn.execute( - "SELECT is_global FROM memories WHERE id = %s", (memory_id,) + "SELECT is_team_decision, is_global FROM memories WHERE id = %s", (memory_id,) ).fetchone() - return bool(row["is_global"]) + assert not row["is_global"] + return bool(row["is_team_decision"]) def test_sql_origin_list_matches_the_write_path(): @@ -84,7 +85,10 @@ def test_sqlite_store_init_backfills_pre_fix_decisions(sqlite_path): reopened = SqliteMemoryStore(sqlite_path) for label, _, expected in _CASES: - assert _is_global(reopened._conn, ids[label]) is expected, label + assert _is_team(reopened._conn, ids[label]) is expected, label + assert reopened._conn.execute(TEAM_DECISION_BACKFILL_SQLITE).rowcount == 0 + reopened.close() + store.close() def test_pg_backfill_statement(): @@ -102,4 +106,22 @@ def test_pg_backfill_statement(): ) store._conn.execute(TEAM_DECISION_BACKFILL_PG) for label, _, expected in _CASES: - assert _is_global(store._conn, ids[label]) is expected, label + assert _is_team(store._conn, ids[label]) is expected, label + assert store._conn.execute(TEAM_DECISION_BACKFILL_PG).rowcount == 0 + + +def test_sqlite_upgrade_adds_team_marker_without_global_promotion(tmp_path): + from mcp_server.infrastructure.sqlite_store import SqliteMemoryStore + + path = str(tmp_path / "legacy.db") + store = SqliteMemoryStore(path) + mid = store.insert_memory(_row("legacy-schema", {})) + store._conn.execute("ALTER TABLE memories DROP COLUMN is_team_decision") + store._conn.commit() + store.close() + reopened = SqliteMemoryStore(path) + try: + assert _is_team(reopened._conn, mid) + assert reopened._conn.execute(TEAM_DECISION_BACKFILL_SQLITE).rowcount == 0 + finally: + reopened.close() diff --git a/tests_py/invariants/test_I2_canonical_writer.py b/tests_py/invariants/test_I2_canonical_writer.py index 85fe4df77..acbd3e2d2 100644 --- a/tests_py/invariants/test_I2_canonical_writer.py +++ b/tests_py/invariants/test_I2_canonical_writer.py @@ -44,11 +44,11 @@ # PostgreSQL batched writer. ("infrastructure/pg_store_heat.py", 121), # SQLite anchor transfer. - ("infrastructure/sqlite_store.py", 485), + ("infrastructure/sqlite_store.py", 486), # SQLite single-row writer. - ("infrastructure/sqlite_store.py", 512), + ("infrastructure/sqlite_store.py", 513), # SQLite batched writer. - ("infrastructure/sqlite_store.py", 569), + ("infrastructure/sqlite_store.py", 570), # Homeostatic fold. ("handlers/consolidation/homeostatic_apply.py", 184), # Anchor pin. diff --git a/tests_py/scripts/test_reclassify_team_scope.py b/tests_py/scripts/test_reclassify_team_scope.py new file mode 100644 index 000000000..49e7a8011 --- /dev/null +++ b/tests_py/scripts/test_reclassify_team_scope.py @@ -0,0 +1,286 @@ +"""Operator repair invariants. source: ADR-1083""" + +from dataclasses import replace +import json +from pathlib import Path +import sqlite3 +from types import SimpleNamespace + +import pytest + +from scripts.reclassify_team_scope import ( + ScopeMappings, + ScopeRow, + classify, + load_mappings, + make_report, + run, +) +from scripts.reclassify_team_scope_db import ScopeDatabase, verify_backup + + +@pytest.fixture +def row(): + return ScopeRow( + 1, + "DECISION: use project-specific parser", + [], + "project-a", + "/project-a", + "agent-a", + True, + False, + ) + + +@pytest.fixture +def mappings(): + return ScopeMappings({}, {}, frozenset()) + + +def test_project_decision_is_team_only(row, mappings): + change = classify(row, mappings) + assert (change.directory_context, change.is_global, change.is_team_decision) == ( + "/project-a", + False, + True, + ) + + +def test_global_detector_and_explicit_override(row, mappings): + universal = replace( + row, + content="Clean architecture and dependency injection: SOLID composition root", + ) + assert classify(universal, mappings).is_global + assert classify(row, replace(mappings, keep_global_ids=frozenset({1}))).is_global + + +def test_unknown_project_remains_unchanged(row, mappings): + unknown = replace(row, domain="", directory_context="") + change = classify(unknown, mappings) + assert change.is_global and not change.is_team_decision + assert make_report([unknown], mappings)["unresolved_ids"] == [1] + assert make_report([unknown], mappings)["change_count"] == 0 + + +def test_domain_mapping_and_owner_mapping(row, mappings): + unknown = replace(row, directory_context="") + domain = replace(mappings, domains={"project-a": "/verified-a"}) + assert classify(unknown, domain).directory_context == "/verified-a" + owner = replace(domain, memories={"1": "/owner-a"}) + assert classify(unknown, owner).directory_context == "/owner-a" + assert classify(row, owner).directory_context == "/project-a" + + +def test_nondecision_and_existing_marker(row, mappings): + ordinary = replace(row, content="The parser uses XML.") + assert not classify(ordinary, mappings).is_team_decision + assert classify(replace(ordinary, is_team_decision=True), mappings).is_team_decision + assert not classify(replace(row, agent_context=""), mappings).is_team_decision + + +def test_a_global_row_the_defect_could_not_have_produced_stays_global(row, mappings): + """ADR-0200 promoted decisions written under an agent context. A global + row with no agent context, or with non-decision content, was made global + by an explicit act: clearing it would lose the owner's intent (#611).""" + explicit = replace( + row, content="The parser uses XML.", agent_context="", is_global=True + ) + kept = classify(explicit, mappings) + assert kept.is_global + assert kept.reason == "global_origin_not_the_defect" + no_agent = classify(replace(row, agent_context="", is_global=True), mappings) + assert no_agent.is_global + promoted = classify(replace(row, is_global=True), mappings) + assert not promoted.is_global + assert promoted.is_team_decision + + +def test_the_owner_can_clear_a_global_the_script_would_keep(row): + explicit = replace(row, content="The parser uses XML.", is_global=True) + owner = ScopeMappings({}, {}, frozenset(), frozenset({1})) + assert not classify(explicit, owner).is_global + + +def test_an_id_cannot_be_both_kept_and_cleared(tmp_path): + file = tmp_path / "mappings.json" + file.write_text(json.dumps({"keep_global_ids": [7], "clear_global_ids": [7]})) + with pytest.raises(ValueError, match="both kept and cleared"): + load_mappings(file) + + +def test_mapping_validation(tmp_path): + file = tmp_path / "mappings.json" + file.write_text( + json.dumps( + { + "domains": {"a": str(tmp_path)}, + "memories": {"1": str(tmp_path)}, + "keep_global_ids": [2], + } + ) + ) + result = load_mappings(file) + assert result.domains == {"a": str(tmp_path)} + assert result.keep_global_ids == frozenset({2}) + assert load_mappings(None) == ScopeMappings({}, {}, frozenset()) + + +@pytest.mark.parametrize( + "data", + [ + [], + {"typo": {}}, + {"domains": []}, + {"domains": {"": "/a"}}, + {"domains": {"a": 4}}, + {"domains": {"a": "relative"}}, + {"memories": {"bad": "/tmp"}}, + {"keep_global_ids": [True]}, + {"keep_global_ids": "1"}, + ], +) +def test_bad_mappings_fail(tmp_path, data): + path = tmp_path / "bad.json" + path.write_text(json.dumps(data)) + with pytest.raises(ValueError): + load_mappings(path) + + +def seed(path: Path, marker: bool = True): + with sqlite3.connect(path) as db: + db.execute( + "CREATE TABLE memories (id INTEGER PRIMARY KEY, content TEXT, tags TEXT, " + "domain TEXT, directory_context TEXT, agent_context TEXT, " + "is_global INTEGER, is_benchmark INTEGER, superseded_by_id INTEGER" + + (", is_team_decision INTEGER DEFAULT 0" if marker else "") + + ")" + ) + db.execute( + "CREATE VIEW current_memories AS SELECT * FROM memories " + "WHERE superseded_by_id IS NULL" + ) + db.executemany( + "INSERT INTO memories (id,content,tags,domain,directory_context," + "agent_context,is_global,is_benchmark,superseded_by_id) " + "VALUES (?,?,?,?,?,?,?,?,?)", + [ + ( + 1, + "DECISION: use project-specific parser", + "[]", + "a", + "/a", + "agent", + 1, + 0, + None, + ), + (2, "DECISION: unknown project", None, None, None, "agent", 1, 0, None), + (3, "DECISION: history", "[]", "a", "/a", "agent", 1, 0, 1), + (4, "DECISION: benchmark", "[]", "a", "/a", "agent", 1, 1, None), + ], + ) + + +def arguments(path, apply=False): + return SimpleNamespace( + mappings=None, apply=apply, database_url=None, sqlite_path=path, backup=None + ) + + +def test_dry_run_apply_and_second_run_are_idempotent(tmp_path): + path = tmp_path / "scope.db" + seed(path) + before = path.read_bytes() + report = run(arguments(path)) + assert report["candidate_count"] == 2 + assert report["change_count"] == report["clear_global_count"] == 1 + assert not report["applied"] + assert path.read_bytes() == before + assert run(arguments(path, True))["change_count"] == 1 + assert run(arguments(path, True))["change_count"] == 0 + with sqlite3.connect(path) as db: + assert db.execute( + "SELECT id,is_global,is_team_decision FROM memories ORDER BY id" + ).fetchall() == [(1, 0, 1), (2, 1, 0), (3, 1, 0), (4, 1, 0)] + assert db.execute( + "SELECT superseded_by_id FROM memories WHERE id=3" + ).fetchone() == (1,) + + +def test_old_schema_supports_dry_run_but_refuses_apply(tmp_path): + path = tmp_path / "old.db" + seed(path, marker=False) + assert run(arguments(path))["change_count"] == 1 + with pytest.raises(ValueError, match="schema migration"): + run(arguments(path, True)) + + +def test_partial_failure_rolls_back(tmp_path): + path = tmp_path / "scope.db" + seed(path) + with pytest.raises(RuntimeError, match="exactly row"): + with ScopeDatabase(None, path) as db: + db.apply_changes( + [ + { + "id": 1, + "directory_context": "/a", + "is_global": False, + "is_team_decision": True, + }, + { + "id": 99, + "directory_context": "/a", + "is_global": False, + "is_team_decision": True, + }, + ] + ) + with sqlite3.connect(path) as db: + assert db.execute("SELECT is_global FROM memories WHERE id=1").fetchone() == ( + 1, + ) + + +def test_missing_database_and_backup_are_rejected(tmp_path): + with pytest.raises(ValueError, match="existing database"): + ScopeDatabase(None, tmp_path / "missing") + with pytest.raises(ValueError, match="existing --backup"): + verify_backup(None) + backup = tmp_path / "invalid.dump" + backup.write_bytes(b"invalid") + with pytest.raises(ValueError, match="custom-format"): + verify_backup(backup) + + +def test_backup_requires_table_data_and_full_decode(tmp_path, monkeypatch): + archive = tmp_path / "backup.dump" + archive.write_bytes(b"PGDMP") + calls = [] + + def restored(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(stdout="123; 0 456 TABLE DATA public memories owner") + + monkeypatch.setattr("scripts.reclassify_team_scope_db.subprocess.run", restored) + verify_backup(archive) + assert [call[0][1] for call in calls] == ["--list", "--file"] + assert all(call[1]["check"] for call in calls) + monkeypatch.setattr( + "scripts.reclassify_team_scope_db.subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(stdout="SCHEMA public"), + ) + with pytest.raises(ValueError, match="table data"): + verify_backup(archive) + + +def test_noncanonical_mapping_rejected(tmp_path): + child = tmp_path / "child" + child.mkdir() + mapping = tmp_path / "mapping.json" + mapping.write_text(json.dumps({"domains": {"a": str(child / "..")}})) + with pytest.raises(ValueError, match="canonical"): + load_mappings(mapping) diff --git a/tests_py/scripts/test_reclassify_team_scope_pg.py b/tests_py/scripts/test_reclassify_team_scope_pg.py new file mode 100644 index 000000000..c1b14e4d3 --- /dev/null +++ b/tests_py/scripts/test_reclassify_team_scope_pg.py @@ -0,0 +1,93 @@ +"""Real PostgreSQL transactions for scope repair. source: ADR-1083""" + +import os +import uuid + +import pytest + +from scripts.reclassify_team_scope_db import ScopeDatabase + +psycopg = pytest.importorskip("psycopg") +sql = psycopg.sql +make_conninfo = psycopg.conninfo.make_conninfo + + +@pytest.fixture +def scope_pg_url(): + url = os.environ.get("CORTEX_TEST_DATABASE_URL") + if not url: + pytest.skip("requires explicitly configured scratch CORTEX_TEST_DATABASE_URL") + schema = "scope_611_" + uuid.uuid4().hex + with psycopg.connect(url, autocommit=True) as conn: + conn.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + yield make_conninfo(url, options=f"-csearch_path={schema},public") + finally: + conn.execute( + sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)) + ) + + +def seed(url, marker=True): + with psycopg.connect(url) as conn: + marker_sql = ( + ", is_team_decision BOOLEAN NOT NULL DEFAULT FALSE" if marker else "" + ) + conn.execute( + "CREATE TABLE memories (id INTEGER PRIMARY KEY, content TEXT, " + "tags JSONB, domain TEXT, directory_context TEXT, " + "agent_context TEXT, is_global BOOLEAN, is_benchmark BOOLEAN" + + marker_sql + + ")" + ) + conn.execute("CREATE VIEW current_memories AS SELECT * FROM memories") + conn.execute( + "INSERT INTO memories (id, content, tags, domain, " + "directory_context, agent_context, is_global, is_benchmark) " + "VALUES (1, 'DECISION: parser', '[]', 'a', '/a', 'agent', TRUE, FALSE)" + ) + + +def change(identifier=1): + return { + "id": identifier, + "directory_context": "/a", + "is_global": False, + "is_team_decision": True, + } + + +def test_pg_apply_second_run_and_persisted_marker(scope_pg_url): + seed(scope_pg_url) + with ScopeDatabase(scope_pg_url, None) as db: + rows = db.fetch_rows(require_marker=True) + assert len(rows) == 1 + assert rows[0]["tags"] == [] + db.apply_changes([change()]) + with ScopeDatabase(scope_pg_url, None) as db: + assert db.fetch_rows(require_marker=True) == [] + db.apply_changes([]) + with psycopg.connect(scope_pg_url) as conn: + assert conn.execute( + "SELECT is_global,is_team_decision FROM memories" + ).fetchone() == (False, True) + + +def test_pg_later_update_failure_rolls_back(scope_pg_url): + seed(scope_pg_url) + with pytest.raises(RuntimeError, match="exactly row"): + with ScopeDatabase(scope_pg_url, None) as db: + db.fetch_rows(require_marker=True) + db.apply_changes([change(), change(99)]) + with psycopg.connect(scope_pg_url) as conn: + assert conn.execute( + "SELECT is_global,is_team_decision FROM memories" + ).fetchone() == (True, False) + + +def test_pg_old_schema_read_only_and_apply_refusal(scope_pg_url): + seed(scope_pg_url, marker=False) + with ScopeDatabase(scope_pg_url, None) as db: + assert db.fetch_rows(require_marker=False)[0]["is_team_decision"] is False + with pytest.raises(ValueError, match="schema migration"): + db.fetch_rows(require_marker=True) diff --git a/wiki/adr/cortex/1083-separate-project-team-decisions-from-global-knowledge.md b/wiki/adr/cortex/1083-separate-project-team-decisions-from-global-knowledge.md new file mode 100644 index 000000000..762d0e59f --- /dev/null +++ b/wiki/adr/cortex/1083-separate-project-team-decisions-from-global-knowledge.md @@ -0,0 +1,67 @@ +--- +created: 2026-09-17 +kind: adr +number: 1083 +status: proposed +tags: [memory-scoping, team-decisions, issue-611] +title: Separate project team decisions from global knowledge +--- +# ADR-1083: Separate project team decisions from global knowledge + +## Status + +Proposed for owner review in issue #611. Supersedes the team-to-global +mapping in ADR-0200 and the team-decision exception in ADR-1080. + +## Evidence + +Issue #611 records memory 4353879 from japonais-2027 injected into +anthropic-partnership by both session hooks. `team_scope.is_team_decision` +and `global_detector.resolve_global_scope` promote deliberate decisions +under an agent context to `is_global`, which bypasses project isolation. +The intended visibility across agents therefore also crosses projects. + +## Decision + +A team decision is visible to every agent within its project. Persist +`is_team_decision` independently of `is_global` on both backends. Team +visibility relaxes only the agent predicate. Project visibility continues +to require the recorded project root or an ancestor, as in ADR-1080. + +Reserve `is_global` for an explicit global write or a positive result from +the existing content detector. A row may carry both flags when justified +independently. No detector threshold changes are part of this correction. +The Team Decisions query applies project scope before ordering and limit. +Ordinary hook recall treats team decisions as ordinary project memories. + +Reclassify legacy global rows with an idempotent, explicit operator script. +Run a dry-run first and verify a PostgreSQL custom-format backup before +applying to production. Legacy rows do not persist the reason for the +global flag. Operators can preserve IDs known to have been explicitly +global through `keep_global_ids`, and the content detector also preserves a +row. Otherwise `is_global` is cleared only for the rows ADR-0200 promoted: a +decision written under an agent context, which receives the team marker. Any +other global row is kept and reported, because an explicit act the script +cannot see made it global; the owner can clear one by ID through +`clear_global_ids`. Preserve row IDs, history and supersession. + +An empty directory context is never a project wildcard. Resolve it only +from a domain with an unambiguous, verified project-directory mapping, +or an explicit owner-approved memory-ID mapping recorded in the run report. +Leave unresolved rows global and enumerate their IDs for owner review. +Do not derive project ownership by guessing from prose or path suffixes. + +## Verification + +Both backends must exclude a project-A team decision from project B and +include it in project A for another agent. Explicit globals remain visible +in both. A second reclassification run changes no rows. Production signal: +4353879 is absent under anthropic-partnership and present under japonais-2027; +the former's Team Decisions block contains no japonais-2027 rows. + +## Consequences + +Schema migrations add a default-false team flag without rewriting legacy +scope automatically. The separate data operation is inspectable and +reversible from its backup. Unresolved legacy globals remain an explicit +owner-review list. Existing explicit global semantics are preserved. diff --git a/wiki/manifest.json b/wiki/manifest.json index 3e6e150a0..0af9b2c28 100644 --- a/wiki/manifest.json +++ b/wiki/manifest.json @@ -3499,6 +3499,10 @@ "ADR-1082": { "mirror": "ADR-1082-sessionend-writes-its-session-log-before-it-detaches-consolidation.md", "path": "adr/cortex/1082-sessionend-writes-its-session-log-before-it-detaches-consolidation.md" + }, + "ADR-1083": { + "mirror": "ADR-1083-separate-project-team-decisions-from-global-knowledge.md", + "path": "adr/cortex/1083-separate-project-team-decisions-from-global-knowledge.md" } }, "project": "cortex",