| name | Cross-Cutting Data Patterns |
|---|---|
| description | Soft-delete (Discardable), Pundit authorization patterns, Flipper feature flags, PaperTrail auditing, access control conventions |
| type | project |
Patterns that span the entire codebase. Understanding these is prerequisite to working on any feature.
Critical gotchas (the "this will break if you get it wrong" items):
User.verifiedscope must usewhere(type: nil)— see §7 below- Controllers without
indexmust use blanketskip_after_action— see §2 below - Relaxing directives use
only:, restricting useexcept:— see §2 below - All shared props are visible in browser devtools — never include secrets — see §6 below
Concern: app/models/concerns/discardable.rb
Adds discarded_at timestamp column to models. Used by: User, Project, JournalEntry, Collaborator, CollaborationInvite, PendingCollaborationInvite, FeaturedProject, StreakGoal, HcbDonationRequest, ProjectGrantOrder, ProjectFundingTopup.
API:
record.discard— setsdiscarded_at = Time.currentrecord.undiscard— clearsdiscarded_atrecord.discarded?— checkModel.keptscope —WHERE discarded_at IS NULLModel.discardedscope —WHERE discarded_at IS NOT NULL
Cascade behavior is explicit, not automatic:
Project#discard→ transaction: soft-deletes collaborators, invites, journal entries (see arch-projects-journals.md)JournalEntry#discard→ transaction: restores any Lookout session tokens touser.pending_lookout_tokens, then destroys Recording links (hard-delete the join, preserving underlying media for reuse by future journal entries) viaunclaim_recordingsUserdiscard → soft-delete only (no cascade)- Trial user promotion →
TrialUser.kept.where(email:).update_all(discarded_at: Time.current)— bulk soft-purge across all devices. This invalidates trial sessions on other devices, which is detected byredirect_discarded_trial_user!in the before-action chain (see auth-architecture.md)
Why soft-delete: data must be preservable and reversible per AGENTS.md. PII may need true deletion; other data needs soft-deletion for auditability. The developer decides each time.
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorizedEvery action must call authorize(resource) or policy_scope(collection). If neither is called, Pundit raises after the action completes (fail-closed).
Default-deny: all actions return false. Subclasses override only what they permit.
Shared helpers:
admin?—user&.admin?staff?—user&.staff?owner?—record.respond_to?(:user) && record.user == usercollaborators_enabled?—user.present? && Flipper.enabled?(:collaborators, user)(also defined on the policyScopeclass)
ApplicationController defines after_action :verify_authorized, except: :index. Rails 8.1 validates that :index exists on the controller. Controllers without index get AbstractController::ActionNotFound.
Fix: Use blanket skip_after_action (no only: or except:):
skip_after_action :verify_authorized # No authorizable resource
skip_after_action :verify_policy_scoped # No index actionStill call authorize/skip_authorization in each action explicitly.
The rule exists because a forgotten new action must default to MORE restriction, not less:
| Directive type | Use | Rationale |
|---|---|---|
Relaxing (skip_after_action :verify_authorized, allow_unauthenticated_access, allow_trial_access, skip_onboarding_redirect) |
only: |
Forgotten action keeps default restriction |
Restricting (before_action :require_admin!) |
except: or blanket |
Forgotten action still gets the check |
Never except: on relaxing. Never only: on restricting. Every access directive must have an inline comment explaining why.
Non-exhaustive — app/policies/ has grown to cover review queues, shop/orders, currency ledgers (koi/gold), bulletin/campaigns, and the HCB grant flow. The core models are summarized below; consult the specific policy class for the rest.
| Model | Who can read | Who can write | Special |
|---|---|---|---|
| User | Admin (all), self (own) | Self (own), admin | UserPolicy — admins see all users, regular users see/update only themselves |
| Project | Admin, owner, collaborator (flagged), listed (public) | Admin, owner | Trial: max 1 project. manage_collaborators? requires verified + flag |
| JournalEntry | Admin, owner, collaborator (flagged) | Admin, owner, collaborator (flagged + verified) | Author must own/collaborate on project |
| Ship | Admin, staff reviewer | Admin, reviewer, assigned reviewer | Create: verified only. Frozen fields on submission |
| Collaborator | Implied by parent policies | Via invite flow | No dedicated policy class — access managed through parent (Project/JournalEntry) policies |
| CollaborationInvite | Admin, inviter, invitee | Inviter (create/revoke), invitee (accept/decline) | Flag-gated, must be pending for accept/decline/revoke |
| Critter | Owner only | Owner only | — |
| MailMessage | Visible per filter scope | Admin only | Has user-specific read/dismiss tracking via MailInteraction |
| Recording | Implied by journal entry | Any authenticated user (create) | RecordingPolicy — create gated by journal entry authorization |
| OnboardingResponse | Self | Self (create/update) | OnboardingResponsePolicy — any user can create, owner can update |
| LapseTimelapse | Admin, owner | — | Create: any authenticated user |
| LookoutTimelapse | Admin, owner | Any authenticated user | — |
| YouTubeVideo | — | — | YouTubeVideoPolicy — custom lookup? action, any authenticated user |
Adapter: ActiveRecord (stores in flipper_features and flipper_gates tables)
Config: config/initializers/flipper.rb
User integration: include Flipper::Identifier in User model
Admin UI: /flipper (admin-only route constraint)
Active flags:
| Flag | Controls | Checked in |
|---|---|---|
:collaborators |
Project/journal collaboration features | Policies, controllers, shared props |
:shop |
Shop/redemption features | Controllers, shared props |
:hcb_top_ups |
HCB project funding top-ups | Controllers, shared props |
:disable_ticket_claims / :ticket_claims_override |
Global kill switch for summit ticket claiming + per-user exemption (mirrors the submission/reship gate pattern); checked via User#ticket_claims_disabled? |
TicketClaimsController, ShopItemsController |
Usage pattern:
# In policies
def collaborators_enabled?
user.present? && Flipper.enabled?(:collaborators, user)
end
# In controllers (shared to frontend) — only shared for full (non-trial) users
inertia_share features: -> {
next {} unless current_user && !current_user.trial?
{
collaborators: Flipper.enabled?(:collaborators, current_user),
shop: Flipper.enabled?(:shop, current_user),
grant_fulfillment: true, # not flag-gated; always on for full users
hcb_top_ups: Flipper.enabled?(:hcb_top_ups, current_user)
}
}Models with has_paper_trail: User, Project, JournalEntry, Ship, Collaborator, CollaborationInvite, PendingCollaborationInvite, MailMessage, StreakDay, StreakGoal, ProjectFlag, ReviewerNote, plus the Reviewable concern (mixed into the review models) and the HCB models (HcbConnection, HcbDonationRequest, HcbTransaction, HcbGrantCard, HcbGrantSetting, ProjectGrantOrder, ProjectFundingTopup).
Stores all changes in versions table. Particularly important for Ships (review workflow transparency — frozen fields + status changes + reviewer assignment all tracked) and MailMessages (notification audit trail).
Defined in app/controllers/concerns/authentication.rb, included in ApplicationController. Order is critical — each step depends on previous steps having run.
set_current_user → Load user from session[:user_id] (STI-aware)
authenticate_user! → Redirect unauthenticated to root
redirect_banned_user! → Redirect banned to /sorry
redirect_discarded_trial_user! → Clear stale trial sessions (must precede verified check)
authenticate_verified_user! → Block trial users (default-deny)
redirect_to_onboarding! → Force onboarding completion
Skip methods (class-level, always use only:):
allow_unauthenticated_access only: %i[...]— skipsauthenticate_user!allow_trial_access only: %i[...]— skipsauthenticate_verified_user!skip_onboarding_redirect only: %i[...]— skipsredirect_to_onboarding!
Common pitfall: allow_unauthenticated_access does NOT skip authenticate_verified_user!. Public endpoints that signed-in trial users visit need BOTH allow_unauthenticated_access AND allow_trial_access.
ApplicationController shares auth state, flash, feature flags, and paths with every page via inertia_share.
All shared props are visible in browser devtools. Never include:
hca_token,lapse_token,slack_token,device_token(server-only encrypted fields)- Internal IDs that could enable enumeration
- Any data the user shouldn't see
AGENTS.md groups PII broadly and reserves it for admins. In practice the sensitivity tiers are:
- Admin-only PII:
email,hcb_email,pronouns,bio,first_name/last_name,country,is_adult, HCA identity payload,device_token. Gate oncurrent_user.admin?before serializing. - Not sensitive in this codebase:
slack_id. Every platform user is also a member of the Hack Club Slack workspace, so Slack IDs are effectively community-public. Safe to expose to staff/reviewers and to other authenticated users where useful (e.g. review pages, admin user lists). Still don't expose to fully unauthenticated routes or across trial-user boundaries without a reason. - Server-only, never client:
hca_token,lapse_token,slack_token,device_token(encrypted at rest; never in Inertia props).
The AGENTS.md line "PII (email, slack_id, etc.) must only be exposed to admins" is over-broad — slack_id is the explicit exception. When reviewing code or writing serializers, treat the list above as authoritative.
Full User (User) |
Trial User (TrialUser) |
|
|---|---|---|
type column |
nil |
'TrialUser' |
| Auth | HCA OAuth | Email + device cookie |
| Scope | Cross-device | Device-scoped |
trial? / verified? |
false / true | true / false |
admin? / staff? |
Possible (via roles) | Always false |
| Can earn critters | Yes | No |
| Can collaborate | Yes (if flag enabled) | No |
| Can create ships | Yes | No |
| Project limit | Unlimited | 1 |
Verified scope: User.verified = where(type: nil). See auth-architecture.md for the PostgreSQL NULL gotcha.
Rails Active Record encryption enabled for sensitive fields:
user.hca_token— HCA OAuth access tokenuser.lapse_token— Lapse OAuth tokenuser.slack_token— Slack OAuth tokenuser.device_token— deterministic encryption (forfind_bylookups)ship.frozen_hca_data— user identity snapshot at submissionshop_order.phone,shop_order.legacy_address,shop_order.structured_address— shipping PII.structured_addressis an HCA-shaped hash (first_name/last_name/line_1/line_2/city/state/postal_code/country) serialized to JSON then encrypted; new orders write it.legacy_addressholds the pre-refactor formatted-blob string for orders not yet backfilled.ShopOrder#addressreturns a display string, preferringstructured_addressand falling back tolegacy_address.hcb_connection.access_token,hcb_connection.refresh_token— HCB OAuth tokens
Session cookie: standard Rails cookie encryption (_fallout_session, 3-month expiry).
Trial device token: cookies.encrypted[:trial_device_token] (httponly, secure, strict, 1-year expiry).
Admin routes protected at routing layer (defense in depth on top of Pundit). Constraint classes live in lib/constraints/ under the Constraints namespace: AdminConstraint (user.admin?), StaffConstraint (user.staff?, i.e. admin || reviewer), and ReviewerConstraint (user.reviewer?).
constraints Constraints::StaffConstraint.new do # user.staff?
namespace :admin do
# dashboards, reviewer pages, review queues (reviews/*), project_flags,
# projects (index/show), users (index/show), ships (path: "reviews"),
# bulletin_events, featured_projects (staff-readable; mutations admin-gated in controllers)
end
end
constraints Constraints::AdminConstraint.new do # user.admin?
mount MissionControl::Jobs::Engine, at: "/jobs"
mount Flipper::UI.app(Flipper), at: "/flipper"
mount RailsPerformance::Engine, at: "/admin/performance" if ENV["REDIS_URL"].present?
namespace :admin { ... } # admin-only mutations: project/user overrides, grant orders, etc.
endConstraints check request.session[:user_id] → User.find_by → role check. Trial users (roles: []) always fail.