Skip to content

feat(auth): prune spent one-time codes on a schedule (RUK-296) - #6

Merged
ruko1202 merged 9 commits into
mainfrom
feat/otp-prune-processor
Sep 5, 2026
Merged

feat(auth): prune spent one-time codes on a schedule (RUK-296)#6
ruko1202 merged 9 commits into
mainfrom
feat/otp-prune-processor

Conversation

@ruko1202

@ruko1202 ruko1202 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

auth_credentials gains a row per sign-in attempt and nothing ever removed
them, so code digests and browser-binding nonces accumulated indefinitely.
RUK-285 landed the index for this sweep ("supports the periodic prune of
expired codes, which is a separate piece of work") but the work was never
scheduled. This is that work, mirroring the shipped audit.prune and
invitation.prune jobs.

  • Add otp.prune: a daily cron job deleting kind='otp' rows whose
    expires_at is older than a 24h retention window, in bounded batches
  • Measure age on expires_at, not created_at — the partial index is the only
    one on the table and migrations were out of scope; EXPLAIN confirms it is
    used with no sort node
  • Keep kind='otp' explicit: nothing in the schema ties kind='password' to a
    NULL expiry, and the literal is also what makes the partial index usable
  • Coerce a non-positive retention to the default before computing the cutoff —
    a cutoff at or after now would delete a live attempt-exhausted code and
    reset the per-code guess ceiling claimSlot enforces by that row's existence
  • Retain 24h rather than the siblings' 365d, and log every completed sweep
    including empty ones, since this job has no metric and the log line is its
    only liveness signal
  • Note the resulting ~48h forensic window in the existing OTP runbook

ruko1202 and others added 4 commits September 5, 2026 16:38
Adds otp.prune and otp.prune.cron plus ProcessorTaskPayloadOTPPrune, and the
task_processor.otp_prune config block with validateOTPRetention.

The two task-type registries are guarded differently and both entries are
required: the runtime startup guard reads ActiveProcessorTaskTypes, while the
drift test's length assertion reads allDeclaredTaskTypes.

The validator checks only for a negative retention, deliberately. A comparison
against the OTP TTL was considered and dropped: a code becomes sweep-eligible
only once its expires_at is already past, so no positive retention can reach a
live row however small -- and services/otp imports this package, so calling
otp.TTL from config would not compile anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-296)

PruneOTPExpiredBefore is one batch-bounded DELETE of kind='otp' rows past
expires_at, ordered by the indexed column so the partial index serves the scan
directly rather than through a backward one.

Age is measured on expires_at rather than created_at because
auth_credentials_otp_expiry_idx is the only index on the table and migrations
were out of scope; the two differ by exactly the TTL for a one-time code.
consumed_at never enters the predicate -- a consumed code carries an expiry too
and ages out through the same sweep.

The kind='otp' conjunct is load-bearing twice and must not be simplified away
on the grounds that password rows have a NULL expiry: no CHECK ties kind to a
NULL expires_at, so that is an observation about today's write paths rather
than an invariant, and the literal is also what lets Postgres use the PARTIAL
index at all.

Two fixtures are new because the existing helpers could not express the rows
these tests need -- makeOTP hardcodes now+10m, and makePassword sets no expiry.
makePasswordExpiringAt in particular builds a shape no write path produces
today, and it is the only fixture that can prove the kind guard does anything:
a NULL-expiry password row survives whether or not the guard exists.

Every cutoff here stays in the past by design. The DELETE is bounded by
expires_at and limit alone -- never by user_id -- so under `-p 2` a sweeping
cutoff would eat fixtures belonging to other packages, and the bad outcome is
not a flake but a silent green in services/otp's TestVerify_ExpiredCodeIsRetired,
whose "row is gone" assertion an eaten fixture also satisfies.

Mutation-verified: dropping the kind conjunct fails SparesPasswordWithExpiry
and only that test; replacing the cutoff comparison with IS NOT NULL fails
SparesLiveCode at its 1-minute leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prune drains the store's batch behind a single cutoff, mirroring
invitation.Service.Prune, with two deliberate differences.

The retention fallback is 24h, not the sibling's 365 days: the direction of
safety is inverted here, because a year-long fallback would silently disable
the sweep and let code digests and session nonces pile up -- exactly what this
job exists to prevent. And the completion line is logged unconditionally,
including a zero-row sweep, because this job carries no metric and that line is
its only liveness evidence; logging only non-empty sweeps would make a cron
that stopped firing indistinguishable from one with nothing to collect.

Coercing a non-positive retention is the safety guard rather than hygiene. The
per-code attempt ceiling is enforced by the row's continued existence --
claimSlot refuses to issue while it finds a live row with attempts exhausted --
so deleting such a row would hand back a fresh code with a fresh counter and
turn "five attempts per code" into "five attempts per code, unlimited codes".
Only a non-positive retention can place the cutoff at or after now, so the
coercion is what closes it, and it runs before the cutoff is computed. The
assertion behind it is unreachable by construction and kept as a guard against
a future edit; the fallback must not be narrowed to make it firable.

Also adds the generated mock for services/otp, which the Makefile already
declared but no commit had produced.

Mutation-verified: narrowing the fallback to `retention == 0` fails
CutoffIsAlwaysInThePast; removing the batch cap fails StopsAtBatchCap;
re-adding the sibling's `if total > 0` fails LogsCompletionEvenWhenNothingDeleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The processor package (day-bucketed external id, typed processor, cron
factory), its registration in the goque worker, and the otp_prune block in all
four stands.

