Move completed design/implementation docs from docs/plans/ to docs/archive/ to keep the plans folder focused on active and future work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
Foundational Domain Model: Step Library, User Trees, and Session Sharing
Context
This design establishes the foundational schema for three critical ResolutionFlow features that define the durable domain model. All future functionality depends on these tables, fields, and constraints being correctly designed from the start.
Features Covered
- Step Library Core Schema (Issues #4-#7): Reusable troubleshooting steps with categories, ratings, and usage tracking
- User Trees & Forking (Issue #11): Personal/forked tree data model for engineer customization
- Session Sharing (Issue #15): Read-only share links with configurable access control
Why These First
These features establish:
- Step Library: Canonical data model for reusable steps before any UI or session integrations
- User Trees: Fork relationships and ownership model before implementing fork/share workflows
- Sharing Schema: Token mechanics and access control before APIs/UI solidify
Getting these schemas right now prevents costly migrations and refactoring later.
Part 1: Tree Forking Model
Design Principle
Forked trees are regular trees with added fork metadata. They use the same ownership/visibility model as existing trees:
author_id: Engineer who created the forkaccount_id: Account that owns the forkis_public/ visibility: Controls who can see/use it
Schema Changes: New Fields on trees Table
Add three fields to track fork relationships:
# Fork relationship tracking
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"), # Orphan forks on parent delete
nullable=True,
index=True
)
fork_reason: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="Brief reason: 'Added Cisco Meraki steps for our network'"
)
parent_updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates."
)
New Relationships on Tree Model
# In Tree class:
parent: Mapped[Optional["Tree"]] = relationship(
"Tree",
remote_side=[id],
foreign_keys=[parent_tree_id],
back_populates="forks"
)
forks: Mapped[list["Tree"]] = relationship(
"Tree",
foreign_keys=[parent_tree_id],
back_populates="parent"
# No cascade - ondelete="SET NULL" handles orphaning at DB level
)
Field Explanations
| Field | Type | Purpose |
|---|---|---|
parent_tree_id |
UUID nullable | NULL = root tree, not-NULL = forked tree. Points to original tree. |
fork_reason |
String(255) | Optional engineer note: "Added wireless troubleshooting for our Meraki APs" |
parent_updated_at |
Datetime nullable | Timestamp snapshot. Compare to parent's actual updated_at to detect changes. |
Key Behaviors
Fork Creation:
- Copy parent's
tree_structureJSONB - Set
parent_tree_idto parent's ID - Set
fork_reasonfrom user input - Snapshot
parent_updated_at = parent.updated_at - Set
author_id = current_user.id - Set
account_id = current_user.account_id - Default
is_public = False(private fork) - Reset
version = 1for fork
Fork Updates:
- Engineer edits fork →
versionincrements - Parent tree unaffected
parent_updated_atremains frozen at fork time
Parent Deletion:
- Hard delete →
parent_tree_idbecomes NULL (orphaned) - Fork survives as independent tree
- Soft delete →
parent_tree_idpreserved, fork still references parent
Update Notifications:
def has_parent_updates(fork: Tree) -> bool:
"""Check if parent tree has updates since fork."""
if not fork.parent_tree_id or not fork.parent_updated_at:
return False
parent = db.query(Tree).get(fork.parent_tree_id)
if not parent:
return False # Parent deleted or soft-deleted
return parent.updated_at > fork.parent_updated_at
Migration 1: Tree Forking Fields
File: alembic/versions/022_add_tree_forking.py
def upgrade():
# Add fork tracking columns
op.add_column('trees', sa.Column('parent_tree_id', UUID, nullable=True))
op.add_column('trees', sa.Column('fork_reason', sa.String(255), nullable=True))
op.add_column('trees', sa.Column('parent_updated_at', sa.DateTime(timezone=True), nullable=True))
# Add foreign key
op.create_foreign_key(
'fk_trees_parent_tree_id',
'trees', 'trees',
['parent_tree_id'], ['id'],
ondelete='SET NULL'
)
# Add index for fork queries
op.create_index('ix_trees_parent_tree_id', 'trees', ['parent_tree_id'])
def downgrade():
op.drop_index('ix_trees_parent_tree_id')
op.drop_constraint('fk_trees_parent_tree_id', 'trees')
op.drop_column('trees', 'parent_updated_at')
op.drop_column('trees', 'fork_reason')
op.drop_column('trees', 'parent_tree_id')
Part 2: Session Custom Steps Enhancement
Current State
sessions.custom_steps already exists as JSONB field. Current structure:
{
"custom_steps": [
{
"type": "action",
"content": "Check Meraki dashboard for AP status",
"notes": "Found AP offline"
}
]
}
Enhanced Structure (Backward Compatible)
No migration needed - pure JSONB enhancement:
{
"custom_steps": [
{
"type": "action",
"content": "Check Meraki dashboard for AP status",
"notes": "Found AP offline",
// NEW FIELDS (optional, added to new sessions only):
"source": "ad-hoc", // "ad-hoc" | "step-library" | "forked-tree"
"source_step_id": null, // UUID string if from StepLibrary
"inserted_at": "2026-02-07T15:30:00Z", // ISO datetime
"inserted_after_node_id": "ad_verify_identity" // Node ID from tree_structure
},
{
"type": "action",
"content": "Verify DNS configuration",
"notes": "DNS servers correct",
"source": "step-library",
"source_step_id": "123e4567-e89b-12d3-a456-426614174000",
"inserted_at": "2026-02-07T15:32:00Z",
"inserted_after_node_id": "network_check"
}
]
}
New Field Definitions
| Field | Type | Purpose |
|---|---|---|
source |
string | Origin: ad-hoc (typed by engineer), step-library (from library), forked-tree (from fork) |
source_step_id |
UUID string | If source=step-library, points to StepLibrary.id for usage tracking |
inserted_at |
ISO datetime | When engineer added this step to session |
inserted_after_node_id |
string | Node ID from tree_structure where step was inserted (e.g., "ad_verify_identity") |
Use Cases
1. Step Library Usage Tracking
- Engineer inserts step from library → logs to
step_usage_log - Increments
StepLibrary.usage_count - Even if step later deleted,
source_step_idpreserved (orphaned reference for analytics)
2. Identify Popular Ad-Hoc Steps
- Analytics query: "What ad-hoc steps appear across multiple sessions?"
- Example: "Check Meraki dashboard" appears 15 times → suggest adding to library
- Helps identify gaps in official step library
3. Save Session as Tree (Future)
- Engineer completes session with custom steps
- "Save as Tree" reconstructs tree structure
- Uses
inserted_after_node_idto place custom steps correctly
Backward Compatibility Strategy
Pydantic schema (backend):
class CustomStepSchema(BaseModel):
type: str # "decision" | "action" | "solution"
content: str
notes: Optional[str] = None
# New fields with defaults for old sessions:
source: str = "ad-hoc"
source_step_id: Optional[UUID] = None
inserted_at: Optional[datetime] = None
inserted_after_node_id: Optional[str] = None
Result: Old sessions load without error. New fields auto-populate with defaults in-memory. Database unchanged.
Step Library Reference Handling
When StepLibrary entry deleted:
source_step_idremains in sessions (orphaned UUID)- No foreign key constraint (it's in JSONB)
- Analytics can still count: "Deleted step XYZ was used 50 times historically"
- Frontend shows: "From step library (no longer available)"
Usage Tracking Implementation
When engineer inserts library step into session:
async def insert_step_from_library(
session_id: UUID,
step_id: UUID,
current_node_id: str,
current_user: User,
db: AsyncSession
):
# 1. Load step from library
step = await db.get(StepLibrary, step_id)
# 2. Add to session.custom_steps
new_step = {
"type": step.step_type,
"content": step.content,
"notes": "",
"source": "step-library",
"source_step_id": str(step.id),
"inserted_at": datetime.now(timezone.utc).isoformat(),
"inserted_after_node_id": current_node_id
}
session.custom_steps.append(new_step)
# 3. Log usage immediately
usage_log = StepUsageLog(
step_id=step.id,
user_id=current_user.id,
session_id=session.id,
used_at=datetime.now(timezone.utc)
)
db.add(usage_log)
# 4. Increment counter
step.usage_count += 1
await db.commit()
Multiple insertions: Same step inserted twice in one session → 2 StepUsageLog entries, 2 items in custom_steps array, usage_count += 2.
No Migration Required
✅ This is pure application-layer enhancement. Existing sessions remain valid. New sessions use enhanced structure.
Part 3: Session Share Tokens
Overview
Enable engineers to share read-only session links with configurable access control:
- Public shares: Anyone with link can view (no auth required)
- Account-only shares: Requires login + account membership
Account owners control whether engineers can create public shares via policy setting.
New Table 1: session_shares
class SessionShare(Base):
__tablename__ = "session_shares"
__table_args__ = (
CheckConstraint(
"visibility IN ('public', 'account')",
name='ck_session_shares_visibility'
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True
)
share_token: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="URL-safe random token (48 bytes → 64 base64 chars)"
)
share_name: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="Optional label: 'Training link', 'Customer escalation #1234'"
)
visibility: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="public",
comment="public = anyone with link, account = account members only"
)
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True # For "My Shares" view performance
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
comment="Optional expiration for time-limited shares"
)
# Deprecated: Simple counting replaced by session_share_views table
view_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0
)
last_viewed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
index=True
)
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="shares")
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
views: Mapped[list["SessionShareView"]] = relationship(
"SessionShareView",
back_populates="share",
cascade="all, delete-orphan"
)
New Table 2: session_share_views
Track detailed view analytics, including WHO viewed account-only shares:
class SessionShareView(Base):
__tablename__ = "session_share_views"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
share_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("session_shares.id", ondelete="CASCADE"),
nullable=False,
index=True
)
viewer_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="NULL for public shares (unauthenticated views)"
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
index=True
)
viewer_ip: Mapped[Optional[str]] = mapped_column(
String(45), # IPv6 max length
nullable=True
)
viewer_user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True
)
# Relationships
share: Mapped["SessionShare"] = relationship("SessionShare", back_populates="views")
viewer: Mapped[Optional["User"]] = relationship("User")
Account-Level Sharing Policy
Add to accounts table:
# In Account model:
allow_public_shares: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
)
Policy enforcement (at share creation time only):
# In create_share endpoint:
if visibility == "public" and not current_user.account.allow_public_shares:
raise HTTPException(
status_code=403,
detail="Your organization does not allow public session sharing. Use account-only visibility."
)
Existing shares: If account owner toggles policy OFF, existing public shares remain active (grandfathered). Owner can manually revoke individual shares if needed.
Share Token Generation
Requirements:
- URL-safe (no special chars)
- Cryptographically random (non-guessable)
- Collision-resistant
Implementation:
import secrets
from sqlalchemy.exc import IntegrityError
async def create_session_share(
db: AsyncSession,
session_id: UUID,
created_by: UUID,
visibility: str,
share_name: Optional[str] = None,
expires_at: Optional[datetime] = None,
max_retries: int = 3
) -> SessionShare:
"""Create share with automatic token collision retry."""
for attempt in range(max_retries):
try:
share_token = secrets.token_urlsafe(48) # 48 bytes → 64 chars
share = SessionShare(
session_id=session_id,
share_token=share_token,
share_name=share_name,
visibility=visibility,
created_by=created_by,
expires_at=expires_at
)
db.add(share)
await db.commit()
await db.refresh(share)
return share
except IntegrityError as e:
if "session_shares_share_token_key" in str(e):
# Token collision (extremely rare), retry
await db.rollback()
if attempt == max_retries - 1:
raise
continue
else:
raise
Share URL format:
https://patherly.com/share/x3K9mN_2pQ7vR8sT4wZ1aB5cD6eF7gH8iJ9kL0mN
Access Control Flow
When user visits share link:
async def access_share(
token: str,
current_user: Optional[User],
request: Request,
db: AsyncSession
) -> SessionResponse:
# 1. Lookup share
share = await db.execute(
select(SessionShare)
.where(SessionShare.share_token == token)
.options(joinedload(SessionShare.session))
)
share = share.scalar_one_or_none()
# 2. Validate share
if not share or not share.is_active:
raise HTTPException(404, "Share not found or has been revoked")
if share.expires_at and share.expires_at < datetime.now(timezone.utc):
raise HTTPException(410, "Share link has expired")
# 3. Check visibility
if share.visibility == "account":
if not current_user:
raise HTTPException(401, "This share requires authentication")
# Check account membership
session_owner = await db.get(User, share.session.user_id)
if current_user.account_id != session_owner.account_id:
raise HTTPException(403, "You don't have access to this session")
# 4. Record view
view = SessionShareView(
share_id=share.id,
viewer_id=current_user.id if current_user else None,
viewed_at=datetime.now(timezone.utc),
viewer_ip=request.client.host,
viewer_user_agent=request.headers.get("user-agent")
)
db.add(view)
share.last_viewed_at = datetime.now(timezone.utc)
await db.commit()
# 5. Return read-only session view
return SessionResponse.from_orm(share.session)
Share Management Permissions
Create share: POST /api/v1/sessions/{session_id}/shares
- Requires:
session.user_id == current_user.id(session owner only) - Validates:
account.allow_public_sharesifvisibility='public'
Revoke share: DELETE /api/v1/shares/{share_id}
- Requires:
share.created_by == current_user.id(share creator only) - Sets
is_active = False(soft revoke)
List my shares: GET /api/v1/shares/my-shares
- Returns:
WHERE created_by == current_user.id - Uses
created_byindex for performance
Multiple Shares Per Session
✅ Supported: No unique constraint on session_id. Engineer can create:
- Public link for customer
- Account-only link for internal team
- Time-limited link (24h) for contractor
- Named links: "Training link for new hires", "Escalation to senior"
Cleanup Job for Expired Shares
Background task (runs daily via cron/scheduler):
async def cleanup_expired_shares(
db: AsyncSession,
retention_days: int = 30
):
"""Hard-delete expired shares after retention period."""
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
# Find shares expired > retention_days ago
expired_shares = await db.execute(
select(SessionShare)
.where(SessionShare.expires_at < cutoff)
.where(SessionShare.is_active == False)
)
for share in expired_shares.scalars():
await db.delete(share) # Cascades to session_share_views
await db.commit()
logger.info(f"Cleaned up {len(list(expired_shares))} expired shares")
Migration 2: Session Sharing
File: alembic/versions/023_add_session_sharing.py
def upgrade():
# Create session_shares table
op.create_table(
'session_shares',
sa.Column('id', UUID, primary_key=True),
sa.Column('session_id', UUID, nullable=False),
sa.Column('share_token', sa.String(64), nullable=False, unique=True),
sa.Column('share_name', sa.String(100), nullable=True),
sa.Column('visibility', sa.String(20), nullable=False),
sa.Column('created_by', UUID, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('view_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('last_viewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean, nullable=False, server_default='true'),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'),
sa.CheckConstraint("visibility IN ('public', 'account')", name='ck_session_shares_visibility')
)
# Create indexes
op.create_index('ix_session_shares_session_id', 'session_shares', ['session_id'])
op.create_index('ix_session_shares_share_token', 'session_shares', ['share_token'])
op.create_index('ix_session_shares_created_by', 'session_shares', ['created_by'])
op.create_index('ix_session_shares_expires_at', 'session_shares', ['expires_at'])
op.create_index('ix_session_shares_is_active', 'session_shares', ['is_active'])
# Create session_share_views table
op.create_table(
'session_share_views',
sa.Column('id', UUID, primary_key=True),
sa.Column('share_id', UUID, nullable=False),
sa.Column('viewer_id', UUID, nullable=True),
sa.Column('viewed_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('viewer_ip', sa.String(45), nullable=True),
sa.Column('viewer_user_agent', sa.String(500), nullable=True),
sa.ForeignKeyConstraint(['share_id'], ['session_shares.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['viewer_id'], ['users.id'], ondelete='SET NULL')
)
# Create indexes
op.create_index('ix_session_share_views_share_id', 'session_share_views', ['share_id'])
op.create_index('ix_session_share_views_viewer_id', 'session_share_views', ['viewer_id'])
op.create_index('ix_session_share_views_viewed_at', 'session_share_views', ['viewed_at'])
# Add account policy
op.add_column('accounts', sa.Column('allow_public_shares', sa.Boolean, nullable=False, server_default='true'))
def downgrade():
op.drop_column('accounts', 'allow_public_shares')
op.drop_table('session_share_views')
op.drop_table('session_shares')
Summary of Schema Changes
Migration 022: Tree Forking
New columns on trees:
parent_tree_id(UUID nullable, FK to trees.id, SET NULL on delete)fork_reason(String 255)parent_updated_at(Datetime nullable)
Indexes:
ix_trees_parent_tree_id
Migration 023: Session Sharing
New tables:
session_shares(13 columns, 5 indexes)session_share_views(6 columns, 3 indexes)
New columns on accounts:
allow_public_shares(Boolean, default true)
No Migration: Custom Steps Enhancement
Pure JSONB enhancement with backward compatibility. Existing sessions remain valid.
Implementation Checklist
Phase 1: Tree Forking (Issues #11)
- Create migration 022 (tree forking fields)
- Update Tree model with new fields and relationships
- Add fork creation endpoint:
POST /api/v1/trees/{tree_id}/fork - Add fork list endpoint:
GET /api/v1/trees/{tree_id}/forks - Implement parent update detection logic
- Frontend: Fork button on tree detail page
- Frontend: "Parent updated" notification badge
- Tests: Fork creation, orphaning, update detection
Phase 2: Custom Steps Enhancement (Issues #4-#7 partial)
- Update CustomStepSchema with new optional fields
- Implement step insertion from library endpoint
- Create StepUsageLog entry on insertion
- Increment StepLibrary.usage_count
- Analytics endpoint: Popular ad-hoc steps
- Frontend: Insert from library button in session
- Tests: Usage tracking, backward compatibility
Phase 3: Session Sharing (Issue #15)
- Create migration 023 (session sharing tables)
- Update SessionShare and SessionShareView models
- Add Account.allow_public_shares field
- Implement share creation endpoint with token retry
- Implement share access endpoint with view tracking
- Implement share revocation endpoint
- Setup cleanup job for expired shares
- Frontend: Share button in session detail
- Frontend: Share management modal
- Frontend: Public share view page
- Tests: Share creation, access control, expiration
Verification Plan
Tree Forking Tests
- Create fork from tree →
parent_tree_idset correctly - Update parent tree → fork unaffected
- Delete parent tree (hard) → fork orphaned (
parent_tree_id = NULL) - Soft delete parent → fork still references parent
has_parent_updates()returns true after parent updatedhas_parent_updates()returns false if parent unchanged- Fork reason stored and retrieved correctly
Custom Steps Tests
- Old session loads without error (backward compat)
- Insert step from library →
source_step_idpopulated - Insert step from library →
StepUsageLogcreated - Insert step from library →
usage_countincremented - Insert same step twice → 2 log entries, count += 2
- Delete step →
source_step_idremains (orphaned) - Ad-hoc step →
source = "ad-hoc",source_step_id = null
Session Sharing Tests
- Create public share → token generated, unique
- Access public share without auth → works
- Create account-only share → requires auth
- Access account-only share as outsider → 403 error
- Access account-only share as member → works, view logged
- Account with
allow_public_shares=False→ cannot create public share - Expired share → 410 error
- Revoked share → 404 error
- Multiple shares per session → all work independently
- View tracking →
SessionShareViewentries created - Delete session → shares cascade deleted
Future Enhancements (Out of Scope)
Tree Forking:
- Merge fork changes back to parent (pull request model)
- Visual diff view: fork vs parent tree
- Bulk fork operations: fork all trees in category
Custom Steps:
- One-click "Promote to library" for ad-hoc steps
- Auto-suggest library steps based on session context
- "Save session as tree" reconstruction
Session Sharing:
- Embed shares in iframe (oEmbed support)
- Password-protected shares
- Share groups (one link, multiple sessions)
- Share templates with pre-filled expiration/visibility
References
- Issues: #4, #5, #6, #7 (Step Library), #11 (User Trees), #15 (Sharing)
- Existing Models: Tree, Session, StepLibrary, StepRating, StepUsageLog
- Feature Brainstorm:
docs/plans/2026-02-04-feature-ideas-brainstorm.md - Tree Structure:
backend/scripts/seed_trees.py(examples)