Rails backend of Lago. PostgreSQL and Redis are required; ClickHouse, Kafka and a premium license are optional, so code must also work without them.
This repository is public. Code, comments, specs, commits and PRs must not contain customer data, secrets or internal URLs, and ticket IDs stay out of code and comments.
The app runs with Docker Compose from getlago/lago, where lago is an alias for docker compose -f docker-compose.dev.yml (see its docs/dev_environment.md). Run commands in the api service. Specs use the lago_test database through DATABASE_TEST_URL, which only the api service sets. In a worker container the test environment falls back to DATABASE_URL, the development database lago, and the suite empties it.
| Task | Command |
|---|---|
| Run specs | lago exec api bundle exec rspec spec/path/to_spec.rb:42 |
| Lint | lago exec api bundle exec rubocop -a <changed files> |
| Create a migration | lago exec api bin/rails g migration AddStatusToContracts |
| Migrate PostgreSQL and ClickHouse | lago exec api bin/rails db:migrate |
Dump schema.graphql and schema.json |
lago exec api bin/rails graphql:schema:dump |
| Refresh model annotations | lago exec api bundle exec annotaterb models |
| Normalize locale files | lago exec api bundle exec i18n-tasks normalize |
Before finishing a change:
- The specs that cover it pass, and only those run (see "Specs" for what counts as a passing run). CI runs the full suite, lints the changed
.rbfiles and runsbin/rails zeitwerk:check. rubocop -apasses on the changed files.- The generated files below are regenerated.
- A convention the change adds, or a rule of this file it makes wrong, is updated here in the same PR.
Running commands:
- The container mounts the main checkout at
/app. For a git worktree inside it, add-w /app/<worktree path>afterexec. The worktree also needs the gitignored.env,config/master.keyandconfig/keysof the main checkout: link them with relative symlinks so they resolve inside the container (config/keyssits one level deeper than.env, so its target has one more../). Create the symlinks from the container: one created or changed on the host can keep its old target inside it. Worktrees share the databases of the main checkout, so checkbin/rails db:migrate:statusbefore migrating: migrations applied from other branches end up in thedb/structure.sqldump. When the worktree'sGemfile.lockdiffers from the main checkout's, runbundle installfrom the worktree first: the container's gems follow the main checkout. - Specs run from a worktree take
LAGO_DISABLE_SCHEMA_DUMP=true ANNOTATERB_SKIP_ON_DB_TASKS=true. When the branch's schema differs fromlago_test, the suite reloads the test database, and without these variables it also rewritesdb/structure.sqland the model annotations of the worktree. When another run holdslago_test(lock waits inpg_stat_activity), pointDATABASE_TEST_URLat a throwawaylago_test_<name>database, prepare it withRAILS_ENV=test bin/rails db:create:primary db:schema:load:primary, run the specs, thenRAILS_ENV=test bin/rails db:drop:primary. Load the schema withoutLAGO_DISABLE_SCHEMA_DUMP: the test config readsschema_dumpfor the load too, so the variable turns the load into a no-op. - Without Docker, the same commands run without the
lago exec apiprefix.config/environments/test.rbloads.env, whose template.env.distpointsDATABASE_URLat a locallago_testdatabase. Redis must be reachable too, and ClickHouse forclickhouse: truespecs;.github/workflows/spec.ymlshows the other variables and the RSA keys the suite needs.
CI fails when these files are stale, so regenerate and commit them with the change that causes them:
db/structure.sqland the schema annotations at the bottom of models, after a migration (db:migrateupdates both in development). CI also runs every migration on an empty database.schema.graphqlandschema.json, after any GraphQL change. This includes a new permission, enum value or webhook type.config/locales/**/*.yml, edited by hand, then normalized withi18n-tasks normalize.
Never edit the schema files or annotations by hand. Revert changes that do not come from your change, such as annotation churn or a db/structure.sql dumped from a local or test database. Running specs with pending migrations migrates the test database and rewrites both.
- Tenancy. Load tenant data through its organization:
current_organization.customers.find_by(...)orModel.where(organization:). Every new table has aNOT NULLorganization_idwith a foreign key, not an organization reachedthrough:another table.spec/organization_id_column_spec.rbfails when the column is missing (it does not checkNOT NULLor the foreign key); never add a table to its skip list. - Money. Amounts are integer
*_amount_centscolumns next to a currency. Keep unrounded values asBigDecimal(precise_*_amount_cents) and round once, at the end. Never useFloatfor money. - Dates. Billing dates follow the customer timezone (
customer.applicable_timezone,*_in_customer_timezonehelpers), notTime.zone, UTC or a bare.to_date. Billing periods come fromSubscriptions::DatesService. - Compatibility. SDKs, lago-front and customers rely on the REST API, the webhook payloads and the GraphQL schema. Add optional fields and parameters; do not rename or remove existing ones.
- Breaking changes. Before rejecting input that the API accepts today (a new validation, a tighter limit, a stricter format), check how production requests and data use it, not only the code and the docs. When the input is in use, keep accepting it (for example by falling back to the previous behaviour) or put the change behind a setting, and say in the pull request what was checked.
- Rolling deploys. Jobs enqueued by the previous version run on the new one, so a renamed job class or a changed
performsignature must keep accepting old jobs for one release. A new validation must accept existing rows (validates :email, ..., if: :email_changed?). - Ruby version. getlago/lago builds this app for its single-container image on its own Ruby (
RUBY_VERSIONindocker/Dockerfile). A change to.ruby-versionor theGemfilerubyrequirement needs the same version there, or that image stops building. - Input. Cast and validate input before using it (
ActiveModel::Type::Boolean.new.cast, enum values). Bad input returns a validation error, never a 500. Never interpolate input into SQL: use bind parameters orconnection.quote. - Feature gating. Premium features check
License.premium?. Premium integrations checkorganization.<integration>_enabled?(Organization::PREMIUM_INTEGRATIONS), which includes the license check. A rollout flag is declared inapp/config/feature_flags.yaml, with a description and owners, and read withorganization.feature_flag_enabled?(:name). - Scope. Keep the diff to the task, and look for an existing helper before writing a new one. A bug fix also covers its sibling paths: REST and GraphQL, create and update services, the PostgreSQL and ClickHouse event stores.
Business logic, and every create, update or delete of a model, lives in a service in app/services, except one-off scripts (see "Migrations"). Model a new one on app/services/x402/connections/create_service.rb.
- Name it
<Namespace>::<Action>Service, such asCustomers::CreateService. - It inherits
BaseService, stores its keyword arguments in instance variables with privateattr_readers, then callssuper. Its only public method iscall, without arguments (copLago/ServiceCall), and it returnsresult. - Declare the result:
Result = BaseResult[:customer, :subscription], orResult = BaseResultwhen nothing is returned. - Callers use
Service.call(...), orService.call!(...)to raiseBaseService::FailedResulton failure. NeverService.new(...).call: it skips the middlewares (activity log, tracing). - Fail early with
return result.not_found_failure!(resource: "customer"). User errors usesingle_validation_failure!(field:, error_code:),validation_failure!(errors:),record_validation_failure!(record:),forbidden_failure!(code:)when the license, the plan or the caller's rights refuse the action (pass the code; it defaults tofeature_unavailable), ornot_allowed_failure!(code:)when a record's state refuses it. Error codes are snake_case tokens (value_is_invalid) that the front-end translates, not sentences. A service whose codes are already sentences that v1 and GraphQL clients read keeps them, and passes the tokens native v2 renders in their place:validation_failure!(errors:, codes: {to: ["invalid_email_format"]}). A validation error can carry meta, facts per field that let a client fix its next request, each key declared with its JSON type inValidationMeta::KEYSand rendered by native v2 only:single_validation_failure!(field: :position, error_code: "...", meta: {min: 1, max: 3}), orvalidation_failure!(errors:, meta: {position: {min: 1, max: 3}}).record_validation_failure!derives from each error the one bound its value crossed:minormaxfrom an inclusive Integer bound of a numericality validator,min_lengthormax_lengthfrom theminimumormaximumof a length validator on a String. Rename its fields with a block,record_validation_failure!(record:) { (it == :images) ? :image : it }, orValidationFailure#remap, not by re-keyingmessages, which drops the meta. service_failure!and unknown failures become a 500 in REST and GraphQL. Keep them for real server errors.- Never drop the failure of an inner service: check
success?and pass it on withresult.fail_with_error!(inner_result.error), or usecall!. - Inside a transaction, enqueue jobs with
SomeJob.perform_after_commit(...)or anafter_commit {}block. A plainperform_latercan run before the commit and miss the data. - Roll back by raising.
ActiveRecord::Rollbackis ignored when an outer transaction exists. - Serialize concurrent work on a customer with
Customers::LockService, inside a transaction. - A uniqueness validation checks before writing, so a concurrent request can pass it and hit the unique index. Rescue
ActiveRecord::RecordNotUniquewithunique_index_failure!(e, "<index name>" => :field), which answersvalue_already_existas the validation does and raises any other violation (violated_unique_index(e)gives the index name). Webhooks::*services,CacheServicesubclasses and the payment provider services built onTypedResultsfollow their own base classes. Do not use them as a model for other services.
- A job that runs one service has its name with
Job(Contracts::ActivateServiceruns inContracts::ActivateJob), takes positional arguments (records or ids) and callsService.call!. Seeapp/jobs/contracts/activate_job.rb. ApplicationJobsets Sidekiqretry: 0. Retries only come fromretry_on(lock errors,LagoHttpClient::HttpError,BaseService::ThrottlingError) or an explicitsidekiq_options retry:; any other error sends the job to the dead set.- Use an existing queue. Dedicated queues are enabled by a
SIDEKIQ_<NAME>flag and fall back to a queue the default worker reads (config/sidekiq/*.yml). A new queue needs a Sidekiq config, a start script inscripts/and its ownSIDEKIQ_<NAME>flag. - Jobs must be idempotent. Add
unique(usually:until_executed, on_conflict: :log) when the same job can be enqueued twice. Enqueue many jobs withApplicationJob.perform_all_later, which raises for unique jobs. - A new webhook
resource.actionneeds aWebhooks::<Resources>::<Action>Service(seeapp/services/webhooks/customers/created_service.rb), an entry inSendWebhookJob::WEBHOOK_SERVICESand one inconfig/webhook_event_types.yml, then a GraphQL schema dump. It is sent withSendWebhookJob.perform_after_commit("resource.action", record).
- Controllers inherit
Api::V1::BaseController(Api::V2::BaseControllerfor a native v2 one), load records throughcurrent_organizationand call a service. Seeapp/controllers/api/v1/usage_attribution_types_controller.rb. - Every controller has a
resource_name, an entry ofApiKey::RESOURCES. Without it, every API key of an organization with theapi_permissionspremium integration gets a 403, and only the"requires API permission"shared example catches it. - Look records up with
find_byand answernot_found_error(resource: "alert").findgives a generic error code. - Render failures with
render_error_response(result), records with::V1::<Model>Serializer, and lists with::CollectionSerializerandpagination_metadata.createreturns 200, not 201. - Shared routes go in
config/routes/shared_api.rb, which is also served under/api/v2as beta. Native v2 routes are declared first inconfig/routes.rb. Unknown invoice, credit-note and billable-metric v2 routes return 404 rather than falling through to v1. Their controllers (app/controllers/api/v2) include customers, billing entities, the product catalog, taxes, contracts and what they bill (catalog invoices, product fees), plus credit notes: cursor pagination filtered oncreated_at_fromandcreated_at_to(date_range_filtered_index(:created_at)thendate_range_filtersin the controller,with_date_rangein the query, specced with the"a v2 list filtered on created_at"shared example; the logs filter onfrom_dateandto_date),app/serializers/v2, no nested resources in lists, and adeleted_aton every soft-deletable record (nulluntil it is deleted). Each one declaresserializable_with ::V2::<Model>Serializer(metadata-only controllers reuse::V1::MetadataSerializer;root:when the JSON key is not the singular controller name,expand_on:for actions other thanshow) and renders with the generatedrender_<root>(record)andrender_<root plural>(relation)(userender_resource(record)for an uncountable root such as metadata, whose generated names collide), never with a serializer, aCollectionSerializeror aCursorPagination::Pagebuilt by hand. Invoicesvoidrenders a v1 invoice; credit-noteestimateuses a separate estimated-note root and serializer. A record embeds a related one only onexpands[]=<name>, one level deep: its serializer declaresexpandable :name, serializer:, which addsmodel.<name>, or what the serializer's own private method<name>returns, at the end ofserialize. Seeapp/controllers/api/v2/products_controller.rb. A webhook cannot ask for expansions:Webhooks::BaseServicerenders its body throughfor_webhook!, which embeds the ones declared withwebhooks: trueunless the service built its serializer withwebhook: false, and refuses an expansion inincludes:either way. A serializer that renders a record inside its own webhook body passeswebhook: trueitself. The activity log of a catalog invoice logs the same body. Do not add counts of related records to v2 payloads: a client counts the related list, withinclude_total_countwhen it is cursor paginated. Webhooks keep the counts they already send:plan.*passes the internal:countsinclude ofV2::CatalogPlanSerializer. A serializer declares its datetimes withtimestamps :created_at, :updated_at, :deleted_at(copLago/V2TimestampFormat), which rendersmodel.<name>, or what the serializer's own private method<name>returns, withformat_time; its dates render withformat_date. The generated render methods pass the internal:usec_timestampsinclude, whichformat_timereads to render datetimes to the microsecond: webhooks and activity logs render the same serializers without it, at whole seconds. - Native v2 errors are
{code, details}, with the status in the HTTP response only:detailsis always an array of{code, field, meta}, one entry per code in the order of the failure, andmetais always an object. A code that is not a snake_case token, such as a sentence of Rails, renders asvalue_is_invalidand is reported.fieldis a path into the request: keys joined by dots, and[n]for the n-th element of a list, from 0 (rates[1].rate_properties.amount), withbasefor the request as a whole. A service that renames the errors of a list element builds the path withValidationFailure.item_field(:rates, index, field), which givesrates[1]for itsbase. A 404 isresource_not_foundwith the resource inmeta, anot_allowed_failure!is a 422, and a filter that a query's contract refuses (BaseQuery::FiltersFailure) a 422value_is_invalidon the filter.Api::V2::BaseControllerincludesApi::V2Errors, which defines under the same names theApi::V1Errorsrenderers that callbacks shared with v1 call, and overridesApiResponses#not_found_error, whileApi::V1Errorsis included byApi::V1::BaseController;native_controllers_speclists the v1-only renderers and fails on one added later until it is classified. So callrender_error_response,not_found_error,forbidden_errororvalidation_errorsas in v1, andinvalid_request_error(code:, error_details: {param => {reason:, **meta}})for a 400. Never render an error body by hand, and never changeApi::V1Errorsfor v2: v1, Admin and the data API share it. A provider failure is aprovider_errorthat names its provider inmeta(provider_name), and the provider's error code when it gives one (provider_error_code), never its message.meta, from the failure or the 400error_details, goes throughValidationMeta.check: a key keeps one meaning and one JSON type across codes (minandmaxbound a value,min_lengthandmax_lengtha length,max_itemsa number of elements, all inclusive integers) and is added, never renamed. Only a 400 echoes what the request sent (value,invalid_values). - Mask secret parameters in API logs with
filter_audit_log_params!.
- Mutations are
Mutations::<Resources>::<Action>, resolversResolvers::<Resource>Resolveror<Resources>Resolver, typesTypes::<Resources>::Object,CreateInputandUpdateInput. Register them inapp/graphql/types/mutation_type.rborquery_type.rb. Seeapp/graphql/mutations/usage_attribution_types/create.rbandapp/graphql/resolvers/usage_attribution_types_resolver.rb. - Mutations and resolvers of the app include
AuthenticableApiUserandRequiredOrganizationand declareREQUIRED_PERMISSION, defined inconfig/permissions.yml. Some older resolvers have no permission: do not copy them. The customer portal and admin ones use their own modules. - Return
result.success? ? result.record : result_error(result). Keep query logic in a query object, not in the resolver. - Avoid N+1 queries with
dataload_associationin types. Give new enums an explicitgraphql_name, and match field nullability to the model.
- Model schema annotations are generated. Read them and
db/structure.sqlbefore adding an index or writing a query: avoid duplicate indexes, and remember that a composite index only serves its leftmost columns. - Declarative class-level macros stay grouped in the declaration block at the top of the model, before any method definitions, never interleaved with instance or class methods. This covers
include,enum, associations,monetize,validates,scope, and project macros such aspreloadable_aggregationandpreloadable_associated_aggregation. - Declare enum values in a constant, then
enum :status, STATUSES, validate: true(validate: {allow_nil: true}for a nullable column). New enums are PostgreSQL enums with string values:STATUSES = {draft: "draft", active: "active"}.freeze. Older enums use arrays on integer columns. - Soft-deletable models use
include Discard::Modeland are never hard deleted. They setself.discard_column = :deleted_atanddefault_scope -> { kept }. Bulk deletion isupdate_all(deleted_at: Time.current, updated_at: Time.current)(copLago/DiscardAll). Discarding a record does not discard its children (dependent:does not apply), so the service discards them explicitly. Associations to a parent that can be discarded use-> { with_discarded }. update_allskips callbacks andupdated_at: setupdated_atyourself.- Give every list query a deterministic order.
.firstand.lastorder by the UUIDid, not by creation, andfind_byandtakehave no order: add an explicit order whenever the record picked matters.find_eachiterates by id, and raises in tests when the relation has an order. - Query objects live in
app/queries(< BaseQuery,Filters = BaseFilters[...],paginatelast). Seeapp/queries/contracts_query.rb. Ransack attributes must be listed inransackable_attributes.
- Create migrations with the generator. It sets the current
ActiveRecord::Migration[x.y]version and a real timestamp. Never edit a merged migration. - strong_migrations checks every migration. Wrap an operation in
safety_assuredonly when you know it is safe. - An index on an existing table gets its own migration:
disable_ddl_transaction!,algorithm: :concurrently,if_not_exists: true, explicitupanddown, and removal of an invalid index left by a failed run. Copydb/migrate/20261002142357_add_contracts_status_cursor_index.rb. Columns and indexes never share a migration, except indexes declared insidecreate_table. add_column,add_foreign_keyandadd_check_constrainttakeif_not_exists: true, so a failed migration can be retried. Preferadd_columnoverchange_table.- PostgreSQL enum types are created with
create_enum, named after the table and column (subscription_on_termination_credit_note). - A constraint added with
validate: falseis validated by a second migration in the same PR. If existing rows must be fixed first, register it indb/not_valid_constraints.ymlwith avalidate_bydate, and remove the entry once the constraint is validated (spec/db/not_valid_constraints_spec.rbchecks both). Passcolumn:tovalidate_foreign_keywhen a table has several foreign keys to the same table. - Dropping a column or a table takes two releases: follow
docs/dropping_columns_and_tables.md(copLago/NoDropColumnOrTable). Alwaysself.ignored_columns += [...], never=. - Small backfills run in the migration (
Model.unscoped.in_batches.update_all, as the default scope hides discarded rows). Large ones are aDatabaseMigrations::*Jobstarted by a rake task inlib/tasks/migrations/, not a job enqueued by a migration. Seeapp/jobs/database_migrations/backfill_invoices_search_terms_job.rb. - A one-off fix of existing data (repairing records, resending webhooks, notices missed by a past bug) is an idempotent rake task with a spec in
spec/lib/tasks/, not a console orrails runnerscript. Its logic stays in the task file, in a class defined there when it needs one (seelib/tasks/migrations/wallet_traceability.rake), never in a service inapp/services. It runs as a dry run by default (DRY_RUN=falseapplies it) and lists each record with what it would do. Seelib/tasks/customers.rake. - SQL views use scenic:
db/views/<name>_v01.sqlwithcreate_view, then a new numbered file andupdate_viewfor each change.
- Self-hosted migrations live in
db/clickhouse_migrate/. ClickHouse Cloud DDL lives indb/clickhouse_migrate/cloud/NN_*.sql, run by hand in order when a cluster is created, so a change to an existing Cloud table is a new numberedALTERscript, not an edit. - ClickHouse DDL is not transactional: one DDL change per migration, explicit
upanddown,IF NOT EXISTSandIF EXISTSguards. - Kafka engine tables cannot be altered: drop and recreate the queue table and its materialized view.
spec/mirrorsapp/: a spec file is named after the class it tests, and itsRSpec.describenames that class. Endpoint specs live inspec/requests/api/v1andv2. Billing scenarios inspec/scenariosgo through API calls and jobs withspec/support/scenarios_helper.rb, rather than calling services.- A bug fix comes with a spec that fails without the fix: run it once with the fix reverted to see it fail. Cover the negative case (a record that must not be returned) and the error paths.
- Before writing a spec, open one or two existing specs of the same kind (two
create_service_spec.rbfor a new create service) and follow their structure. Do not add top-leveldescribeblocks they do not have. - Test behaviour, not configuration: a spec for a threshold, a schedule or a webhook crosses the trigger and checks the side effect, not only the saved settings.
- A webhook or serializer spec compares the whole serialized object with
eq, with an explicit value per field. Thecreates webhookshared example only checks the keys it is given, so a missing or renamed field still passes. - An expected value computed by hand (proration, weighted sum, tiers) shows its derivation: write the arithmetic, or the literal with the arithmetic in a comment, and name where each term comes from (
eq((1000 + 7 + 20 * 18.fdiv(28)).ceil(5)) # snapshot + late event + 11 Feb event over 18 of 28 days). - A run counts only when its summary shows the expected number of examples and no "errors occurred outside of examples": a spec file that fails to load is left out of the count. Run new spec files on their own first.
- Helpers:
- REST:
get_with_token(organization, path, params)(alsopost_,put_,delete_),jsonfor the parsed body,expect(response).to be_not_found_error("alert")(be_v2_not_found_erroron a native v2 endpoint),include_examples "requires API permission", "alert", "write". - GraphQL:
execute_graphql(current_user:, current_organization:, permissions:, query:, variables:),it_behaves_like "requires current user","requires current organization"and"requires permission", "alerts:create". - Jobs:
have_enqueued_job,have_been_enqueued,have_enqueued_job_after_commit. Never stub or spy on job classes (copLago/NoExpectReceiveOnJob). - Metadata:
:premium,clickhouse: true,transaction: false,cache: :memory,:sentry,:bullet(fails on N+1 queries),v2_timestamps: false(every spec underspec/requests/api/v2checks that its last JSON response renders datetimes to the microsecond; a spec of a v1 payload opts out).
- REST:
- A resolver or mutation spec has one shared example per check its class makes:
"requires current user"forAuthenticableApiUser,"requires current organization"forRequiredOrganization, and"requires permission"with the fullREQUIRED_PERMISSIONlist. When a class accepts several permissions, examples passpermissions: described_class::REQUIRED_PERMISSION.sample, so each run uses one of them. aggregate_failuresis on by default, so several expectations in one example are fine.- Name the subject:
subject(:result) { described_class.call(...) }. Do not test#initialize. - Declare fixtures with
let,let_it_beorbefore, never insideit, and preferletwithbeforeoverlet!. Stub withallowinbefore. Vary a scenario by overriding aletin a nested context, not by updating records in the example, unless that update is what the spec tests. - Prefer
buildorbuild_stubbedwhen the test does not need the database. Build the target factory and let its associations create the rest. Entitlement factories are:feature,:privilegeand:entitlement. - Freeze time with
freeze_timeortravel_toinstead of comparing withbe_within. Compare lists witheq, ormatch_arraywhen the order does not matter, instead of severalincludechecks. - Model specs use these blocks, in this order:
describe "enums"and"associations"(oneiteach, with every association option),"Clickhouse associations"(clickhouse: true),"Scopes"(onedescribe ".scope_name"per scope),"validations"(oneit, plus a nesteddescribe "<attribute> validation"for a custom validation). PostgreSQL enums usedefine_enum_for(:status).backed_by_column_of_type(:enum).validating.with_values(...). Seespec/models/x402/settlement_spec.rb.
TestProf helpers create shared records once per example group.
- Use
let_it_befor records that examples read and do not change (organization, customer, plan, billable metric). Alet_it_beblock cannot read alet. let_it_berecords are frozen (spec/spec_helper.rb). When an example or the code under test updates one, addrefind: trueto that record only. It loads a fresh copy before each example, whilereload: truereloads the shared instance in place.- Use
before_allfor setup that only needs to exist, such asbefore_all { create_default(:billable_metric) }. create_default(:organization)makes factories reuse that organization. A record of another organization needs it passed explicitly (create(:invoice, organization: create(:organization))), and alet_it_beis declared after the records it depends on.transaction: falsegroups delete all rows after each example, which removeslet_it_beandbefore_allrecords: useletthere.
Read the PR description first, then check the code against it and against the ground rules above.
- Important means it breaks behaviour, leaks data or blocks a rollback: a query not scoped to the organization, money as
Floator rounded too early, a breaking change to the REST API, webhooks or GraphQL schema, a migration that locks a large table, a changed job signature. Naming and refactoring are nits at most. - Do not report what CI enforces: RuboCop,
db/structure.sql,schema.graphql,schema.json, model annotations. - A claim about behaviour cites the
file:linethat shows it, not a guess from a method name. - On a later review of the same PR, report only important findings on the new commits.
Also check:
- Callers outside the diff. Every caller of a changed method still gets what it expects: the return value, the default, the raised error.
- Existing data. The first run of the new code on rows written before it (defaults,
NULLs, old enum values) works, or the PR adds a backfill or a guard. - Concurrency. The same request sent twice, two writers on one record, a job running during the change. A uniqueness or ordering guarantee rests on a lock or a unique index, not on a check before the write.
- Announced state. For state that was sent outside (a webhook, a triggered alert, a balance shown), every event that changes its inputs has a path, both into and out of the state: configuration edits, deletion of the parent, time boundaries.
- Errors. A rescue catches the narrowest exception the code raises, and a spec covers it.
- Tests. Every new public method and branch has a spec, and the specs follow the rules above.
- Cache keys. When a cached value changes shape or meaning, its cache key changes in the same PR, and the PR says the cache is rebuilt once.
- Large data. A new or changed query on a table that grows with usage (events, fees, invoices, wallet transactions) has an index that serves it. Check the plan on a large dataset when unsure.
- Sign-in and permissions. A change to sign-in, tokens or permissions covers every sign-in path (password, Google, Okta, Entra ID) and both the REST and GraphQL APIs, and logs no email, token or request body.
- Rules of this file. A new violation of a rule here is a finding, and so is a change that makes a rule here wrong without updating it.
- Self-contained diff. The code does not reference a class, constant or column that neither the diff nor
mainhas.
- RuboCop (Standard, plugins and the custom cops in
dev/cops) defines the style. Fix offenses in the files you change, and do not reformat other files. - No
OpenStruct: useStruct,Data.defineor a hash. - No guard clause right before the last line of a method (
return other unless conditionthenvalue): write anif/elseinstead. ?methods return booleans, and datetime columns end with_at.- Comments explain why, in one or two lines. No ticket IDs, internal links or narration of the change.
- PRs are squash-merged, so the PR title becomes the commit on
main. It follows Conventional Commits,type(scope): summary, in 72 characters or less. Types:feat,fix,perf,refactor,chore,misc,docs,test,ci,build,revert,style. - The PR body follows
PULL_REQUEST_TEMPLATE.md(## Context,## Description). It says why the change is needed and what it changes, without a tour of the diff, and it is updated when the approach changes. - One concern per PR. An
AGENTS.mdrule that comes from the PR's own review is part of it. - A change visible outside this repo also needs an update elsewhere, and the PR description says which:
- lago-openapi for a REST endpoint, parameter, field, error, limit or webhook payload.
- lago-doc for a behaviour users see, a new environment variable or setting, or a step self-hosted users must take when upgrading (a page under
guide/migration).
- When a change needs one of these updates, an agent says so and asks the user whether to prepare it. It never edits those repositories without that answer.
- A new environment variable is read with a safe default, and the PR description names it with its default and purpose, so the self-hosting docs and the deployment configs can follow.
lago:diagnosticsprints every environment variable for support tickets and masks values by name (Lago::Diagnostics::SECRET_PATTERN). A variable that holds a secret has a name the pattern matches (_KEY,_SECRET,_TOKEN,_PASSWORD). If it cannot, for example a URL with a key in its path, the PR extends the masking.
- When a review comment points out a mistake that no rule here prevents, and the lesson applies beyond this PR, the same PR adds or fixes the rule in this file. One-off choices and personal preferences stay out.
- When you notice a recurring pattern in the code, or a repeated step in the workflow, that no rule here captures, propose adding it rather than leaving it undocumented. Suggest the exact wording so a maintainer can approve it.
- A rule that a cop (
dev/cops) or a spec can check goes there instead. Keep this file short: tighten or replace an existing rule rather than stacking a new one, and remove rules that no longer match the code. - Edit
AGENTS.md.CLAUDE.mdonly imports it.