epic-1/story-3: Create and Reuse Visitor Contexts (gap-fill) - #28
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a typed, immutable ContextState to manage per-visitor state separately from the shared configuration snapshot, and standardizes the API by renaming attributes to visitor_attributes across creation boundaries. The review feedback highlights a critical vulnerability in the defensive copying mechanism where wrapping a mutable dictionary in MappingProxyType can still allow mutations to leak, suggesting that the mapping should always be copied. Additionally, the feedback recommends resolving a type mismatch when initializing ContextState with an optional mapping, and adding corresponding test coverage for the defensive copying of proxy types.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if not isinstance(self.visitor_attributes, MappingProxyType): | ||
| object.__setattr__( | ||
| self, | ||
| "visitor_attributes", | ||
| MappingProxyType(dict(self.visitor_attributes or {})), | ||
| ) |
There was a problem hiding this comment.
The current implementation of defensive copying checks isinstance(self.visitor_attributes, MappingProxyType) to skip copying if the attributes are already wrapped in a MappingProxyType. However, a MappingProxyType is only a read-only view of an underlying dictionary. If the caller passes a MappingProxyType that wraps a mutable dictionary and subsequently mutates that dictionary, those mutations will leak into the context state, violating the defensive copying guarantee. Always copying the mapping ensures complete isolation.
object.__setattr__(
self,
"visitor_attributes",
MappingProxyType(dict(self.visitor_attributes or {})),
)| self._state = ContextState( | ||
| visitor_id=visitor_id, | ||
| snapshot=snapshot, | ||
| visitor_attributes=visitor_attributes, | ||
| ) |
There was a problem hiding this comment.
In Context.__init__, visitor_attributes is typed as Optional[Mapping[str, Any]] = None. Passing it directly to ContextState when it is None violates the type annotation of ContextState.visitor_attributes, which is typed as Mapping[str, Any] (not optional). To ensure type safety and avoid static analysis warnings, default to an empty dictionary visitor_attributes or {} when instantiating ContextState.
| self._state = ContextState( | |
| visitor_id=visitor_id, | |
| snapshot=snapshot, | |
| visitor_attributes=visitor_attributes, | |
| ) | |
| self._state = ContextState( | |
| visitor_id=visitor_id, | |
| snapshot=snapshot, | |
| visitor_attributes=visitor_attributes or {}, | |
| ) |
| def test_visitor_attributes_are_copied_defensively(): | ||
| source = {"country": "US"} | ||
| state = ContextState(visitor_id="v", visitor_attributes=source, snapshot=_snapshot()) | ||
| source["country"] = "DE" # mutate the caller's dict after construction | ||
| assert dict(state.visitor_attributes) == {"country": "US"} | ||
|
|
There was a problem hiding this comment.
Add a test case to verify that passing a MappingProxyType is also defensively copied and does not allow mutations to the underlying dictionary to leak into the ContextState.
| def test_visitor_attributes_are_copied_defensively(): | |
| source = {"country": "US"} | |
| state = ContextState(visitor_id="v", visitor_attributes=source, snapshot=_snapshot()) | |
| source["country"] = "DE" # mutate the caller's dict after construction | |
| assert dict(state.visitor_attributes) == {"country": "US"} | |
| def test_visitor_attributes_are_copied_defensively(): | |
| source = {"country": "US"} | |
| state = ContextState(visitor_id="v", visitor_attributes=source, snapshot=_snapshot()) | |
| source["country"] = "DE" # mutate the caller's dict after construction | |
| assert dict(state.visitor_attributes) == {"country": "US"} | |
| def test_visitor_attributes_proxy_is_copied_defensively(): | |
| from types import MappingProxyType | |
| source = {"country": "US"} | |
| proxy = MappingProxyType(source) | |
| state = ContextState(visitor_id="v", visitor_attributes=proxy, snapshot=_snapshot()) | |
| source["country"] = "DE" | |
| assert dict(state.visitor_attributes) == {"country": "US"} |
Beads: ai-driven-product-dev-vo1o Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… implementation (GREEN) Beads: ai-driven-product-dev-vo1o Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(RED) Beads: ai-driven-product-dev-5zfa Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… implementation (GREEN) Beads: ai-driven-product-dev-5zfa Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-nd5o Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ate attr access) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
c63f8f6 to
0cf59fe
Compare
9ec000b to
3962b4e
Compare
|
Superseded — all commits already in main (bc76b64). Closing without merge as part of post-sprint cleanup. |
Summary
Gap-fill story closing the three gaps identified by the 2026-06-07 sprint gap check — ~80% of the Context surface already shipped via story 1-4's minimal foundation (PR #26); this PR completes Story 1.3's contract on the current stacked lineage:
Core.create_context(visitor_id, visitor_attributes=None)(wasattributes=; reconciled-PRD frozen signature — drift would have propagated through epic-2+).Contextgains avisitor_attributesaccessor;attributeskept as back-compat alias. README updated; story 1-6 examples needed no change (no-attr calls). Run-time request-overlayattributes=onrun_*left unchanged (separate call-time concern).src/convert_sdk/domain/context_state.py— frozenContextStatedataclass (visitor_id + defensively-copied read-only visitor_attributes + snapshot-by-reference) with non-mutatingwith_overlay();Contextnow composes it, keeping visitor state separate from the shared immutableConfigSnapshot.tests/test_context_creation.py— 12 dedicated AC#1/AC#2 tests (creation, stored/read-only/defensive-copy attrs, init-required, reuse across evaluations, no Core-managed cache, overlay non-mutation, snapshot linkage).Tests: 193 → 218 passing (+8 context_state, +5 contract-freeze, +12 context_creation). No regressions.
Traceability
sprint/2026-04-06-convert-python-sdk— stacked on epic-1/story-6: Deliver Quickstart and First-Run Examples #27 (story 1-6), which stacks on epic-1/story-4: Run Local Experience Evaluations #26 (1-4) → epic-1/story-2: Support sdkKey and Direct-Config Initialization #25 (1-2) → Epic 1 Story 1: Scaffold the publishable SDK foundation #24 (1-1)ai-driven-product-dev-0h1b; tasks-vo1o(ContextState),-5zfa(rename),-nd5o(tests) — all closed_bmad-output/implementation-artifacts/2026-04-06-convert-python-sdk/1-3-create-and-reuse-visitor-contexts.md(reopened per gap check; originaldonereferred to superseded PR 17 lineage)Notes for reviewer
attributesback-compat alias). See conductor's assessment atai-driven-product-dev/work/2026-06-07-create-and-reuse-visitor-contexts/readiness-assessment.md._snapshot). Two non-blocking notes logged (stale module docstring; benign_snapshot/_state.snapshotduplication).🤖 Generated with Claude Code