Rails 8 application for international students seeking document-equivalence (homologación) recognition in Spain. Students submit requests, upload documents, and chat with a super admin who shepherds the case through Spanish bureaucracy.
spaceforedu.com— marketing/landing site (separate repo)app.spaceforedu.com— this Rails app
Never run git commit. Only the human owner commits. Edit files, run git status / git diff / git log, and git add only when explicitly asked. Leave finished work in the working tree and report the diff.
NOT A SINGLE LINE OF CODE WITHOUT QUALITY TESTS FIRST. NON-NEGOTIABLE.
Every behavior change goes through Red → Green → Refactor. There is no other way to write code in this project.
The cycle, every time:
- Red — write one minimal test that describes the desired contract. Run it. It must fail. Show the failing test to the user before writing any implementation.
- Green — write the simplest implementation that turns that one test green. No extra code. No anticipation of the next test. No "while I'm here" cleanups.
- Refactor — clean up with the green test bar held throughout. Run tests after every change. If nothing needs cleaning, skip this step.
Hard rules — these are violations of doctrine:
- No implementation without a failing test in front of it. Writing the model method first and then "adding tests to cover it" is test-after, not TDD. It is forbidden.
- One test per iteration. Never write 5 tests up front and then 5 implementations. Red → Green → Refactor → next test. The discipline only works one cycle at a time.
- Tests describe observable behavior, not implementation details. A test that asserts "after
confirm_payment!,pipeline_stageequalspago_recibido" is a contract. A test that asserts "the YAML hash has 8 keys" is internal trivia. Write the first kind, never the second. - Tests must be high-quality, not just present. A test is high-quality when: (a) it would fail if the behavior broke, (b) its name describes the behavior in plain language, (c) the arrange/act/assert structure is obvious at a glance, (d) it does not duplicate another test's coverage. A test that passes regardless of the implementation is not a test — it is theatre. Delete it.
- Minimum viable implementation in the Green step. If the test says "advance from
documentosreturnstraduccion",def advance = "traduccion"is correct on that step. Generalization comes from the next test, not from imagined needs. - Bug fix = failing test first. Reproduce the bug as a red test, then fix. A bug fix without a regression test is not done.
- Refactor never changes behavior. If you need to change behavior, that's a new Red test. The same suite must pass before and after refactoring, byte-for-byte.
- Do not skip TDD because something is "trivial". Renames are trivial; getters are trivial; constants are trivial. The discipline is the point. Slipping on small things is how the dam breaks.
Pause points — agent must stop and show the user:
- After Red: show the failing test and its output, before writing implementation.
- After Green: show the diff and the green test run.
- After Refactor: show the diff and the green test run.
The user approves each step. The agent does not chain Red → Green → next Red without the user seeing the green in between.
Coverage requirements:
- Every model method that changes state — model test.
- Every controller action — controller test (status, redirect/render, side effects).
- Every Pundit policy method — policy test.
- Every Turbo Stream broadcast — system test that watches the DOM update.
- Every background job — job test that asserts the work was performed.
If a piece of behavior cannot be tested, that is a design problem — fix the design, not the test.
Ship and validate the core homologation flow with real students before adding anything else.
The flow, in order:
- Student registers, completes profile, accepts privacy policy
- Student creates a
HomologationRequest, fills in details, uploads documents - Super admin reviews, changes status, leaves internal notes
- Student and admin exchange messages in the request's conversation
- Admin records a Stripe invoice; student pays; admin confirms payment
Hard rules until step 5 works end-to-end with real users:
- No new models, tables, or domain concepts without explicit instruction
- No third-party integrations beyond Stripe and Telegram (no OAuth, analytics, S3)
- No roles beyond
super_adminandstudent - No Redis, no Sidekiq — Solid Queue is the only background system
- A thin slice that works beats a wide surface that doesn't
Out of scope: teachers, coordinators, lessons, study plans, multi-tenancy, organizations, public API, mobile app.
- Rails 8.1 — full-stack, server-rendered (no API mode, no React, no Inertia)
- Hotwire — Turbo Drive + Frames + Streams + Stimulus
- Tailwind CSS 4 + Daisy UI 5 — UI built from Daisy component classes (a deliberate choice on top of omakase Tailwind)
- Propshaft + import maps — no Sprockets, no webpack/Vite/esbuild
- SQLite — primary database; Solid Queue (jobs), Solid Cable (broadcasts), Solid Cache (cache); no Redis
- Litestream — continuous SQLite replication to off-box storage; the only backup mechanism. A single-node Docker host means a lost disk is a lost app — the replica is the recovery story.
- Active Storage — uploads on local disk, served through controllers
- Authentication — Rails 8
bin/rails generate authentication(no Devise, no OAuth) - Authorization — Pundit, two roles only:
super_admin,student - Pagination — Pagy
- Payments — Stripe (
stripegem; admin records invoices, student pays, signed webhook confirms) - Notifications — in-app Turbo Streams + email (Action Mailer; SMTP in production,
letter_openerin development) + Telegram Bot API (raw HTTP) - i18n —
es(default),en,ru; translation keys in English;rails-i18nfor locale data - Secrets — Rails encrypted credentials (
config/credentials/<env>.yml.enc), keyed per environment. No secrets in ENV in dev or prod beyondRAILS_MASTER_KEY. - Deployment — Kamal + Thruster, single-node Docker
- Safety nets —
strong_migrations(block dangerous migrations),rack-attack(auth + webhook rate limits),bullet(dev N+1),bundler-audit+brakeman(CI) - Testing — Minitest + Capybara + Selenium + Rails fixtures +
webmockfor Stripe/Telegram
bin/setup # install gems, create DB, migrate, seed
bin/dev # Puma + Tailwind watcher + hotwire-spark hot reload
bin/ci # rubocop → bundler-audit → importmap audit → brakeman → tests → system testsRun bin/ci before every commit. CI is wired through config/ci.rb (ActiveSupport::ContinuousIntegration).
bin/rails test test/path/file_test.rb
bin/rails test:system
bin/rails db:migrate
bin/rails db:seed # creates super_admin admin@example.com (see db/seeds.rb)
bin/rails db:resetOutbound mail in development opens in the browser via letter_opener.
Rails 8 built-in auth generator. Tables: users, sessions. has_secure_password on User. Login uses email_address (not email). Password reset via Action Mailer + signed token.
Current.session is set per request by the Authentication concern; Current.user is delegated through it. Reach for Current.user in models that genuinely need actor context (audit columns, broadcasts). Don't pass current_user through five layers of method signatures.
Single role string column on users, enforced by a DB check constraint.
| Role | Root path | Purpose |
|---|---|---|
super_admin |
/dashboard |
Reviews requests, runs payments, manages users |
student |
/requests |
Submits requests, uploads documents, chats with admin |
authorize @record in every controller action. policy_scope in every index. No role checks in views — call policy(@record).action?.
User — all roles in one table.
roleenforced by check constraint (super_adminorstudent)discarded_atfor soft delete (always query through.kept)- Encrypted PII (Active Record encryption):
phone,whatsapp,guardian_phone,guardian_whatsapp - Minor support:
is_minor,guardian_name,guardian_email - Telegram opt-in:
telegram_chat_id,telegram_link_token,notification_telegram - GDPR:
deletion_requested_atflags a pending erasure; the row is kept, PII is nullified or re-encrypted,discarded_atis set.
HomologationRequest — core model. Two parallel lifecycles:
- Status (visible to student) — request lifecycle:
draft → submitted → in_review ⇄ awaiting_reply → awaiting_payment → payment_confirmed → in_progress → resolved | closed - Pipeline (super_admin only) — physical processing of documents after payment. See
### Pipeline (super_admin)below.
The DB check constraint enforces the set of valid statuses. The model's transition_to!(new_status, changed_by:) is the only sanctioned write path for status. Never update(status: ...) directly. Today the method validates the set; transition edges are policed by callers — when reverse edges become a real concern, the edge map moves into transition_to!. Audit columns status_changed_by / status_changed_at are written by the same method; payment_confirmed_by / payment_confirmed_at are written by confirm_payment!.
confirm_payment!(confirmed_by:) does three things atomically: sets payment audit columns, transitions status to payment_confirmed, and seeds pipeline_stage with PipelineFlow::STARTING_STAGE ("pago_recibido"). Pipeline never starts without a confirmed payment.
Soft delete via discarded_at. Always query through .kept.
Three Active Storage attachments:
application_file—has_one_attached, the official application PDForiginals—has_many_attached, original certificates/diplomasdocuments—has_many_attached, supporting paperwork
document_checklist is a JSON column (text + serialize :document_checklist, coder: JSON) — boolean flags keyed by short codes from config/pipeline.yml#checklist_keys. View access through request.checklist_done?(key), never dig from a template.
Conversation — one per HomologationRequest. last_message_at for ordering. student_last_read_at and admin_last_read_at columns drive unread counts (no join table).
Message — belongs_to :conversation, belongs_to :user, has_many_attached :attachments. Broadcasts via after_create_commit.
Notification — in-app, polymorphic on notifiable (notifiable_type / notifiable_id; today: HomologationRequest, Message). Broadcasts via Turbo Streams on create. Delivered async to email and Telegram via NotificationDeliveryJob. read_at and emailed_at track delivery.
Pipeline is the admin's physical processing workflow over a request after payment is confirmed. It is not status — status is the request's visible lifecycle to the student; pipeline_stage is the admin's internal map of where the paperwork is.
Stages live in config/pipeline.yml, grouped by display:
display: kanban (linear path) |
display: horizontal (final group) |
|---|---|
pago_recibido → documentos → traduccion → tasas_volantes → redsara |
cotejo_ministerio / cotejo_delegacion → completado |
After redsara the path branches by user.country: countries listed in config/pipeline.yml#cotejo.ministerio_countries go to cotejo_ministerio; everyone else routes to cotejo.default (cotejo_delegacion). Both cotejo branches converge on completado.
PipelineFlow is the single source of truth for stage traversal. It reads config/pipeline.yml once per process (PipelineFlow.reload! for tests) and exposes:
next_stage(current, country:)/previous_stage(current, country:)— branching is hidden from callerskanban_stages/horizontal_stages/all_stages— derived from YAML, not hard-coded in views or controllerscotejo_for(country)— branch resolver
Two write methods on HomologationRequest, both stamping pipeline_changed_at / pipeline_changed_by:
advance_pipeline!(changed_by:)— moves to the next stage; raisesInvalidTransitionpastcompletado. Bound to the→button on the kanban card.retreat_pipeline!(changed_by:, reason:)— moves back one stage.reasonis required (ArgumentErroron blank). The retreat is appended topipeline_notesas[ts] email old_stage → new_stage: reasonso the audit trail is permanent. Bound to a separate "↩ откат" button that opens a form requiring the reason — never a one-click action.
Each pipeline transition is its own resource:
namespace :admin do
resource :pipeline, only: :show
resources :homologation_requests, only: [], module: :homologation_requests do
resource :pipeline_advance, only: :create
resource :pipeline_retreat, only: :create
end
endDrag-and-drop is deliberately not implemented. If kanban-buttons stop being enough after real usage, drag is added as a second iteration: explicit edge map in advance_pipeline!, server-side validation of the drop, optimistic UI rollback on error, system test with real drag_to.
Authorization: PipelinePolicy#show? and HomologationRequestPolicy#manage_pipeline? — both super_admin? only. Index controller uses policy_scope so verify_policy_scoped is satisfied.
No polling. No custom WebSocket code. Turbo Streams + Action Cable + Solid Cable.
<%= turbo_stream_from @conversation %>
<div id="messages"><%= render @messages %></div>class Message < ApplicationRecord
after_create_commit -> { broadcast_append_to conversation }
endNotificationDeliveryJob # email + Telegram fan-out
Solid Queue runs in-process; surfaced via Mission Control in production.
Local disk via Active Storage. All downloads go through a controller that calls authorize before serving the blob. Never expose rails_blob_url or rails_blob_path to authenticated users — those URLs leak access.
The stripe gem talks to Stripe directly. The webhook endpoint verifies the signature with Stripe::Webhook.construct_event against the secret in credentials; an unsigned, malformed, or replayed payload is dropped before any database write. Webhook handlers are idempotent — keyed on the event id — so retries are safe. webmock stubs Stripe in tests; the network is never hit.
Locale priority: current_user.locale → Accept-Language → :es. All visible text via t(), all dates via l(). Translation keys are in English.
redirect_to @request, notice: t("flash.request_submitted")
l @request.created_at, format: :longKamal deploys a single Docker image. Thruster fronts Puma for asset caching, compression, and X-Sendfile. SQLite, Solid Queue, Solid Cable, Solid Cache, and Active Storage all live on the same disk. Litestream replicates the SQLite files continuously off-box; the replica is the disaster-recovery story.
Daisy classes directly in ERB. Extract a partial when something repeats — no view components, no presenters, no decorators. Logic belongs in models and controllers; views read like the page they render.
Helpers are reserved for view-only formatting that takes a value and returns a string (an icon name, a date band). Anything that needs the database, the current user, or a method chain longer than two stops belongs on the model. View-only data about a record (the CSS class for a status, a humanized label) lives on the model so views stay declarative.
The DaisyUI theme is corporate (set in app/views/layouts/application.html.erb via data-theme). Clean blue primary, sharp 0.25rem radii, no default shadows. The look is "official document service" — banking/government-adjacent, not playful.
Hierarchy. One H1 per page (text-2xl font-bold). One subtitle level — text-sm uppercase tracking-wide opacity-60. Never mix three weights/sizes for headings on the same screen. If you want emphasis, use color (text-primary, text-error), not another size.
Cards. bg-base-100 border border-base-300. No shadows. If everything has shadow, shadow conveys nothing. Use card for the container; card-body for padding. Don't reach for card-title — write a normal <header> with text-sm uppercase label so cards stay consistent.
Borders, not shadows. Separation between elements is a border (1px), divide-y, or divide-x. Reserve shadow for ephemeral overlays only (modals, dropdowns).
Spacing scale (memorize this).
- Page sections:
gap-6 - Within a card section:
space-y-3 - Form fields in a row:
gap-2 - Inline meta (badge + label):
gap-3 - Between rows in a list:
divide-y(nogap)
Density. Admin pages favor density. Use btn-sm, select-sm, input-sm by default. btn (default size) only for primary page actions. No btn-lg outside marketing.
Color tokens. Use DaisyUI semantic tokens only: bg-base-100/200/300, text-base-content, border-base-300, primary/success/warning/error/info. Never raw Tailwind colors like bg-blue-500, text-gray-700, border-slate-200. For muted text use opacity-60, never two opacities stacked (parent opacity-70 + child opacity-60 compounds to 0.42 — invisible).
One accent per card. If status is success, the badge is badge-success — buttons stay btn-primary or btn-ghost. Don't paint three semantic colors in one card.
Badges. badge (default size) for counts and inline labels. badge-lg only for the primary status indicator of a page or card — one per card max. badge-sm only for tight inline counts inside dense lists.
Buttons. btn-primary for the one main action. btn (no modifier) for secondary. btn-ghost btn-sm for tertiary nav-like actions. Destructive — btn-error (only on confirmed destructive operations, never on "Cancel"). Never btn-success for "Save" — primary is for save.
Icons. Heroicons inline-SVG, h-4 w-4 or h-5 w-5. Stroke width 1.5 or 2, never mixed within a page. Don't add icons to text buttons unless the icon clarifies the action (▶ "Advance" is OK; 💾 "Save" is noise).
Hover and transitions. hover:bg-base-200 transition on rows; hover:border-primary on selectable cards. Don't add hover to non-interactive surfaces — hover on a static card lies about affordance. Transitions are transition (~150ms), no longer.
Forms.
input input-bordered,select select-bordered,textarea textarea-bordered— always with-bordered.- Use Tailwind
flex gap-2 items-endfor input + submit, never DaisyUIjoin(it stretches all children to max height, breaking buttons next to textareas). - Validation errors:
text-error text-smunder the field.
Layout.
- Page-level layout uses Tailwind utilities (
grid,flex,gap-*). - DaisyUI layout helpers (
hero,drawer,join) are forbidden in app pages — they constrain composition for marketing patterns we don't have. - Heights: avoid
h-[calc(100vh-...)]. Use the flex chainhtml.h-full → body.flex flex-col → main.flex-1 min-h-0 → container.h-full flex flex-coland let children takeflex-1.
DaisyUI components — the safe set.
Use freely: btn, badge, card, input, select, textarea, checkbox, progress, avatar, modal, divider, dropdown, menu, navbar, stats, table.
Avoid (fragile in DaisyUI 5): tabs (broken with radio inputs in some configs — prefer stacked sections), chat (inflexible — wrap in plain Tailwind for our chat needs), join (kills cross-axis sizing), hero, drawer.
When in doubt, simplify.
- Remove a card border before adding one.
- Remove a heading level before adding one.
- Remove an icon before adding one.
- Two visible-state indicators on the same row = one too many. Pick one.
Page header:
<%= render "shared/page_header", title: t("nav.requests") %>Card:
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wide opacity-60"><%= t("requests.sections.summary") %></h3>
<span class="badge <%= @request.status_badge_class %>"><%= t("requests.status.#{@request.status}") %></span>
</header>
<p class="mt-2 text-base-content/70 text-sm"><%= l @request.created_at, format: :short %></p>
<div class="card-actions justify-end mt-3">
<%= link_to t("actions.view"), @request, class: "btn btn-primary btn-sm" %>
</div>
</div>
</div>Table:
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table">
<thead>
<tr>
<th><%= t("requests.subject") %></th>
<th><%= t("requests.status_label") %></th>
<th></th>
</tr>
</thead>
<tbody>
<%= render @requests %>
</tbody>
</table>
</div>Status badge — CSS class lives on the model:
<span class="badge <%= @request.status_badge_class %>">
<%= t("requests.status.#{@request.status}") %>
</span>Standard form_with. Server-side validation. On failure: render :new, status: :unprocessable_entity.
<%= form_with model: @request do |f| %>
<%= f.text_field :subject, class: "input input-bordered w-full" %>
<%= f.submit t("actions.save"), class: "btn btn-primary" %>
<% end %>button_to for state changes. Never fetch(), never axios.
<%= button_to t("actions.submit"), submit_request_path(@request),
method: :patch, class: "btn btn-primary" %>Always use dom_id for frame ids — never hand-roll strings.
<%= turbo_frame_tag dom_id(@request, :status) do %>
<span class="badge"><%= t("requests.status.#{@request.status}") %></span>
<% end %>def create
@message = @conversation.messages.create!(message_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
endIn the application layout:
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>When an action doesn't map to CRUD, introduce a new resource — not a custom action.
# Bad
resources :requests do
post :confirm_payment
post :archive
end
# Good
resources :requests do
resource :payment_confirmation, only: %i[create]
resource :archival, only: %i[create destroy]
endThin. Pundit on every action. .kept on every query. Pagy on every index that can grow.
def show
@request = HomologationRequest.kept.find(params[:id])
authorize @request
end
def index
scope = policy_scope(HomologationRequest).kept.includes(:user).order(updated_at: :desc)
@pagy, @requests = pagy(scope)
endRails validations can be bypassed. DB constraints cannot. Every migration ships with the right null:, indexes, foreign keys, and check constraints. strong_migrations blocks anything dangerous in CI.
add_column :users, :email_address, :string, null: false
add_index :users, :email_address, unique: true
add_foreign_key :homologation_requests, :users
add_check_constraint :homologation_requests,
"status IN ('draft','submitted','in_review','awaiting_reply','awaiting_payment','payment_confirmed','in_progress','resolved','closed')",
name: "valid_status"scope :kept, -> { where(discarded_at: nil) }Never hard-delete records that have downstream history (users, requests, messages, payments). GDPR deletion = nullify or re-encrypt PII, set discarded_at, keep the row.
Rack::Attack throttles login, password reset, registration, and the Stripe webhook endpoint. Rules live in config/initializers/rack_attack.rb. Every new authentication-adjacent endpoint gets a throttle.
Order private methods top-to-bottom by how they are called.
def submit!
validate_documents
transition_to_submitted
end
private
def validate_documents; end
def transition_to_submitted; endNo newline under private. Indent methods beneath it.
private
def normalize_phone(number)
number.gsub(/\D/, "")
endException: modules where everything is private — private at top, blank line, no indent.
module User::Notifications
private
def notify_by_email(message) = ...
def notify_by_telegram(message) = ...
endUse ! only when (a) a non-bang counterpart exists, or (b) the method is a stricter version of a Rails idiom that follows the convention itself (update!, save!, find_by!). Don't decorate methods with ! for emphasis.
Break large models into concerns under app/models/<model>/. One concern, one behavior — HomologationRequest::StatusNotifications, not HomologationRequest::Helpers. Wire them in with include at the top of the model.
Jobs are shallow and delegate to the model. _later enqueues, _now runs synchronously.
module HomologationRequest::StatusNotifications
extend ActiveSupport::Concern
included do
after_update_commit :deliver_status_notification_later, if: :status_previously_changed?
end
def deliver_status_notification_later = NotificationDeliveryJob.perform_later(self)
def deliver_status_notification_now
NotificationMailer.status_changed(self).deliver_now
TelegramNotifier.notify(self) if user.notification_telegram?
end
end
class NotificationDeliveryJob < ApplicationJob
def perform(request) = request.deliver_status_notification_now
endThe TDD discipline is defined at the top of this document under TDD — IRON RULE. That section is the contract; what follows here is the toolchain.
Concrete patterns, conventions, and common pitfalls are documented in doc/testing.md. Read it before writing any test involving file uploads, ActiveStorage, or Turbo Stream responses.
- Minitest for unit, controller, integration, and mailer tests
- Capybara + Selenium for system tests covering the 5 MVP flow steps
- Rails fixtures (YAML) — no FactoryBot, no Faker
webmockstubs Stripe and Telegram in tests; never hit the network- Tests run in parallel by default; fixtures are written to be parallel-safe
bin/rails testfor unit/controller;bin/rails test:systemfor system;bin/ciruns everything
- Writing implementation code without a failing test in front of it — see TDD — IRON RULE at the top
- Test-after (writing tests for code that already exists) — that is not TDD, it is theatre
- Skipping the user-visible Red → Green → Refactor pause points — the agent must show the failing test, then the green diff, then the refactor (if any), waiting for user acknowledgment between phases
fetch()/axios— use Turbo +button_to/form_withrender json:outside the Stripe webhook controller — no API mode; respond with HTML orturbo_streamwindow.location— useredirect_toor Turbo- Hardcoded strings in views — use
t() - Hardcoded URLs in views — use route helpers (
@request,request_path) - Role checks in views without Pundit — use
policy(@record).action? - Skipping
authorizeorpolicy_scopein any controller action - ViewComponent / Phlex — use ERB partials
- Service objects, presenters, decorators, query objects, form objects — put it on the model
- FactoryBot / Faker — use Rails fixtures
update_column/update_columns/update_all/delete_all— bypasses callbacks, validations, and soft delete- Hard-deleting
User,HomologationRequest,Message, or payment rows - Writing
statusdirectly — go throughtransition_to! - Writing
pipeline_stagedirectly — go throughadvance_pipeline!/retreat_pipeline!.confirm_payment!is the only other writer (it seeds the pipeline) - One-click pipeline retreat —
retreat_pipeline!requires a non-blankreason; the UI must collect it through a form, not pass a hardcoded string - Hard-coded pipeline stage lists in controllers or views — read from
PipelineFlow(kanban_stages,horizontal_stages,all_stages) dig-ing intodocument_checklistfrom a template — callrequest.checklist_done?(key)- New
app/javascript/files for behavior expressible as a Stimulus controller - Secrets in ENV beyond
RAILS_MASTER_KEY— use Rails encrypted credentials