Phase 0 — Research Notes
Mission: private-teamspace-ingress-safeguards-01KQH03Y Date: 2026-05-01
This document records the targeted research decisions taken during Phase 0. Most architecture (rehydrate locality, single-flight, negative cache) was settled in Plan interrogation and lives in decisions/. The remaining unknowns were narrow:
R-01 — Where do the stdout-violating sync diagnostics originate?
Decision: Six print() calls in src/specify_cli/sync/client.py (websocket client) are the only stdout-violating sync diagnostics in the codebase today.
Rationale: A repository-wide grep for Connection failed, skipping final sync, and Could not acquire sync lock produced exactly two source modules:
`` print(f"❌ Token refresh failed: {exc}") print(f"❌ Connection failed: {exc}") print("✅ Connected to sync server") print("❌ WebSocket rejected token. Please re-authenticate.") print(f"❌ Connection failed: HTTP {e.response.status_code}") print(f"❌ Connection failed: {e}") ` These are the source of the ❌ Connection failed: Forbidden: Direct sync ingress must target Private Teamspace.` line that appeared on stdout during this mission's specify and plan phases.
src/specify_cli/sync/client.py— sixprint()calls at lines 141, 146, 178, 184, 186, 193:
src/specify_cli/sync/background.py:179, 182— already usinglogger.debug/logger.warningcorrectly. These lines reach stderr through Python's default logging handler, which is acceptable per FR-009 (stderr or structured logs is the contract; only stdout is forbidden).
Alternatives considered:
- Searching for any sync-side
print()more broadly: confirmed onlyclient.pyviolates.background.py,daemon.py,batch.py,emitter.py,queue.pyalready uselogger.*for diagnostics. - Replacing
print()withrich.printto keep the green/red coloring: rejected becauserich.printstill writes to stdout by default and would re-introduce the same bug. The fix routes these messages throughlogging(stderr) and accepts the loss of console color for sync diagnostics; users who want to see them can set log level via existing env vars.
Implication for the plan: FR-009 is a single-file mechanical fix in sync/client.py (six lines). No daemon-level rework needed.
R-02 — Logging conventions for new diagnostics
Decision: Use a per-module logger = logging.getLogger(__name__) and logger.warning(...) for the structured "direct ingress skipped" diagnostic. Pass structured fields via the extra= keyword so log aggregators can pick them up; serialize the dict explicitly into the message string for human readers as well.
Rationale:
src/specify_cli/sync/background.pyalready useslogger = logging.getLogger(__name__)withlogger.warning(...)andlogger.debug(...). That is the dominant convention in the sync package. Mirroring it keeps log routing predictable.extra={...}is the stdlib's idiomatic way to attach structured fields; existing log aggregators in the codebase consume it.- For NFR-002 ("category, rehydrate_attempted, ingress_sent must be derivable from the line"), embedding the dict in the human-readable portion of the message ensures the fields are also visible when only the message string is displayed.
Alternatives considered:
structlogorloguru: rejected — neither is currently a project dependency; introducing one violates Charter ("no new runtime dependencies for this mission").- A bespoke JSON-line log handler: rejected — overkill for the single new structured-warning shape this mission introduces.
Implication for the plan: New diagnostic emission shape:
log = logging.getLogger(__name__)
log.warning(
"direct ingress skipped: %s",
{
"category": "direct_ingress_missing_private_team",
"rehydrate_attempted": True,
"ingress_sent": False,
"endpoint": "/api/v1/events/batch/",
},
extra={
"category": "direct_ingress_missing_private_team",
"rehydrate_attempted": True,
"ingress_sent": False,
"endpoint": "/api/v1/events/batch/",
},
)
Tests assert both the category string and the dict shape via caplog.
R-03 — /api/v1/me payload shape (already known from existing flows)
Decision: Reuse the existing parse path used by auth/flows/authorization_code.py:245+ and auth/flows/device_code.py:252+. Both already construct a Team list from me["teams"] and read is_private_teamspace, id, name, slug, etc. The new me_fetch.fetch_me_payload returns the raw dict; the team list construction is delegated to a small Team.from_dict helper that already exists in auth/session.py:54.
Rationale: No research needed; the contract has been stable since the auth flows were introduced. We are simply lifting one HTTP call out of two flow modules into a shared helper. No schema research required.
Implication for the plan: me_fetch.py is roughly:
async def fetch_me_payload(transport: HTTPTransport, access_token: str) -> dict:
response = await transport.get(
"/api/v1/me",
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return response.json()
Followed by [Team.from_dict(t) for t in payload["teams"]] in the caller.
R-04 — Concurrent rehydrate semantics (decision recorded, not researched)
Settled in Plan interrogation. See decisions/DM-01KQH1YZGKKMJPJ7DGKKSKJ7XS.md. Both: asyncio.Lock in TokenManager for single-flight, plus a process-lifetime negative cache invalidated by session-identity change, fresh login, or force=True.
R-05 — Test fixture availability
Decision: Existing fixtures in tests/auth/conftest.py and tests/sync/conftest.py provide enough scaffolding. Specifically:
tests/auth/conftest.pyalready has factories forStoredSession/Teamand a mockedSecureStorage.tests/sync/conftest.pyalready has fixtures that build aTokenManager+ transport pair.respx(already a project test dep) handles/api/v1/me,/api/v1/events/batch/, and/api/v1/ws-tokenmock routing.
No fixture refactor required.
Alternatives considered: introducing a new top-level tests/conftest.py fixture for a "shared-only session" — rejected as redundant; per-test factory parameterization is sufficient.
Out of Scope for Research
The following items were explicitly not researched because they belong to the SaaS-side companion change or future missions:
- The exact field name on
/api/v1/methat signals "newer membership generation" (R-06 was deferred — for this mission, when localis_private_teamspaceis missing we always probe; we do not depend on a server-side hint). - Behavior changes to non-ingress shared-team UI/tracker reads (out of scope per spec C-001, C-002).
- The SaaS-side prevention of shared-only authenticated sessions (out of scope per spec; tracked in
Priivacy-ai/spec-kitty-saas#142).