The cron spec falls back to "15 3 * * *" instead of failing to build the job.
The sibling sweeps abort startup when their spec is missing, which in this
merged auth+maintmode process means an absent config line takes sign-in down;
falling back the way license.heartbeat already does keeps that to a degraded
schedule, while a malformed spec still surfaces as an error. The 03:15 offset
also keeps this sweep out of the 03:00 minute the three other daily jobs share.

Retention is 24h in config, matching the service fallback and the factory's
cmp.Or default -- three values that must agree, and the factory test asserts
the literal rather than only its local constant so they cannot drift apart
silently. The YAML comment deliberately does not repeat the siblings'
"must be set or NewTaskProcessors panics" warning: with the fallback in place
that sentence is false for this block. It documents the ~48h worst-case
residency instead, since retention alone reads as a tighter guarantee than the
daily cadence gives.

registerOTPPrune is extracted for symmetry with registerInvitationRotation, not
for funlen headroom.

The registration test covers what the entity drift test structurally cannot:
adding otp.prune to both task-type lists while never registering the periodic
job satisfies every assertion in that file. Mutation-verified -- dropping the
RegisterPeriodicJob call or the cmp.Or fallback each fail a test.

Verified by booting the binary against local config with no otp_prune block:
both goque-processor-otp.prune and goque-periodic-job-otp.prune.cron start and
the startup coverage guard passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ruko1202 ruko1202 changed the title 296 feat(auth): prune spent one-time codes on a schedule (RUK-296) Sep 5, 2026
ruko1202 and others added 5 commits September 5, 2026 21:56
The guard read the clock a second time, so it compared the cutoff against a
later instant than the one the cutoff was derived from. What it actually
tested was "retention exceeds the gap between two clock reads" -- tens of
microseconds -- rather than "the cutoff is in the past".

That makes it useless for the single regression it was added to catch. If the
retention fallback were narrowed to `retention == 0`, a zero retention would
produce a cutoff a few microseconds behind now, the branch would evaluate
false, and the sweep would proceed exactly as if the guard were absent.

Its `return nil` also reported success while doing nothing.

A guard that looks like protection but is not is worse than no guard, because
the next reader trusts it instead of the coercion that does the work. The
regression stays covered by TestPrune_CutoffIsAlwaysInThePast, which asserts on
the cutoff the store is actually handed across negative, zero and positive
retentions -- verified still dead by re-running that mutation after this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registration tests called registerOTPPrune directly, which left the wiring
itself untested: removing the registerOTPPrune call from NewTaskProcessors kept
all three green while the sweep would silently never run in the binary. Nothing
else covered it either -- the entity drift test only reads the task-type maps,
and no test called NewTaskProcessors at all. Verified by mutation: with the call
removed the new test fails on "missing=[otp.prune otp.prune.cron]" while the
helper-level test still passes.

Zero-valued Stores and Services are enough to drive it, so the real constructor
needs no database. It also exercises the startup coverage guard, which until now
was only ever checked by booting the binary by hand.

In the processor test, drop `require.NotNil(task)` after a nil error and
`require.NotEmpty(task.ExternalID)`: both are goque echoing its own arguments,
and the external id's real value is pinned by TestOTPPruneExternalID_DayBucketed.
The task-type assert is KEPT and now carries a comment saying why -- mutating the
factory to stamp the .cron type is otherwise undetected, and such a task would
sit in the queue with no processor to drain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the cmp.Or fallback and the defaultOTPPruneCronSpec constant: the spec now
comes straight from config, and an empty or malformed value fails
NewTaskProcessors and aborts startup, exactly as it does for audit.prune and
invitation.prune.

The fallback was defending against a stand whose config predated this change,
but that trade was wrong: a value that is set but malformed should fail loudly
rather than be silently replaced, and a sweep running on a schedule nobody
configured is harder to notice than a process that refuses to start. All four
stands set the value, so the failure mode is reachable only by deleting a line
that is already there.

Also removes otp_prune_registration_test.go. Its remaining tests either called
registerOTPPrune and then asserted that registerOTPPrune had registered
something -- a tautology -- or duplicated what the constructor-level checks
already cover.

The YAML comments no longer promise a fallback that does not exist; they now
carry the same "must be set" warning as the neighbouring blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validateOTPRetention is a four-line negative check whose sibling,
validateInvitationRetention, ships without a test of its own. Matching that is
the more consistent choice.

Nothing safety-relevant rides on it: the behaviour that matters -- a
non-positive retention never reaching the DELETE -- is pinned at the service
level by TestPrune_CutoffIsAlwaysInThePast, where it is actually reachable.
The startup validator is a config-hygiene message layered on top of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing referenced it, neither of the two shipped periodic sweeps has an
equivalent, and the only mention of runbooks anywhere in the repo is a comment
in alerts.yml phrased as "if you keep operator runbooks of your own" -- which
reads as an invitation, not as an established practice.

It also carried facts that duplicate the config and the code comments (the
schedule, the retention window, which rows the predicate spares), so it was set
up to drift out of date the first time either changed.

Dropping it here rather than extending it with an otp.prune section, which is
what an earlier commit on this branch did before the file's place in the repo
was questioned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ruko1202
ruko1202 force-pushed the feat/otp-prune-processor branch from 05d5d52 to 3abbead Compare September 5, 2026 18:57
@ruko1202
ruko1202 merged commit ad316a8 into main Sep 5, 2026
5 checks passed
@ruko1202
ruko1202 deleted the feat/otp-prune-processor branch September 5, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant