Skip to content

feat: add durable session outcome tracking - #388

Merged
yyiilluu merged 2 commits into
mainfrom
codex/session-outcomes
Jul 28, 2026
Merged

feat: add durable session outcome tracking#388
yyiilluu merged 2 commits into
mainfrom
codex/session-outcomes

Conversation

@yyiilluu

@yyiilluu yyiilluu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add caller-authored success/failure outcomes for published sessions without coupling outcomes to profile or playbook learning
  • derive user and source from the canonical earliest request and enforce lifetime first-write-wins semantics
  • preserve outcomes across transcript deletion while including them in subject governance erasure
  • expose typed Python client write/read methods with bounded JSON metadata and exact optional filters

Changes

  • add public outcome request, response, enum, client, and HTTP contracts
  • rate-limit session-outcome reads at 60 requests per minute, matching the other interactions POST endpoints
  • add SQLite persistence plus the shared storage interface used by enterprise backends
  • serialize request insertion, deletion, and outcome recording by session to keep canonical identity deterministic
  • sanitize caller-controlled warning fields and return stable 422 responses for nested non-finite JSON numbers
  • document the endpoints in the OSS method registry and server code maps

Test Plan

  • uv run pytest tests/client/test_session_outcomes_client.py tests/server/api_endpoints/test_session_outcomes_integration.py tests/server/services/storage/test_storage_contract_session_outcomes.py tests/server/test_app_route_inventory.py -q -o 'addopts='
  • uv run pytest -q --no-cov tests/server/api_endpoints/test_session_outcomes_integration.py (9 passed)
  • verified SlowAPI registers reflexio.server.routes.interactions.get_session_outcomes as 60 per 1 minute
  • uv run ruff check and uv run ruff format --check over all changed Python files
  • uv run pyright over all changed Python files
  • npx biome check lib/methods/requests-sessions.ts
  • npx tsc --noEmit in docs/
  • executed the public Python client notebook against a fresh local SQLite backend; publish, mark, filtered read, duplicate write, transcript deletion, retry, and governance cleanup all passed

Summary by CodeRabbit

  • New Features
    • Added APIs to record session outcomes and to query recorded session outcomes with filters and pagination.
    • Added client and server support for the new session-outcome endpoints.
    • Integrated session outcomes into user-data deletion and governance purge workflows.
  • Bug Fixes
    • Improved request validation error handling so non-finite numeric values no longer break 422 responses.
    • Added safeguards to prevent unsafe/invalid session outcome data from being persisted.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Session outcome recording and querying are added across schemas, client APIs, service facades, FastAPI routes, SQLite storage, and governance erasure. Validation covers timing, metadata, pagination, duplicate context, acceptance hooks, and non-finite values.

Changes

Session outcomes

