Data Model — CLI Private Teamspace Ingress Safeguards

Mission: private-teamspace-ingress-safeguards-01KQH03Y

This mission does not introduce new persistent data shapes. It depends on existing in-memory and on-disk types from src/specify_cli/auth/session.py and adds one piece of in-memory state to TokenManager.


Existing types (referenced, not modified)

Team (src/specify_cli/auth/session.py)

FieldTypeNotes
idstrTeam identifier — used as X-Team-Slug for batch ingress and as ws-token request body
namestrDisplay name
slugstrURL-safe display slug
is_private_teamspaceboolAuthoritative field for direct-ingress eligibility

Team.from_dict(data) (existing) parses the SaaS /api/v1/me teams[] entries.

StoredSession (src/specify_cli/auth/session.py)

FieldTypeUsed by this mission
emailstrNegative-cache invalidation key (when this changes between set_session calls, cache is cleared)
teamslist[Team]Read by require_private_team_id; rewritten by rehydrate path
default_team_idstrRead for backward-compatible login/UI default; never used by the strict resolver
access_token, refresh_token, etc.(existing)Preserved verbatim across rehydrate (rehydrate never touches tokens)

New in-memory state

TokenManager._membership_negative_cache (src/specify_cli/auth/token_manager.py)

FieldTypeLifecycle
_membership_negative_cacheboolFalse at construction. Set to True when rehydrate_membership_if_needed completes a /api/v1/me GET that returns no Private Teamspace. Cleared back to False when (a) set_session(new) is called and new.email != prior.email, (b) login flows finish and explicitly call set_session, or (c) any caller invokes rehydrate_membership_if_needed(force=True) and the GET succeeds with a Private Teamspace.

The cache is process-scoped, in-memory only. It does not persist across CLI invocations. A user who fixes their session on the SaaS side will have it picked up on the next spec-kitty command without manual intervention.


Rehydrate outcome (function return shape)

TokenManager.rehydrate_membership_if_needed(*, force: bool = False) -> bool:

ReturnMeaningSide effects
TrueStored session now contains a Private TeamspaceStoredSession.teams and StoredSession.default_team_id may have been updated and persisted via set_session. Negative cache is cleared.
FalseStored session still has no Private Teamspace, or rehydrate could not runNegative cache may have been set to True (when GET succeeded but returned no private team). On HTTP/parse failure, cache is not flipped — transient errors do not poison the next command.

Diagnostic line shape (NFR-002 contract)

Every "direct ingress skipped" log line carries these fields:

FieldTypeAllowed values
categorystr"direct_ingress_missing_private_team" (only value introduced by this mission)
rehydrate_attemptedboolFalse if the call site skipped rehydrate (e.g. session is None); True if rehydrate ran
ingress_sentboolAlways False for skip-ingress events
endpointstrOne of "/api/v1/events/batch/" or "/api/v1/ws-token"

Emitted via logger.warning("direct ingress skipped: %s", payload, extra=payload) so both the message string and the structured extra dict carry the same fields.


State transitions

Session lifecycle relative to rehydrate

[no session]
    │
    ▼ login
[session, may or may not have private team]
    │
    │ direct-ingress call site asks require_private_team_id(session)
    ▼
[has private team] ──── ingress proceeds ────────────► (continue)
    │ (else)
    ▼
[token_manager.rehydrate_membership_if_needed()]
    │
    ├── lock acquired, session now has private team (raced winner) ──► (continue)
    ├── negative cache hit ──────────────────────────► [skip ingress, log warning]
    ├── GET /api/v1/me, returns private team ───────► [set_session, continue]
    ├── GET /api/v1/me, returns no private team ────► [set _membership_negative_cache=True, skip ingress, log warning]
    └── GET /api/v1/me, HTTP/parse error ────────────► [skip ingress, log warning, cache untouched]

Negative-cache transitions

[_membership_negative_cache = False]
    │
    ▼ rehydrate succeeds with no private team
[_membership_negative_cache = True]
    │
    ├── rehydrate_membership_if_needed(force=True) and GET returns private ──► [False]
    ├── set_session(new) where new.email != prior.email ─────────────────────► [False]
    └── login flow completes and calls set_session ──────────────────────────► [False]

Tests as data contract

The contract above is enforced by tests listed in plan.md §1.7. The data shapes themselves remain inspectable through:

  • StoredSession.teams[*].is_private_teamspace (read by require_private_team_id)
  • caplog.records[-1].extra (asserted in skip-ingress tests)
  • respx.calls (asserted to count exactly one /api/v1/me per process for shared-only sessions)