Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!-- Generated by Cortex wiki_reindex from wiki/adr/cortex/1083-separate-project-team-decisions-from-global-knowledge.md; ADR-1083. Read-only mirror: edit the canonical wiki page. -->

---
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.
160 changes: 160 additions & 0 deletions docs/validation/issue-611-team-scope.md
Original file line number Diff line number Diff line change
@@ -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": {"<id>": "<project root>"},
"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.
7 changes: 2 additions & 5 deletions mcp_server/core/global_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 10 additions & 4 deletions mcp_server/core/memory_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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"),
}
Expand Down
3 changes: 2 additions & 1 deletion mcp_server/core/team_scope.py
Original file line number Diff line number Diff line change
@@ -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"""

Expand Down
6 changes: 3 additions & 3 deletions mcp_server/handlers/remember.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions mcp_server/handlers/remember_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion mcp_server/hooks/agent_briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Loading