Layer / File(s) Summary
Session outcome contracts
reflexio/models/api_schema/domain/*, reflexio/server/services/storage/storage_base/_session_outcomes.py, docs/lib/methods/requests-sessions.ts
Adds outcome enums, request/response models, storage contracts, metadata and query validation, and documented API method definitions.
SQLite persistence and governance
reflexio/server/services/storage/*, reflexio/server/services/storage/sqlite_storage/*, tests/server/services/storage/*
Adds the session_outcomes table, transactional writes and reads, context checks, user cleanup, governance counts, purge targets, subject barriers, and storage contract coverage.
Service, HTTP, and client integration
reflexio/lib/*, reflexio/client/client.py, reflexio/server/routes/interactions.py, reflexio/server/api_endpoints/publisher_api.py, tests/client/*, tests/server/api_endpoints/*
Exposes recording and querying through the service facade, FastAPI routes, publisher wrappers, and typed client methods, with integration tests for API behavior.
Validation and documentation support
reflexio/server/api.py, reflexio/server/README.md, reflexio/server/api_endpoints/README.md
Adds safe validation-error handling for non-finite values and updates endpoint documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant FastAPI
  participant publisher_api
  participant Reflexio
  participant SQLiteStorage
  Client->>FastAPI: POST outcome or outcome query
  FastAPI->>publisher_api: Scope payload to organization
  publisher_api->>Reflexio: Invoke session outcome operation
  Reflexio->>SQLiteStorage: Validate and persist or query
  SQLiteStorage-->>Reflexio: Return result
  Reflexio-->>Client: Return response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: durable session outcome tracking was added.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/session-outcomes

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
reflexio/server/api.py (1)

139-160: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Non-finite detection only inspects input, not ctx.

_contains_non_finite_number is only applied to error.get("input"). If a validation error's non-finite value only surfaces via ctx (Pydantic's context dict for some error types) rather than input, detection would miss it and the request would fall through to the default handler, which still serializes the raw ctx. In practice ctx values are typically schema-defined constants rather than user input, so risk is low, but worth a quick sanity check given this handler is global (applies to every route, not just session outcomes).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/api.py` around lines 139 - 160, The validation error scan in
_safe_request_validation_exception_handler only checks each error’s input and
can miss non-finite values nested in ctx. Extend the detection to inspect both
error.get("input") and error.get("ctx"), while preserving the existing
default-handler path when neither contains a non-finite number.
reflexio/lib/_session_outcome.py (1)

53-133: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Broad except Exception collapses all failures — including acceptance-provider bugs — into STORAGE_ERROR.

If provider(...) (an externally-registered SESSION_OUTCOME_ACCEPTANCE hook) raises due to a bug rather than a real storage failure, the caller and logs both report STORAGE_ERROR, obscuring the true failure source during incident response. Consider catching storage-specific exceptions around the storage calls only, or otherwise annotating the log/response with which stage failed.

♻️ Possible refinement
-        try:
-            for _attempt in range(3):
-                context = storage.get_session_outcome_context(request.session_id)
+        try:
+            for _attempt in range(3):
+                try:
+                    context = storage.get_session_outcome_context(request.session_id)
+                except Exception:
+                    logger.exception("Storage error while fetching session outcome context")
+                    raise
                 ...
                 provider = get_service(SESSION_OUTCOME_ACCEPTANCE)
                 if provider is not None:
-                    reason = provider(
-                        self.org_id, request, received_at, context.user_id
-                    )
+                    try:
+                        reason = provider(
+                            self.org_id, request, received_at, context.user_id
+                        )
+                    except Exception:
+                        logger.exception("Session outcome acceptance provider raised")
+                        raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/lib/_session_outcome.py` around lines 53 - 133, Limit the broad
exception handling in the session outcome flow around the storage operations in
the method containing the shown retry loop, so exceptions from the externally
registered SESSION_OUTCOME_ACCEPTANCE provider are not converted to
STORAGE_ERROR. Catch and log storage-specific failures only, while allowing
provider exceptions to propagate or be reported distinctly; preserve the
existing STORAGE_ERROR response for actual storage failures.
reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py (1)

271-276: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Undocumented asymmetry: session_outcomes is the only count conditionally omitted.

Every other key in this dict (interactions, requests, etc.) is always included, even when zero. session_outcomes is only included when rowcount is truthy. It's harmless today because apply_governance_user_data_delete reads it via counts.get(key, 0), but the asymmetry is easy to "fix" during a future refactor in a way that would break the exact-dict-equality assertion in test_apply_governance_user_data_delete_retains_lineage_skeleton. A short comment explaining why this key is special would prevent that regression.

📝 Suggested comment
         return {
+            # Omitted when zero (unlike other keys) to preserve the pre-existing
+            # exact-dict-equality assertions in governance storage tests that
+            # predate session outcomes; `counts.get(key, 0)` in
+            # apply_governance_user_data_delete tolerates the omission.
             **(
                 {"session_outcomes": session_outcomes_cur.rowcount}
                 if session_outcomes_cur.rowcount
                 else {}
             ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py`
around lines 271 - 276, Add a concise comment immediately above the conditional
session_outcomes entry in the return dict, documenting that its omission when
rowcount is zero is intentional and must remain compatible with
apply_governance_user_data_delete and its exact-dict equality expectations. Do
not change the existing counting behavior.
tests/server/services/storage/test_storage_contract_session_outcomes.py (1)

12-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for get_session_outcome_context's own existing flag.

The duplicate-rejection assertions here (test_first_write_preserves_outcome_fields) reuse a context captured before the first write, so they only exercise record_session_outcome's internal existing-row check — not whether get_session_outcome_context() itself reports existing=True after a successful write. That flag is what mark_session_outcome (lib layer) uses to short-circuit before calling record_session_outcome at all, so it's a distinct, currently-untested code path in this contract suite.

Consider adding a case that calls get_session_outcome_context(session_id) again after a successful record_session_outcome and asserts existing is True.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_storage_contract_session_outcomes.py`
around lines 12 - 63, Extend test_first_write_preserves_outcome_fields by
calling get_session_outcome_context("s1") after the successful first
record_session_outcome call and assert that the returned context has existing
set to True, covering the post-write context behavior separately from duplicate
rejection.
reflexio/server/services/storage/sqlite_storage/_base.py (1)

1996-2014: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the one-outcome-per-session invariant at the DB layer.

The PK is (user_id, session_id), but every application check (get_session_outcome_context/record_session_outcome in _session_outcomes.py) looks up and enforces "first write wins" purely by session_id, never by the (user_id, session_id) pair. The composite PK doesn't itself block two rows for the same session_id with different user_id — that's currently prevented only by the BEGIN IMMEDIATE check-then-insert transaction. Since this table is new, making session_id the PK (or adding UNIQUE(session_id)) would make the intended invariant DB-enforced too, at no migration cost.

♻️ Proposed schema tweak
 CREATE TABLE IF NOT EXISTS session_outcomes (
     user_id TEXT NOT NULL,
-    session_id TEXT NOT NULL,
+    session_id TEXT NOT NULL PRIMARY KEY,
     outcome TEXT NOT NULL CHECK (outcome IN ('success', 'failure')),
     occurred_at INTEGER NOT NULL,
     source TEXT NOT NULL,
     label TEXT,
     value REAL,
     metadata TEXT,
     governance_subject_ref TEXT NOT NULL,
-    created_at INTEGER NOT NULL,
-    PRIMARY KEY (user_id, session_id)
+    created_at INTEGER NOT NULL
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_base.py` around lines 1996 -
2014, Update the session_outcomes schema definition so session_id is uniquely
constrained independently of user_id, replacing the composite PRIMARY KEY or
adding a UNIQUE constraint on session_id. Preserve the existing columns and
indexes while ensuring the database enforces one outcome per session across all
users.
reflexio/server/services/storage/sqlite_storage/_session_outcomes.py (1)

66-153: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard record_session_outcome() before starting a transaction

record_session_outcome() unconditionally issues BEGIN IMMEDIATE, so it will raise cannot start a transaction within a transaction if it’s ever called from an existing transaction. Mirror the _own_transaction() guard used in _requests.py so this write path composes safely with commit_scope().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_session_outcomes.py` around
lines 66 - 153, Update record_session_outcome() to use the same
_own_transaction() guard pattern as _requests.py before issuing BEGIN IMMEDIATE,
so it joins existing commit_scope() transactions instead of starting a nested
transaction. Preserve the current commit/rollback behavior for transactions it
owns, while avoiding rollback or commit of caller-owned transactions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@reflexio/server/routes/interactions.py`:
- Around line 76-86: Add the established `@limiter.limit`(...) decorator to the
get_session_outcomes route, matching the rate-limit pattern and limit used by
the other interactions POST endpoints. Keep the existing response model,
dependency, and publisher_api.get_session_outcomes call unchanged.

---

Nitpick comments:
In `@reflexio/lib/_session_outcome.py`:
- Around line 53-133: Limit the broad exception handling in the session outcome
flow around the storage operations in the method containing the shown retry
loop, so exceptions from the externally registered SESSION_OUTCOME_ACCEPTANCE
provider are not converted to STORAGE_ERROR. Catch and log storage-specific
failures only, while allowing provider exceptions to propagate or be reported
distinctly; preserve the existing STORAGE_ERROR response for actual storage
failures.

In `@reflexio/server/api.py`:
- Around line 139-160: The validation error scan in
_safe_request_validation_exception_handler only checks each error’s input and
can miss non-finite values nested in ctx. Extend the detection to inspect both
error.get("input") and error.get("ctx"), while preserving the existing
default-handler path when neither contains a non-finite number.

In `@reflexio/server/services/storage/sqlite_storage/_base.py`:
- Around line 1996-2014: Update the session_outcomes schema definition so
session_id is uniquely constrained independently of user_id, replacing the
composite PRIMARY KEY or adding a UNIQUE constraint on session_id. Preserve the
existing columns and indexes while ensuring the database enforces one outcome
per session across all users.

In `@reflexio/server/services/storage/sqlite_storage/_session_outcomes.py`:
- Around line 66-153: Update record_session_outcome() to use the same
_own_transaction() guard pattern as _requests.py before issuing BEGIN IMMEDIATE,
so it joins existing commit_scope() transactions instead of starting a nested
transaction. Preserve the current commit/rollback behavior for transactions it
owns, while avoiding rollback or commit of caller-owned transactions.

In
`@reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py`:
- Around line 271-276: Add a concise comment immediately above the conditional
session_outcomes entry in the return dict, documenting that its omission when
rowcount is zero is intentional and must remain compatible with
apply_governance_user_data_delete and its exact-dict equality expectations. Do
not change the existing counting behavior.

In `@tests/server/services/storage/test_storage_contract_session_outcomes.py`:
- Around line 12-63: Extend test_first_write_preserves_outcome_fields by calling
get_session_outcome_context("s1") after the successful first
record_session_outcome call and assert that the returned context has existing
set to True, covering the post-write context behavior separately from duplicate
rejection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f34e6836-4e86-4521-94a0-e324c60bf07d

📥 Commits

Reviewing files that changed from the base of the PR and between cec5adf and 991e567.

📒 Files selected for processing (26)
  • docs/lib/methods/requests-sessions.ts
  • reflexio/client/client.py
  • reflexio/lib/_session_outcome.py
  • reflexio/lib/reflexio_lib.py
  • reflexio/models/api_schema/domain/entities.py
  • reflexio/models/api_schema/domain/enums.py
  • reflexio/server/README.md
  • reflexio/server/api.py
  • reflexio/server/api_endpoints/README.md
  • reflexio/server/api_endpoints/publisher_api.py
  • reflexio/server/routes/interactions.py
  • reflexio/server/services/storage/governance_validation.py
  • reflexio/server/services/storage/sqlite_storage/__init__.py
  • reflexio/server/services/storage/sqlite_storage/_base.py
  • reflexio/server/services/storage/sqlite_storage/_governance.py
  • reflexio/server/services/storage/sqlite_storage/_requests.py
  • reflexio/server/services/storage/sqlite_storage/_session_outcomes.py
  • reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py
  • reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py
  • reflexio/server/services/storage/storage_base/__init__.py
  • reflexio/server/services/storage/storage_base/_session_outcomes.py
  • tests/client/test_session_outcomes_client.py
  • tests/server/api_endpoints/test_session_outcomes_integration.py
  • tests/server/services/storage/sqlite_storage/test_governance_storage.py
  • tests/server/services/storage/test_storage_contract_session_outcomes.py
  • tests/server/test_app_route_inventory.py

Comment thread reflexio/server/routes/interactions.py
@yyiilluu
yyiilluu merged commit 57b6e73 into main Jul 28, 2026
1 check passed
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.

1 participant