Repository navigation
Feature/2962 reserved domains audit trail - #2967
Merged
Merged
Conversation
A reservation used to expire at the exact activation timestamp plus the period, so a domain reserved at 13:43 was released at 13:43 seven days later. The period is now counted in full calendar days in the app time zone: the activation day is not counted and the reservation ends at 23:59:59 of its last day. ReservedDomain.expire_at_for is the single place computing the deadline and is used for both free (7 days) and paid (1 year) reservations. Closes #2961
The business registry billing callback created reserved domains without expire_at, which made them permanent. Use the same 1-year full-day expiry as the status endpoint. Refs #2961
Add ReservedDomain.release_expired, run by cron every day at 00:35, which removes reservations whose expire_at has passed. Permanent reservations (expire_at NULL) are kept. Each record is locked and re-checked before removal. The reason is stored in updator_str and whodunnit before destroy, so the PaperTrail destroy version in log_reserved_domains carries the process, reason and timestamp. Lazy removal on availability check reuses the same path. A failing record is logged and reported to Airbrake without stopping the run. WHOIS refresh is enqueued after commit. Add index on expire_at. Closes #2963
…y-expiry' into feature/2962-reserved-domains-audit-trail # Conflicts: # CHANGELOG.md # test/models/reserved_domain_test.rb
Add source, reason, reason_note, domain_name and registrar_id to log_reserved_domains and fill them through PaperTrail meta from ReservedDomain::Audit (CurrentAttributes). Without an explicit context the source falls back to the whodunnit prefix, so writes never fail because of missing audit data.
Business Registry API, EIS billing callback, expiry cleanup, domain registration, dispute password sync and admin actions now set the audit context, so each log_reserved_domains row says where the change came from and why. Admin update and delete require a reason note; the delete route stays GET and takes the note as a query parameter. API responses and reservation logic are unchanged.
reserved_domain_lifecycles builds one row per reservation from log_reserved_domains plus live rows: name, creator, last change, expiry, end reason, recorded registration and derived status. The reserved_domains:backfill_audit task fills domain_name and, where the whodunnit proves it, source and reason on rows written before the audit columns existed.
Settings > Archive > Reserved domains history lists every reservation lifecycle with filters by name (unicode or punycode), status, dates, source, reason and author, a detail page with the full change timeline (passwords masked) and a CSV export of all audit fields. Each row of the reserved domains list links to its history.
The lifecycle view recomputed every reservation on each request: with 100k reservations the history page took 6 s and the CSV export 92 s. History now lives in reserved_domain_lifecycles, derived by the reserved_domain_lifecycle_rows() SQL function. Admin reads catch up only the reservations changed since the previous read, a nightly cron and the rake tasks rebuild the whole table, and the CSV is streamed and rendered in SQL. Reservation write paths are not touched. With 300k reservations the page loads in under 100 ms, filters in up to 0.5 s and the full CSV export takes about 5 s.
Contributor
🚀 Deploy Complete!
(Environment ready for testing) |
The expired reservation was created one day before the real current time but released at a fixed past moment, so the test failed once the real date moved past it.
…ed-domains-audit-trail # Conflicts: # CHANGELOG.md
Contributor
🚀 Deploy Complete!
(Environment ready for testing) |
The button sits next to 'New reserved domain' and opens the lifecycle history list. If a name search is active, it is carried over as the domain name filter, so a domain missing from the reserved list can be looked up in its history in one click.
Contributor
🚀 Deploy Complete!
(Environment ready for testing) |
maricavor
approved these changes
Oct 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2962
What
log_reserved_domainsare filled for every create/update/destroy.Audit data
New nullable columns on
log_reserved_domains:source,reason,reason_note,domain_name,registrar_id.They are written through PaperTrail
metafromReservedDomain::Audit(ActiveSupport::CurrentAttributes, block-scoped withAudit.set { }). Nothing was added toreserved_domains.creator_str/updator_strkeep their existing string format, soVersions#creator/updatorwork as before.POST reserve_domainsBusiness Registry APIbusiness_registryfree_reservationlong_reserve_domains(_status)(paid)Business Registry APIbusiness_registrypaid_reservationEIS billing callbackeis_billingpaid_reservationexpiry_jobreservation_expiredavailability_checkreservation_expiredregistrar(+registrar_id)domain_registereddisputedispute_password_syncadminadmin_created/admin_updated/admin_deletedadminreleased_to_auctionIf no context is set,
sourcefalls back to a value derived from the whodunnit prefix (AdminUser/ApiUser/console-/rake-, elseunknown). Audit never raises and never blocks a write.Admin behaviour change (from the issue: "Manual admin modification (with reason)")
deleteroute is unchanged. The Delete button now asks for a reason (prompt) and passes it asreason_note. Without a reason nothing is deleted.History page and performance
History lives in a table,
reserved_domain_lifecycles, with one row per reservation. Rows are derived by a single SQL function,reserved_domain_lifecycle_rows(bigint[]), fromlog_reserved_domainsplus livereserved_domainsrows. Derived fields: name, created at/by/source/reason, last change at/by/source/reason/note, expire_at, ended at, end reason,registration_recordedandlive. Status (active/expired/released_to_auction/deleted/removed) is computed at read time, because it depends on the current time.The table is never written by reservation write paths. It is kept current by:
ReservedDomain::Lifecycle.sync!): re-derives only reservations changed since the previous sync, with a 10-minute safety margin. It is serialized by apg_try_advisory_xact_lock, so a busy lock skips the sync and the page serves the current data. A failed sync is logged and shown as a warning; it never breaks the page;ReservedDomain::Lifecycle.rebuild!repairs rows changed without versions;reserved_domains:rebuild_lifecycles, also run at the end ofreserved_domains:backfill_audit.The first implementation was a plain SQL view. It was measured and replaced:
Other details:
'.can :read). Each row of the current reserved domains list gets a History link.Backfill of existing history
rake reserved_domains:backfill_auditis batched and idempotent:domain_namefor old rows, event-aware.source/reasononly where the whodunnit proves them:AdminUser:→ admin,ApiUser:→ registrar, the Release expired domain reservations daily #2965 cleanup strings → expiry_job / availability_check,console-/rake-.unknown; nothing is guessed from expire_at.Deployment
20261005090000: adds nullable columns, metadata-only in PG13.20261005090100: indexes, builtCONCURRENTLY.20261005090200: creates the function and the two tables, and fills the history table. It only reads the log tables; expect tens of seconds on large history.bundle exec rake reserved_domains:backfill_audit(once).mina pr cron:setup/whenever --update-crontab ... cron_group=registry).Rollback:
db:rollback STEP=3drops the history table, the function, the indexes and the audit columns.Tests
sync!/rebuild!, lock contention, CSV;Known issues not fixed here (separate PRs)
test/models/reserved_domain_test.rb"release_expired should record release reason in version history" from Release expired domain reservations daily #2965 createsexpire_at: 1.day.agooutsidetravel_toand has failed since 2026-10-03. It should be fixed in Release expired domain reservations daily #2965.expire_atasl(..., format: :short)(08.10.26, 15:01). Saving without touching the date parses it as year 0008, so the reservation becomes expired and the daily cleanup from Release expired domain reservations daily #2965 will remove it. Reproduced in a headless browser; this needs its own fix.