24 KiB
24 KiB
Particle System & Network Capacity Implementation Plan
Summary
Implement a unified particle system where all content types (streams, folders, media, files, text, quests, papers, AI chats) are particles with a common structure but type-specific data. Add network capacity management for billing/limiting open streams.
Key Design Decisions
| Decision | Choice |
|---|---|
| Data model | Unified particle with type field + JSONB data |
| Hierarchy | Arbitrary nesting (parent_id references another particle) |
| Access control | Split: Handler checks network membership, Particle service checks particle visibility |
| Stream membership | Auto-visible to all network members OR custom member list |
| Open streams | Only "open" streams count against capacity |
| Substream counting | All stream particles count (including nested) |
| Service coupling | Loose - no FK constraints, particle service is independent |
| Listing API | Unified ListParticles(networkID, parentID, ...) - file explorer style |
| Sorting | Streams: updated_at DESC (activity), Folders: created_at (client sorts by type) |
| Pagination | Bidirectional cursor for streams (chat-like); folders return all |
| Network capacity | Stored on networks table (open_stream_capacity), updated via admin API |
| Data validation | Start simple with required field validation in service code |
Architecture: Access Control Split
┌─────────────────────────────────────────────────────────────────────┐
│ HANDLER LAYER │
│ 1. Extract user email from auth context │
│ 2. Check network membership (via network service) │
│ 3. Call particle service with (networkID, email) │
│ 4. Transform Particle structs → Response DTOs │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PARTICLE SERVICE │
│ - Trusts that handler verified network membership │
│ - Checks particle-level visibility (network_all vs custom) │
│ - Filters results to only particles user can see │
│ - Returns Particle domain structs │
│ - NO dependency on network service │
└─────────────────────────────────────────────────────────────────────┘
Why this split?
- Particle service stays decoupled from network service
- Network membership is a cross-cutting concern (handler already knows user context)
- Particle visibility is domain-specific (belongs in particle service)
UI Data Flow Examples
Mental Model: File Explorer
The API follows a file explorer pattern:
parentID = nil→ root items of networkparentID = "p_123"→ children of that particle- Same method works at every level
- Response includes parent for breadcrumbs/context
Example 1: User Opens App → Network Sidebar
┌─────────────────────────────────────────────────────┐
│ Streams in Acme Corp │
│ ├─ 📂 Projects (folder) │
│ │ ├─ 💬 Website Redesign (stream, open) │
│ │ └─ 💬 Mobile App (stream, closed) │
│ ├─ 💬 General Chat (stream, open) │
│ └─ 💬 Support Tickets (stream, open) │
└─────────────────────────────────────────────────────┘
API Calls:
// 1. Get root particles for selected network
resp := particleService.ListParticles(ctx, "net_abc", nil, email, ListFilter{}, nil)
// Returns:
// {
// Parent: nil, // No parent at root
// Particles: [
// {ID: "p_1", Type: "folder", Data: {"name": "Projects"}, ...},
// {ID: "p_2", Type: "stream", Data: {"title": "General Chat"}, StreamStatus: "open"},
// {ID: "p_3", Type: "stream", Data: {"title": "Support Tickets"}, StreamStatus: "open"},
// ]
// }
// 2. User clicks "Projects" folder → fetch children
resp := particleService.ListParticles(ctx, "net_abc", ptr("p_1"), email, ListFilter{}, nil)
// Returns:
// {
// Parent: {ID: "p_1", Type: "folder", Data: {"name": "Projects"}}, // For breadcrumbs
// Particles: [
// {ID: "p_4", Type: "stream", Data: {"title": "Website Redesign"}, StreamStatus: "open"},
// {ID: "p_5", Type: "stream", Data: {"title": "Mobile App"}, StreamStatus: "closed"},
// ]
// }
Example 2: User Opens Stream → Chat View
┌─────────────────────────────────────────────────────┐
│ 💬 Website Redesign [Members] │
├─────────────────────────────────────────────────────┤
│ ↑ Load older │
│ ───────────────────────────────────────────────── │
│ [Alice] Here's the new mockup │
│ 📎 mockup-v2.png (media particle) │
│ ───────────────────────────────────────────────── │
│ [Bob] Looks great! Question about nav │
│ ───────────────────────────────────────────────── │
│ 📋 Update navigation colors (quest) │
│ ───────────────────────────────────────────────── │
│ [Type a message...] [Send] │
└─────────────────────────────────────────────────────┘
API Calls:
// 1. Initial load - most recent particles (sorted by updated_at DESC)
resp := particleService.ListParticles(ctx, networkID, ptr("stream_123"), email, ListFilter{}, nil)
// Returns:
// {
// Parent: {ID: "stream_123", Type: "stream", Data: {"title": "Website Redesign"}},
// Particles: [newest...oldest], // Sorted by updated_at DESC
// HasMore: true,
// PrevCursor: {Position: "p_oldest_in_batch", Direction: "before"},
// NextCursor: nil // At newest
// }
// 2. User scrolls UP → load older messages
resp := particleService.ListParticles(ctx, networkID, ptr("stream_123"), email, ListFilter{},
&Cursor{Position: "p_oldest_in_batch", Direction: "before"})
// 3. User scrolls DOWN → load newer (after scrolling up)
resp := particleService.ListParticles(ctx, networkID, ptr("stream_123"), email, ListFilter{},
&Cursor{Position: "some_id", Direction: "after"})
// 4. User sends message
newParticle := particleService.Create(ctx, CreateInput{
Type: TypeText,
NetworkID: networkID,
ParentID: ptr("stream_123"),
Data: json.RawMessage(`{"content": "My message"}`),
}, email)
// UI inserts at bottom
Handler Implementation
// Handler pseudocode
func (h *Handler) ListParticles(w http.ResponseWriter, r *http.Request) {
email := getAuthEmail(r.Context())
networkID := r.URL.Query().Get("network_id")
parentID := r.URL.Query().Get("parent_id") // Optional
// 1. Check network membership (handler responsibility)
network, err := h.networkService.GetByID(ctx, networkID)
if err != nil { return NotFound }
if !network.HasMember(email) && network.AdminEmail != email {
return Forbidden("not a network member")
}
// 2. Parse cursor if provided
var cursor *particle.Cursor
if r.URL.Query().Has("cursor") {
cursor = parseCursor(r.URL.Query().Get("cursor"))
}
// 3. Call particle service
var parentPtr *string
if parentID != "" {
parentPtr = &parentID
}
result, err := h.particleService.ListParticles(ctx, networkID, parentPtr, email, filter, cursor)
if err != nil { return err }
// 4. Transform to response
json.NewEncoder(w).Encode(toParticleListResponse(result))
}
Response DTO structure
// Handler layer DTOs (in handler.go)
type ParticleResponse struct {
ID string `json:"id"`
Type string `json:"type"`
NetworkID string `json:"network_id"`
ParentID *string `json:"parent_id,omitempty"`
Visibility string `json:"visibility"`
StreamStatus *string `json:"stream_status,omitempty"` // Only for streams
Data json.RawMessage `json:"data"`
CreatedBy string `json:"created_by"`
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
}
// Transform function
func toParticleResponse(p *particle.Particle) ParticleResponse {
return ParticleResponse{
ID: p.ID,
Type: string(p.Type),
NetworkID: p.NetworkID,
ParentID: p.ParentID,
Visibility: string(p.Visibility),
StreamStatus: (*string)(p.StreamStatus),
Data: p.Data,
CreatedBy: p.CreatedByEmail,
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
CreatedAt: p.CreatedAt.Format(time.RFC3339),
}
}
Particle Types
| Type | Purpose | Required Data Fields |
|---|---|---|
stream |
Temporal container (chat-like) | title |
folder |
Structural container | name |
media |
Images, video, audio, clips | url, mime_type |
file |
Documents, PDFs, attachments | url, filename |
text |
Quick text messages | content |
quest |
Tasks/requests | title |
paper |
Rich documents | title |
think |
AI chat container | title |
Note: Quest with assigned_to = current_user is presented as a "request" in UI.
Database Schema
Migration 1: Network Capacity
-- migrations/000003_network_capacity.up.sql
ALTER TABLE networks
ADD COLUMN open_stream_capacity INTEGER NOT NULL DEFAULT 5,
ADD COLUMN open_stream_count INTEGER NOT NULL DEFAULT 0;
-- migrations/000003_network_capacity.down.sql
ALTER TABLE networks
DROP COLUMN open_stream_capacity,
DROP COLUMN open_stream_count;
Migration 2: Particles
-- migrations/000004_particles.up.sql
CREATE TYPE particle_type AS ENUM (
'stream', 'folder', 'media', 'file', 'text', 'quest', 'paper', 'think'
);
CREATE TYPE visibility_mode AS ENUM ('network_all', 'custom');
CREATE TABLE particles (
id TEXT PRIMARY KEY,
type particle_type NOT NULL,
network_id TEXT NOT NULL, -- NO FK constraint, loose coupling
parent_id TEXT, -- NO FK constraint, loose coupling
created_by_email VARCHAR(255) NOT NULL,
visibility visibility_mode NOT NULL DEFAULT 'network_all',
-- Stream-specific (NULL for non-streams)
stream_status VARCHAR(20) CHECK (stream_status IN ('open', 'closed')),
-- Type-specific data
data JSONB NOT NULL DEFAULT '{}',
-- Timestamps
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT stream_status_check CHECK (
(type = 'stream' AND stream_status IS NOT NULL) OR
(type != 'stream' AND stream_status IS NULL)
)
);
CREATE TABLE particle_members (
particle_id TEXT NOT NULL, -- NO FK constraint
email VARCHAR(255) NOT NULL,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (particle_id, email)
);
-- Indexes for query performance
CREATE INDEX idx_particles_parent_updated ON particles(parent_id, updated_at DESC);
CREATE INDEX idx_particles_network_root ON particles(network_id, updated_at DESC) WHERE parent_id IS NULL;
CREATE INDEX idx_particles_open_streams ON particles(network_id) WHERE type = 'stream' AND stream_status = 'open';
CREATE INDEX idx_particle_members_email ON particle_members(email, particle_id);
CREATE INDEX idx_particles_network_id ON particles(network_id);
-- migrations/000004_particles.down.sql
DROP TABLE IF EXISTS particle_members;
DROP TABLE IF EXISTS particles;
DROP TYPE IF EXISTS visibility_mode;
DROP TYPE IF EXISTS particle_type;
Service Layer Design
Package Structure
internal/particle/
├── models.go # Particle struct, type constants, data structs
├── errors.go # ErrNotFound, ErrCapacityExceeded, ErrAccessDenied
├── repository.go # Database operations (internal)
├── service.go # Public Service interface + implementation
├── validation.go # Type-specific validation (simple required fields)
└── service_test.go # Integration tests
Domain Models
// internal/particle/models.go
type ParticleType string
const (
TypeStream ParticleType = "stream"
TypeFolder ParticleType = "folder"
TypeMedia ParticleType = "media"
TypeFile ParticleType = "file"
TypeText ParticleType = "text"
TypeQuest ParticleType = "quest"
TypePaper ParticleType = "paper"
TypeThink ParticleType = "think"
)
type VisibilityMode string
const (
VisibilityNetworkAll VisibilityMode = "network_all"
VisibilityCustom VisibilityMode = "custom"
)
type StreamStatus string
const (
StreamOpen StreamStatus = "open"
StreamClosed StreamStatus = "closed"
)
type Particle struct {
ID string
Type ParticleType
NetworkID string
ParentID *string
CreatedByEmail string
Visibility VisibilityMode
StreamStatus *StreamStatus // Only for type=stream
Data json.RawMessage
UpdatedAt time.Time
CreatedAt time.Time
}
type CreateInput struct {
Type ParticleType
NetworkID string
ParentID *string
Visibility VisibilityMode
Data json.RawMessage
MemberEmails []string // For custom visibility
}
type ListFilter struct {
Types []ParticleType
StreamStatus *StreamStatus
}
type Cursor struct {
Position string // particle ID or timestamp
Direction string // "before" | "after"
}
type ParticleList struct {
Parent *Particle // The parent particle (nil if root level)
Particles []*Particle
HasMore bool
NextCursor *Cursor // For loading more in same direction
PrevCursor *Cursor // For bidirectional (streams)
}
Service Interface
// internal/particle/service.go
type Service interface {
// Core CRUD
// Note: Caller (handler) is responsible for verifying network membership
// Service handles particle-level visibility filtering
Create(ctx context.Context, input CreateInput, creatorEmail string) (*Particle, error)
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
Delete(ctx context.Context, id, requesterEmail string) error
// Unified listing - file explorer style
// - parentID = nil → root particles of network
// - parentID = "p_123" → children of that particle
// - Automatically filters by visibility
// - Streams: sorted by updated_at DESC (activity-based)
// - Folders: sorted by created_at (client can re-sort by type)
ListParticles(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor) (*ParticleList, error)
// Stream lifecycle
OpenStream(ctx context.Context, id, requesterEmail string) error
CloseStream(ctx context.Context, id, requesterEmail string) error
// Returns current open stream count for a network (for capacity check)
GetOpenStreamCount(ctx context.Context, networkID string) (int, error)
// Membership (for custom visibility)
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
GetMembers(ctx context.Context, id string) ([]string, error)
}
ListParticles Implementation Logic
func (s *service) ListParticles(ctx context.Context, networkID string, parentID *string, email string, filter ListFilter, cursor *Cursor) (*ParticleList, error) {
var parent *Particle
var sortBy string
// 1. Determine parent and sort strategy
if parentID != nil {
var err error
parent, err = s.repo.getByID(ctx, *parentID)
if err != nil {
return nil, ErrNotFound
}
// Check visibility access to parent
if !s.canAccess(ctx, *parentID, email) {
return nil, ErrAccessDenied
}
// Sort based on parent type
if parent.Type == TypeStream {
sortBy = "updated_at DESC" // Activity-based for streams
} else {
sortBy = "created_at DESC" // Chronological for folders
}
} else {
sortBy = "updated_at DESC" // Root level: activity-based
}
// 2. Fetch particles with visibility filtering
particles, hasMore, nextCursor, prevCursor := s.repo.listChildren(ctx, networkID, parentID, email, sortBy, filter, cursor)
return &ParticleList{
Parent: parent,
Particles: particles,
HasMore: hasMore,
NextCursor: nextCursor,
PrevCursor: prevCursor,
}, nil
}
Simple Validation (Start Simple)
// internal/particle/validation.go
func validateData(t ParticleType, data json.RawMessage) error {
switch t {
case TypeStream, TypeQuest, TypePaper, TypeThink:
return requireField(data, "title")
case TypeFolder:
return requireField(data, "name")
case TypeMedia, TypeFile:
return requireFields(data, "url", "mime_type")
case TypeText:
return requireField(data, "content")
default:
return ErrInvalidParticleType
}
}
func requireField(data json.RawMessage, field string) error {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("invalid data JSON: %w", err)
}
if _, ok := m[field]; !ok {
return fmt.Errorf("missing required field: %s", field)
}
return nil
}
Particle Visibility Logic
The particle service handles visibility filtering. It does not check network membership (handler does that).
For visibility = 'network_all'
- All network members can see it
- Since handler already verified network membership, service returns it
For visibility = 'custom'
- Only users in
particle_memberstable can see it - Service checks
particle_memberstable
Inheritance Rule
- Children can only restrict access, not expand
- If parent has
customvisibility, child must also becustom(or more restrictive) - Creating a child with
network_allunder acustomparent → error
Access Check Query
-- Check if user can access a specific particle
-- Walks up the ancestor chain, verifies access at each level
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, visibility
FROM particles
WHERE id = $1
UNION ALL
SELECT p.id, p.parent_id, p.visibility
FROM particles p
JOIN ancestors a ON p.id = a.parent_id
)
SELECT bool_and(
CASE
WHEN visibility = 'network_all' THEN true -- Handler already verified network membership
WHEN visibility = 'custom' THEN (
EXISTS (SELECT 1 FROM particle_members pm
WHERE pm.particle_id = ancestors.id AND pm.email = $2)
)
END
) AS has_access
FROM ancestors;
Network Service Updates
New Methods (no changes to coupling)
// In internal/network/service.go
type Service interface {
// ... existing methods ...
// Admin capacity management
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
GetCapacityInfo(ctx context.Context, networkID string) (capacity int, current int, err error)
// Stream count updates (called by handler, not particle service)
IncrementOpenStreamCount(ctx context.Context, networkID string) error
DecrementOpenStreamCount(ctx context.Context, networkID string) error
}
Capacity Enforcement (in Handler)
// Handler: Opening a stream
func (h *Handler) OpenStream(w http.ResponseWriter, r *http.Request) {
// ... auth and validation ...
// 1. Get particle to find its network
particle, err := h.particleService.GetByID(ctx, particleID, email)
// 2. Check capacity
capacity, current, err := h.networkService.GetCapacityInfo(ctx, particle.NetworkID)
if current >= capacity {
return Error("stream capacity exceeded")
}
// 3. Open the stream
err = h.particleService.OpenStream(ctx, particleID, email)
// 4. Increment counter
err = h.networkService.IncrementOpenStreamCount(ctx, particle.NetworkID)
}
Files to Modify
| File | Changes |
|---|---|
internal/network/models.go |
Add OpenStreamCapacity, OpenStreamCount fields |
internal/network/repository.go |
Add capacity CRUD methods |
internal/network/service.go |
Add SetOpenStreamCapacity, GetCapacityInfo, counter methods |
internal/handler/handler.go |
Wire up particle endpoints, add admin capacity endpoint |
migrations/ |
Add 000003 and 000004 migration files |
Files to Create
| File | Purpose |
|---|---|
internal/particle/models.go |
Particle struct, type constants |
internal/particle/errors.go |
Domain errors |
internal/particle/repository.go |
Database operations |
internal/particle/service.go |
Business logic + visibility filtering |
internal/particle/validation.go |
Simple required field validation |
internal/particle/service_test.go |
Integration tests |
Implementation Phases
Phase 1: Network Capacity
- Create migration
000003_network_capacity - Update network models with capacity fields
- Update network repository with capacity methods
- Update network service with
SetOpenStreamCapacity,GetCapacityInfo - Add admin endpoint in handler
Phase 2: Core Particle CRUD
- Create migration
000004_particles - Create particle package with models, errors
- Implement repository with basic CRUD
- Implement simple validation
- Write integration tests
Phase 3: Particle Visibility
- Implement visibility filtering in list queries
- Implement access check for GetByID
- Implement membership management (AddMembers, RemoveMembers)
- Test visibility scenarios
Phase 4: Stream Lifecycle
- Implement
OpenStream/CloseStreamin particle service - Wire up capacity checks in handler
- Test capacity enforcement
Phase 5: Unified ListParticles
- Implement unified
ListParticlesmethod - Add parent-type-aware sorting (streams: updated_at, folders: created_at)
- Implement bidirectional cursor pagination
- Include parent in response for breadcrumbs
Phase 6: Handler Integration
- Wire particle service to handler
- Implement all particle endpoints
- Add DTO transformations
- End-to-end testing
Verification Plan
- Integration tests: Full flow with test database (following existing pattern in
network/service_test.go) - Manual testing:
- Create network with capacity 2
- Open 2 streams → succeeds
- Open 3rd stream → fails with capacity error
- Close a stream → can open new one
- Create nested particles, verify visibility inheritance
- Add custom members, verify restricted access
- Test handler data flow end-to-end