A Rails app for engineering design doc review, purpose-built for AI-assisted planning. Plans get better through collaboration — domain experts leave inline feedback, AI agents respond to that feedback and apply edits automatically, and every change is versioned with full provenance. Humans comment, AI agents edit. Local agents interact via the REST API using skills (see coplan skill) and future CLIs.
Most of the application logic lives in the CoPlan Rails engine (engine/), packaged as the coplan gem (path-based, in Gemfile). The top-level Rails app is a thin host that provides deployment configuration, ActiveAdmin, and app-specific glue.
- Models — all domain models live in
engine/app/models/coplan/(Plan, PlanVersion, User, Comment, CommentThread, EditLease, EditSession, ApiToken, PlanCollaborator) - Controllers — web UI and API controllers in
engine/app/controllers/coplan/, includingapi/v1/for the REST API - Services — all service objects in
engine/app/services/coplan/(Plans::Create, Plans::ApplyOperations, AI providers, etc.) - Policies — authorization policies in
engine/app/policies/coplan/ - Jobs — background jobs in
engine/app/jobs/coplan/ - Views, helpers, assets, JS — all Hotwire views and Stimulus controllers
- Migrations — engine-owned tables go in
engine/db/migrate/. Data/backfill migrations supporting engine behavior also belong here (the gem only packagesengine/, so host-only migrations don't reach gem consumers); copy them into the host withbin/rails co_plan:install:migrations. - Routes — engine routes in
engine/config/routes.rb, mounted by the host
- ActiveAdmin — admin registrations in
app/admin/ - Auth —
SessionsController,Usermodel (legacy, being migrated toCoPlan::User) - App-specific integrations —
SlackClient,SlackNotificationJob - Migrations — this app is just the example/demo host, so
db/migrate/mostly holds the*.co_plan.rbcopies of engine migrations (the engine owns schema forcoplan_*tables) - Config — database, deployment, environment, seeds
When adding new features, put them in the engine unless they are deployment- or host-specific (admin UI, external integrations, auth).
- Rails with importmaps — no Node, no bundler, no Webpack, no esbuild
- Hotwire — Turbo Drive, Turbo Frames, Turbo Streams, Stimulus
- Plain CSS — no Tailwind, no preprocessors
- Plain JavaScript — via importmaps and Stimulus controllers only
- MySQL 8 — but schema must stay portable: hosts may run PostgreSQL, so no adapter-specific column options or SQL outside an adapter check (search is the worked example — see
Plan.adapter_searchand the AddSearchToCoplanPlans migration); nodefault:on JSON columns (useafter_initializein the model instead) - SolidQueue for background jobs, SolidCable for ActionCable
- ActiveAdmin 4 beta +
activeadmin_assetsfor admin UI — no node/tailwind needed - No Devise, no OmniAuth — auth is hand-rolled (stub OIDC in dev, real OIDC later)
- All tables use UUID primary keys (
id: :string, limit: 36), assigned inApplicationRecord#assign_uuid - All multi-tenant tables include
organization_id(FK, not null) - Enums are stored as strings (not integers) — validated with constants and
inclusion:validators - JSON columns for arrays/hashes (
tags,metadata,trigger_statuses,allowed_email_domains) - No PG-only types (
citext,text[],ON CONFLICT ... WHERE) — use Rails validations andjsoncolumns Current.organizationandCurrent.userare set per-request for scoping
- Define valid values as frozen constants on the model (e.g.,
Plan::STATUSES,Comment::AUTHOR_TYPES) - Use
inclusion:validations against those constants - Use
after_initializefor defaults on JSON array columns (e.g.,self.tags ||= []) - Service objects live in
app/services/namespaced by model (e.g.,Plans::Create) - Service objects use the
self.call+new(...).callpattern - Authorization uses plain policy objects in
app/policies/— not Pundit
- JavaScript goes in
app/javascript/controllers/as Stimulus controllers - No npm packages — everything through importmaps or inline
- Views use Turbo Frames for partial page updates, Turbo Streams for realtime broadcasts
- Keep it simple: no React, no Vue, no component libraries
The entire goal of this stack is an app that is plain HTML over the wire but feels indistinguishable from a native app. Server-rendered HTML is the implementation, never the excuse. Concretely, every interaction must meet this bar:
- No layout jiggle between navigations. Page width and shared chrome must
not shift when moving between pages (
scrollbar-gutter: stableguards the scrollbar case; keep shared containers dimensionally consistent). - No scroll jumps or full-page reloads for in-page actions. Submitting a
form, uploading a file, toggling state — the result appears in place
(Turbo Streams / fetch + partial swap), with progress feedback during the
wait.
redirect_to+ full reload for something the user did mid-page is a bug, not a fallback. - Immediate feedback for every action — optimistic UI, busy/progress states, pulses. Nothing may silently wait on a server round-trip.
- Instant-feeling navigation. Real navigations use Turbo Drive with hover prefetch; anything that can be preloaded on intent (hover, spring pulses) should be.
If an interaction scrolls, flashes, reflows, or stalls in a way a native app wouldn't, treat it as a bug — regardless of how idiomatic the Rails behind it is.
- Prefer server-rendered HTML with standard Stimulus bindings (
data-controller,data-action,data-*-target) — directaddEventListeneris a last resort, only when elements are created dynamically in JS and Stimulus can't bind to them (e.g., inline text highlights wrapping arbitrary DOM ranges). Even these cases should be revisited for server-side alternatives when practical.
- RSpec with
rspec-rails - Run the full suite:
bundle exec rspec - Spec files mirror app structure:
spec/models/,spec/requests/,spec/services/,spec/helpers/ - Use FactoryBot (
factory_bot_rails) — factories live inspec/factories/ - Every model, service object, and controller action should have specs
- UUID primary keys are auto-assigned by
ApplicationRecord#assign_uuid— do not setidin factories - Factories derive associations from parent objects (e.g.,
organization { plan.organization }) to keep data consistent - FactoryBot syntax methods (
create,build) are included globally viaconfig.include FactoryBot::Syntax::Methods sign_in_as(user)helper is defined inspec/rails_helper.rbfor request specs
db/seeds.rbmust be idempotent — usefind_or_create_by!or guard with count checks- Seeds should provide enough data to demo features from a fresh checkout
- Run the
code-reviewskill before considering a session complete - Address all feedback until the review passes
- New models get an ActiveAdmin registration in
app/admin/ - Define
ransackable_attributesandransackable_associationson models for ActiveAdmin search
- Plan statuses:
brainstorm → considering → developing → live(or→ abandoned) - Brainstorm plans are private; considering+ are published to the org
- Editing model: humans comment, AI agents apply edits via semantic operations (
replace_exact,insert_under_heading,delete_paragraph_containing) - Edit leases: one agent edits at a time, enforced by a lease with TTL
- Versions are immutable — every edit creates a new PlanVersion with full provenance
The comment system is central to the collaboration workflow. Domain experts leave inline feedback anchored to specific text in the plan, and the plan author triages that feedback.
- Reviewer comments start as
pending(awaiting author triage) - Author's own comments start as
todo(self-assigned work items) - Author triages pending feedback: Accept (
pending → todo) or Discard (pending → discarded) - Author marks completed work: Resolve (
todo → resolved) - Resolved/discarded threads can be Reopened back to
pending
- Highlights: anchored text is wrapped in
<mark>elements — amber forpending, blue fortodo, unstyled forresolved - Margin dots: colored indicators in the left margin aligned to each highlight's vertical position
- Thread popovers: native HTML Popover API (
popover="auto") showing the comment thread, reply form, and action buttons; positioned relative to the anchor and tracked on scroll - Comment toolbar: fixed bottom bar showing open thread count, j/k navigation, and a "Show resolved" toggle
j/k— navigate between open threads (scrolls to highlight, opens popover)r— focus the reply textarea in the current popovera— accept the current pending threadd— discard the current pending threadEnter— submit reply;Shift+Enter— newline
- Thread data is server-rendered as hidden
[data-anchor-text]elements in#plan-threads(via_thread_popover.html.erb) - The
text_selection_controllerreads that data and creates highlight marks + margin dots client-side (text range wrapping requires browser DOM APIs) - Status changes broadcast via Turbo Streams →
Broadcaster.replace_toreplaces the thread data in-place →MutationObserveron#plan-threadsre-runshighlightAnchors()to update the visual state