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>
22 KiB
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
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
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
## 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/:
// 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:
// 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
// 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
// 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:
./scripts/generate-client.sh
This updates packages/api-client/src/schema.d.ts with typed endpoints.
3.2 Create Profile Page
// 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
// 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
// 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
// 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
// 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
# 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:
# 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
# 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
# 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
# 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
# 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
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:
# 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
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
var req RequestType
if err := app.BindAndValidate(r, &req); err != nil {
return err // Validation errors become 400 with details
}
HTTPError Sentinels
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
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
React Auth Hook
const { user, isLoading, login, logout } = useAuth();
Design System Components
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:
# 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:
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:
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 - Creating projects with components
- Landing Page Cookbook - Simple single-component sites
- Composable Monorepo Guide
- API Framework Guide