rdev/cookbooks/feature-development.md
jordan 425ef0f806 feat: add SDLC orchestration - library, CLI, and API integration
Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.

Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence

CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods

API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval

Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)

Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:57:05 -07:00

833 lines
22 KiB
Markdown

# Feature Development Cookbook
> Complete workflow for building features using rdev composable monorepo templates with chassis patterns, design system, and auth integration.
## Overview
This cookbook documents the end-to-end workflow for developing features in a composable monorepo project. It assumes you have:
- An rdev project with monorepo skeleton (created via `POST /project`)
- At least one API service component (`services/api`)
- A Next.js or React app component (`apps/dashboard`)
```
Feature Development Workflow
────────────────────────────
1. Planning → Define requirements, break into tasks
2. API Dev → Bind + Wrap patterns, OpenAPI annotations
3. Frontend Dev → Auth hooks, design system, API client
4. Testing → Handler tests, integration tests
5. Review → /review-code, quality checks
6. Deployment → Git push, Woodpecker CI, verify
```
---
## Prerequisites
### API Access
```bash
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<your-api-key>"
```
### Project Structure
Your composable monorepo should have:
```
my-project/
├── pkg/ # Shared Go packages
│ ├── app/ # Service bootstrapper
│ │ ├── app.go # NewApp, Run, RegisterRoutes
│ │ ├── handler.go # Wrap pattern
│ │ ├── bind.go # Bind + BindAndValidate
│ │ └── health.go # Health probes
│ ├── httperror/ # Typed HTTP errors
│ ├── httpresponse/ # JSON response envelope
│ ├── httpvalidation/ # go-playground/validator
│ └── auth/ # JWT + API key validation
├── packages/ # Shared TS packages
│ ├── ui/ # Design system components
│ ├── layout/ # DashboardShell, Sidebar
│ ├── auth/ # AuthProvider, useAuth
│ ├── logger/ # Structured logging
│ └── api-client/ # Generated TypeScript client
├── services/ # Go backend services
│ └── api/
│ ├── cmd/server/main.go
│ └── internal/api/routes.go
└── apps/ # Frontend applications
└── dashboard/
└── src/app/ # Next.js App Router
```
---
## Step 1: Feature Planning
### Create Feature Branch
```bash
git checkout -b feature/user-profile
```
### Define Requirements
Example: User Profile Feature
- **Backend:** GET/PUT /api/users/me endpoints
- **Frontend:** Profile page with edit form
- **Validation:** Email format, name required
- **Auth:** Require authenticated user
### Break Into Tasks
```markdown
## Tasks
- [ ] Add User domain model with validation
- [ ] Implement GET /api/users/me handler
- [ ] Implement PUT /api/users/me handler
- [ ] Add OpenAPI annotations
- [ ] Create profile page component
- [ ] Add profile edit form
- [ ] Connect to API with generated client
- [ ] Write handler tests
- [ ] Run quality checks
```
---
## Step 2: API Development
### 2.1 Add Domain Model
Create the domain model in `services/api/internal/domain/`:
```go
// services/api/internal/domain/user.go
package domain
import "time"
// User represents an authenticated user's profile.
type User struct {
ID string `json:"id" db:"id"`
Email string `json:"email" db:"email"`
Name string `json:"name" db:"name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
```
### 2.2 Create Handler with Wrap Pattern
The `Wrap` pattern converts error-returning handlers to `http.HandlerFunc`:
```go
// services/api/internal/api/handlers/user.go
package handlers
import (
"net/http"
"my-project/pkg/app"
"my-project/pkg/auth"
"my-project/pkg/httperror"
"my-project/pkg/httpresponse"
"my-project/services/api/internal/service"
)
type UserHandler struct {
svc *service.UserService
logger *slog.Logger
}
func NewUserHandler(svc *service.UserService, logger *slog.Logger) *UserHandler {
return &UserHandler{svc: svc, logger: logger}
}
// GetMe returns the authenticated user's profile.
// Uses app.Wrap to convert error-returning handler to http.HandlerFunc.
func (h *UserHandler) GetMe() http.HandlerFunc {
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Get user from auth context
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
// Fetch full profile
profile, err := h.svc.GetByID(r.Context(), user.ID)
if err != nil {
return httperror.WrapError(err, "failed to get profile")
}
httpresponse.JSON(w, http.StatusOK, profile)
return nil
})
}
// UpdateMe updates the authenticated user's profile.
// Uses app.BindAndValidate for request parsing and validation.
func (h *UserHandler) UpdateMe() http.HandlerFunc {
type request struct {
Name string `json:"name" validate:"required,min=1,max=100"`
AvatarURL string `json:"avatar_url" validate:"omitempty,url"`
}
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Get user from auth context
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
// Bind and validate request body
var req request
if err := app.BindAndValidate(r, &req); err != nil {
return err // Already an HTTPError with validation details
}
// Update profile
profile, err := h.svc.Update(r.Context(), user.ID, req.Name, req.AvatarURL)
if err != nil {
return httperror.WrapError(err, "failed to update profile")
}
httpresponse.JSON(w, http.StatusOK, profile)
return nil
})
}
```
### 2.3 Register Routes
```go
// services/api/internal/api/routes.go
func (s *Server) RegisterRoutes() {
userHandler := handlers.NewUserHandler(s.userService, s.logger)
// Protected routes (require auth)
s.router.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.authValidator))
r.Get("/api/users/me", userHandler.GetMe())
r.Put("/api/users/me", userHandler.UpdateMe())
})
}
```
### 2.4 Add OpenAPI Annotations
```go
// services/api/cmd/server/main.go
func buildOpenAPISpec() *api.OpenAPISpec {
spec := api.NewOpenAPISpec("My Project API", "1.0.0", "/api")
// Security schemes
spec.WithBearerSecurity("bearerAuth", "JWT Bearer token")
spec.WithGlobalSecurity("bearerAuth")
// User profile endpoints
spec.Op("GET", "/users/me", "Get current user profile",
api.OpResponses(
api.OpResponse(200, "User profile", api.Ref("User")),
api.OpStandardResponses()...,
),
)
spec.OpWithBody("PUT", "/users/me", "Update current user profile",
api.Object(map[string]*api.Schema{
"name": api.String().Description("Display name"),
"avatar_url": api.String().Format("uri").Description("Avatar URL"),
}, "name"),
api.OpResponses(
api.OpResponse(200, "Updated user profile", api.Ref("User")),
api.OpStandardResponses()...,
),
)
// Schema definitions
spec.Schema("User", api.Object(map[string]*api.Schema{
"id": api.UUID(),
"email": api.Email(),
"name": api.String(),
"avatar_url": api.String().Format("uri"),
"created_at": api.DateTime(),
"updated_at": api.DateTime(),
}))
return spec
}
```
---
## Step 3: Frontend Development
### 3.1 Generate TypeScript Client
After adding OpenAPI annotations, regenerate the client:
```bash
./scripts/generate-client.sh
```
This updates `packages/api-client/src/schema.d.ts` with typed endpoints.
### 3.2 Create Profile Page
```tsx
// apps/dashboard/src/app/(dashboard)/profile/page.tsx
import { ProfileForm } from '@/components/profile-form';
import { Card, CardContent, CardHeader, CardTitle } from '@my-project/ui';
export default function ProfilePage() {
return (
<div className="p-6">
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent>
<ProfileForm />
</CardContent>
</Card>
</div>
);
}
```
### 3.3 Create Profile Form with Auth Hook
```tsx
// apps/dashboard/src/components/profile-form.tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@my-project/auth';
import { Button, Input, Label } from '@my-project/ui';
import { updateProfile } from '@/actions/profile';
export function ProfileForm() {
const { user, refreshUser } = useAuth();
const [name, setName] = useState(user?.name ?? '');
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? '');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!user) {
return <div>Loading...</div>;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const result = await updateProfile({ name, avatar_url: avatarUrl });
if (result.success) {
await refreshUser();
} else {
setError(result.error ?? 'Failed to update profile');
}
} catch (err) {
setError('An unexpected error occurred');
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={user.email}
disabled
className="bg-surface-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="avatar">Avatar URL</Label>
<Input
id="avatar"
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
/>
</div>
{error && (
<div className="text-sm text-error">{error}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</form>
);
}
```
### 3.4 Create Server Action
```typescript
// apps/dashboard/src/actions/profile.ts
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
interface UpdateProfileInput {
name: string;
avatar_url?: string;
}
interface UpdateProfileResult {
success: boolean;
error?: string;
}
export async function updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult> {
const token = cookies().get('auth_token')?.value;
if (!token) {
return { success: false, error: 'Not authenticated' };
}
const response = await fetch(`${process.env.API_URL}/api/users/me`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json();
return {
success: false,
error: error.error?.message ?? 'Failed to update profile',
};
}
revalidatePath('/profile');
return { success: true };
}
```
### 3.5 Add Navigation Link
```tsx
// apps/dashboard/src/components/sidebar-nav.tsx
import { User, Settings, Home } from 'lucide-react';
const navItems = [
{ label: 'Dashboard', href: '/dashboard', icon: Home },
{ label: 'Profile', href: '/profile', icon: User },
{ label: 'Settings', href: '/settings', icon: Settings },
];
```
---
## Step 4: Testing
### 4.1 Write Handler Tests
```go
// services/api/internal/api/handlers/user_test.go
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"my-project/pkg/auth"
"my-project/services/api/internal/api/handlers"
"my-project/services/api/internal/service"
)
func TestUserHandler_GetMe(t *testing.T) {
svc := service.NewUserService(/* mock deps */)
handler := handlers.NewUserHandler(svc, slog.Default())
t.Run("returns user profile", func(t *testing.T) {
// Create request with auth context
req := httptest.NewRequest("GET", "/api/users/me", nil)
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
Email: "test@example.com",
}))
rr := httptest.NewRecorder()
handler.GetMe().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
data := resp["data"].(map[string]any)
if data["id"] != "user-123" {
t.Errorf("expected user-123, got %v", data["id"])
}
})
t.Run("returns 401 without auth", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/users/me", nil)
rr := httptest.NewRecorder()
handler.GetMe().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
})
}
func TestUserHandler_UpdateMe(t *testing.T) {
svc := service.NewUserService(/* mock deps */)
handler := handlers.NewUserHandler(svc, slog.Default())
t.Run("updates profile", func(t *testing.T) {
body := `{"name": "New Name", "avatar_url": "https://example.com/avatar.png"}`
req := httptest.NewRequest("PUT", "/api/users/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
}))
rr := httptest.NewRecorder()
handler.UpdateMe().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
})
t.Run("validates required name", func(t *testing.T) {
body := `{"name": ""}`
req := httptest.NewRequest("PUT", "/api/users/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
}))
rr := httptest.NewRecorder()
handler.UpdateMe().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
})
}
```
### 4.2 Run Tests
```bash
# Run all tests
./scripts/quality.sh
# Run specific service tests
go test ./services/api/...
# Run with coverage
go test -cover ./services/api/...
```
---
## Step 5: Review & Quality Checks
### 5.1 Run Code Review
Use the built-in review command:
```bash
# In Claude Code
/review-code
```
This checks for:
- Completeness and accuracy
- Tech debt indicators
- Maintainability issues
- Extensibility concerns
- DRY/CLEAN violations
### 5.2 Run Quality Checks
```bash
# Lint Go code
golangci-lint run ./...
# Lint frontend
cd apps/dashboard && npm run lint
# Type check
cd apps/dashboard && npm run typecheck
```
### 5.3 Verify OpenAPI Spec
```bash
# Start the service locally
cd services/api && make run
# Check the spec
curl http://localhost:8001/openapi.json | jq '.paths["/users/me"]'
# View docs
open http://localhost:8001/docs
```
---
## Step 6: Deployment
### 6.1 Commit Changes
```bash
# Stage changes
git add services/api/internal/api/handlers/user.go
git add services/api/internal/api/routes.go
git add apps/dashboard/src/app/\(dashboard\)/profile/
git add apps/dashboard/src/components/profile-form.tsx
git add apps/dashboard/src/actions/profile.ts
# Commit
git commit -m "feat: add user profile feature
- Add GET/PUT /api/users/me endpoints with Wrap pattern
- Add profile page with edit form
- Add server action for profile updates
- Add handler tests"
```
### 6.2 Push and Monitor CI
```bash
# Push feature branch
git push -u origin feature/user-profile
# Monitor pipeline
./cookbooks/scripts/feature-test.sh status my-project
```
### 6.3 Create Pull Request
```bash
gh pr create --title "feat: add user profile feature" --body "
## Summary
- Added user profile endpoints (GET/PUT /api/users/me)
- Added profile page with edit form
- Integrated with auth system
## Testing
- Handler tests added
- Manual testing complete
## Checklist
- [x] Handler tests pass
- [x] Quality checks pass
- [x] OpenAPI spec updated
"
```
### 6.4 Verify Deployment
After merge to main:
```bash
# Check deployment
curl https://my-project.threesix.ai/api/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
# Open the app
open https://my-project.threesix.ai/profile
```
---
## Pattern Quick Reference
### Wrap Pattern
```go
app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Return errors, they become proper HTTP responses
if err := doThing(); err != nil {
return httperror.WrapError(err, "failed to do thing")
}
httpresponse.JSON(w, http.StatusOK, result)
return nil
})
```
### Bind Pattern
```go
var req RequestType
if err := app.BindAndValidate(r, &req); err != nil {
return err // Validation errors become 400 with details
}
```
### HTTPError Sentinels
```go
httperror.BadRequest("invalid input")
httperror.NotFound("user not found")
httperror.Unauthorized("not authenticated")
httperror.Forbidden("access denied")
httperror.Conflict("already exists")
httperror.Internal("something went wrong")
httperror.Validation("validation failed")
```
### Auth Context
```go
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
```
### React Auth Hook
```tsx
const { user, isLoading, login, logout } = useAuth();
```
### Design System Components
```tsx
import { Button, Card, Input, Label, Badge } from '@my-project/ui';
import { DashboardShell, Sidebar, Header } from '@my-project/layout';
```
---
## E2E Test Script
Validate the complete feature development flow:
```bash
# Create test project with components
./cookbooks/scripts/feature-test.sh run test-feature
# Check status
./cookbooks/scripts/feature-test.sh status test-feature
# Cleanup
./cookbooks/scripts/feature-test.sh teardown test-feature
```
---
## Troubleshooting
### Handler returns 500 instead of proper error
Check that you're using `httperror.*` functions, not raw `errors.New()`.
### Validation errors not showing details
Ensure you're using `app.BindAndValidate()` which includes validation details.
### Auth middleware not applying
Verify route is inside the `r.Group()` with `auth.Middleware()`.
### Generated client out of sync
Run `./scripts/generate-client.sh` after OpenAPI changes.
### Frontend not seeing auth user
Check that `AuthProvider` wraps your app in `providers.tsx`.
---
## Chassis Framework
The `pkg/chassis` package provides a convenience facade over `pkg/app`:
```go
import "my-project/pkg/chassis"
svc := chassis.New("my-service", chassis.WithDefaultPort(8080))
```
It re-exports: `New`, `Wrap`, `WrapWithLogger`, `Bind`, `BindAndValidate`, `BindStrict`, `NewHealthHandler`, `PingChecker`, `HTTPChecker`.
## OpenAPI Documentation
Each service defines its spec in `internal/api/spec.go`:
```go
spec := openapi.NewOpenAPISpec("My API", "1.0.0").
WithBearerSecurity("bearer", "JWT token")
spec.WithSchema("User", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID(),
"email": openapi.Email(),
}, "id", "email"))
spec.AddPath("/api/v1/users/{id}", "get", map[string]any{
"summary": "Get user",
"tags": []string{"Users"},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("User"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
application.EnableDocs(spec) // Mounts /docs (Scalar UI) and /openapi.json
```
## Design System Components
Available from `@project/ui`:
| Component | Usage |
|-----------|-------|
| Button | Primary actions, variants: default, destructive, outline, ghost |
| Card | Content containers with CardHeader, CardContent, CardFooter |
| Input, Label | Form fields |
| Badge | Status indicators, variants: success, warning, error, info |
| Dialog | Modal dialogs |
| Table | Data tables |
| Select | Dropdowns |
| Alert | Notification banners, variants: default, destructive, success, warning |
| Textarea | Multiline input |
| DropdownMenu | Context menus with items, checkboxes, radio groups |
| Sheet | Slide-in panels (side: top, right, bottom, left) |
All use CSS custom properties: `var(--background)`, `var(--accent)`, `var(--border)`, etc.
---
## Related
- [Composable App Cookbook](./composable-app.md) - Creating projects with components
- [Landing Page Cookbook](./landing-page.md) - Simple single-component sites
- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md)
- [API Framework Guide](../.claude/guides/packages/api-framework.md)