Skip to content

fix(remember): propagate decisions written under an agent context to team scope (#561) - #565

Merged
cdeust merged 3 commits into
mainfrom
fix/561-decision-team-scope
Sep 15, 2026
Merged

cdeust merged 3 commits into
mainfrom
fix/561-decision-team-scope

Conversation

@cdeust

@cdeust cdeust commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

ADR-0200's Transactive Memory rule (a decision written under an agent context is marked is_global) lived only in core/memory_ingest.py, which only benchmarks and tests call. The live remember path never applied it, so the SessionStart "Team Decisions" block and the subagent briefing, which both select is_protected AND is_global AND agent_context <> '' (hooks/session_start.py:207-239), found almost nothing. Measured on the live store on 2026-09-15: 233 current protected agent-scoped rows were local, 1 was global.

Closes #561

What changed

  • core/team_scope.py (new, pure): propagates_to_team(is_decision, agent_context) is the ADR-0200 predicate; is_team_decision(content, origin, write_class, agent_context) counts a decision cue only on a deliberate write (write_class = deliberate) from an origin allowed a content-derived privilege (capture_origin.may_bypass_write_gate_on_content: deliberate, local_action). Without the origin guard, a fetched page carrying a decision cue and written under an agent topic would be injected into every project's SessionStart; without the class guard, a connection-rooted server (which tags every write, captures included, with its agent topic) would promote tool output to team decisions. Both are pinned by tests. core/memory_ingest.py now calls propagates_to_team instead of an inline copy (behaviour unchanged there).
  • core/global_detector.py: resolve_global_scope(content, tags, *, explicit, team_decision) orders the three sources: explicit request, then team decision, then the existing content detector.
  • handlers/remember.py: is_global goes through resolve_global_scope(..., team_decision=is_team_decision(...)).
  • infrastructure/team_scope_backfill.py: a one-shot, idempotent backfill for rows written before the fix. PostgreSQL runs it from get_all_ddl() right after MIGRATIONS_DDL (same pattern as the ADR-0537 is_benchmark backfill); SQLite runs it at store init after the column migrations. Same origin and class rules as the write path. Anchored rows (_anchor, explicit act with its own is_global argument), benchmark rows, superseded rows, and legacy/unknown origins keep their scope.
  • hooks/session_start.py: the Team Decisions docstring names the real write-time mechanism.
  • tests_py/handlers/_remember_bulk_fakes.py: the frozen W3-4 reference body of remember still calls detect_global, which the live module no longer imports; the loader supplies it explicitly so the oracle runs unchanged.
  • tests_py/invariants/test_I2_canonical_writer.py: the three SQLite heat_base writer lines moved by 4 (import + backfill call); list updated, no new writer.

Migration decision for existing rows

The backfill uses is_protected as the stored trace of the decision cue: on the write path is_protected is set from the same is_decision_content the rule reads. On the live store the PostgreSQL statement matches 131 rows (all capture_origin = 'deliberate', write_class = 'deliberate'); 97 legacy and 5 unknown rows stay local, since their origin cannot be shown to be trusted.

Test plan

Gate stated before the change: the three new test files fail on the old code and pass on the new.

  • Before (fix set aside, SQLite): 2 failed, 3 passed, 1 skipped for the handler and backfill files; the core test file fails at import (ImportError: cannot import name 'propagates_to_team').
  • After, SQLite (CORTEX_MEMORY_STORE_BACKEND=sqlite, the three new files plus test_remember.py): 49 passed, 1 skipped (the PG-only backfill test skips).
  • After, PostgreSQL (CORTEX_TEST_DATABASE_URL=postgresql://127.0.0.1:5432/cortex_test, the three new files): 21 passed.
  • Full suite on PostgreSQL, final state (afc89e5): 8585 passed, 8 skipped, 459 subtests passed. An earlier full run had failed test_I2_canonical_writer (the SQLite writer lines it pins had shifted); the list is updated in this PR.
  • ruff check, ruff format --check (ruff 0.16.6 as in requirements/lint.txt), python scripts/check_craftsmanship.py OK, pyright in the CONTRIBUTING.md environment: 0 errors, 0 warnings, 0 informations. check_doc_claims.py, check_project_wiki.py, check_no_deps_invariant.py, generate_repo_badges.py --check OK.

Failure modes reasoned about

  • Injection amplification. A global row reaches every project's recall and the SessionStart block. The propagation is refused for network and unknown origins and for non-deliberate writes, on both the write path and the backfill; test_network_origin_decision_never_propagates and test_auto_capture_decision_never_propagates pin it. The SQL literal duplicating the origin allow-list is pinned against capture_origin._ORIGINS_ALLOWED_CONTENT_BYPASS by test_sql_origin_list_matches_the_write_path (infrastructure cannot import core).
  • Explicit is_global=False. Indistinguishable from the default in _parse_args (the argument defaults to False), exactly as in memory_ingest. A caller who needs a decision kept local omits agent_topic.
  • Concurrency. The backfill is a single UPDATE in the schema transaction; re-running it matches no row (idempotence measured below).
  • Scale. Write path: one extra regex (is_decision_content, already run on the insert path) per remember, O(len(content)). Backfill: one sequential scan. PostgreSQL runs it only when the DDL is (re)applied; on the live store EXPLAIN shows a single seq scan on memories. SQLite runs it at every store init: measured 3.0 ms on 50 000 rows, then 3.4 ms and 0 rows updated on the second run (in-memory database, this machine, 2026-09-15).

Coding-standards compliance

  • Layer direction preserved: core gains two pure functions; the SQL lives in infrastructure; the handler composes.
  • New functions: ≤ 4 parameters, ≤ 40 lines, every constant sourced (ADR-0200).
  • No new method-size debt; the rule lives in a 33-line core module. remember.py and sqlite_store.py were already over the 300-line cap (baselined) and grow by 4 lines each (380 → 384, the call; 908 → 912, import and backfill call); global_detector.py stays under the cap (279 lines).

🤖 Generated with Claude Code

@cdeust

cdeust commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Reviewed at afc89e5 against #561 and the loop's standard.

  • Root cause fixed where the invariant breaks: the ADR-0200 rule now lives in one pure core module (core/team_scope.py). The live remember path (via resolve_global_scope) and memory_ingest both use it, so there is no second copy left to drift.
  • Hostile objections answered in the diff:
    • Injection amplification is refused for network and unknown origins and for non-deliberate writes, on both the write path and the backfill. Four tests pin it: two in the handler (network origin, auto capture) and two in the core unit tests.
    • The SQL copy of the origin allow-list is pinned against capture_origin._ORIGINS_ALLOWED_CONTENT_BYPASS.
    • The backfill is idempotent (measured: second run updates 0 rows) and runs one sequential scan (measured: 3.0 ms on 50 000 SQLite rows).
  • Evidence re-run on the final state:
    • Full PostgreSQL suite: 8585 passed.
    • New tests on both backends (21 on PostgreSQL; 49 passed, 1 skipped on SQLite).
    • pyright: 0 diagnostics. The craftsmanship gate passes.
    • CI green on all 24 required checks.
  • Behaviour change the owner will see after install: 131 existing deliberate decisions with an agent context become global. They then appear in Team Decisions and in cross-project recall, as ADR-0200 specifies. 97 legacy-origin and 5 unknown-origin rows stay local.

cdeust and others added 3 commits September 15, 2026 04:06
…team scope (#561)

ADR-0200's Transactive Memory rule (a decision written under an agent
context is marked is_global) lived only in core/memory_ingest.py, which
only benchmarks and tests call. The live remember path never applied it,
so the SessionStart "Team Decisions" block and the subagent briefing,
which both require is_protected AND is_global AND agent_context, found
almost nothing: on the live store, 233 current protected agent-scoped
rows were local and 1 was global (2026-09-15).

- core/global_detector: propagates_to_team (the ADR-0200 predicate) and
  resolve_global_scope (explicit > team decision > content detector).
  memory_ingest uses the same predicate instead of an inline copy.
- handlers/remember: resolves is_global through resolve_global_scope. The
  decision cue only counts for origins allowed a content-derived
  privilege (capture_origin: deliberate, local_action), so a fetched page
  carrying a decision cue cannot inject itself into every project.
- infrastructure/team_scope_backfill: one-shot, idempotent backfill of
  rows written before the fix, applied by the PG DDL list and at SQLite
  store init. Same origin rule; anchored, benchmark, superseded, legacy
  and unknown-origin rows keep their scope.
- hooks/session_start: the Team Decisions docstring now names the real
  write-time mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Unattended captures (write_class auto) and connection-rooted scopes tag
every write with an agent topic; a decision cue in tool output is not a
considered decision. The write path and the backfill now require the
deliberate class, alongside the trusted-origin rule.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The attested-decision predicate (deliberate class, trusted origin,
decision cue, agent context) is business logic: it moves out of the
handler into a pure core module next to propagates_to_team, which keeps
remember.py from growing further past its baselined size and gives the
rule direct unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@cdeust
cdeust force-pushed the fix/561-decision-team-scope branch from afc89e5 to 58c92e5 Compare September 15, 2026 02:06
@cdeust

cdeust commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Re-verified at 58c92e5, the same three commits rebased onto 1d5a534 (#566, docs only, no file in common). The new tests, tests_py/invariants and the bulk-oracle tests pass on the rebased state: 144 passed on PostgreSQL. The craftsmanship gate passes. CI is green on the rebased head: 24 passed, 4 skipped.

@cdeust
cdeust merged commit e382a6e into main Sep 15, 2026
28 checks passed
@cdeust
cdeust deleted the fix/561-decision-team-scope branch September 15, 2026 02:17
@cdeust cdeust mentioned this pull request Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] remember never propagates decisions to is_global, so Team Decisions stays empty (ADR-0200)

1 participant