Approval request expiry
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. The deadline is the kind's, not the request's
- 2.2. Two paths close a request, and they state the same thing
- 2.3. The sweep is a scheduled job, not a timer in a service
- 2.4. The inbox declares the job; the installation says when it fires
- 2.5. A registration that fails must be loud
- 2.6. The close is one statement across every tenant
- 2.7. An expiry is not a decision
- 2.8. The warning is its own sweep, and the notice is its mark
- 2.9. The sweep is idempotent, and it races safely
- 3. Interaction
- 4. Patterns used
- 5. See also
1. Summary
An approval request carries a deadline it takes from its kind. When nobody answers by then, the request closes itself as expired and the person who asked is told. Two paths close it: the decider who arrives too late, and a scheduled sweep, because the request nobody looks at is the one that matters. The sweep is a job the scheduler fires, and that job publishes an inbox operation; the inbox closes every overdue request across every tenant in one statement and then raises one notice for each person who asked. The inbox declares the job and registers it at start; how often it fires is a system setting, so an installation tunes the sweep without a build.
The other half of a deadline is the warning. A second sweep, on its own schedule and its own window, reads the open requests whose deadline is close and warns the people who may answer them, so a decider learns before the request runs out rather than after. It is a separate job because warning somebody is a nudge and closing a queue is housekeeping, and the two want different cadences. A request is warned about once, and the notice is what says so: a reminder already raised names the request, so there is no second mark to keep in step with the first.
2. Detail
2.1. The deadline is the kind's, not the request's
ores.inbox.approval_kind carries
expires_after_days, and the raise path turns it into the request's
expires_at once, at the moment the request is made. A kind that states no
days produces a request that never expires, which is the right answer for a
kind whose answer is genuinely open-ended. The role request kind states
fourteen days.
The policy is therefore data a tenant's deployment can change without a build, and it is stated once for every request of that kind rather than counted again each time one is read.
2.2. Two paths close a request, and they state the same thing
The decider path is
the decide operation: an open
request whose expires_at has passed becomes expired instead of being
decided, and the decider is told the request expired before they reached it.
This path costs nothing and needs no schedule, but it only fires when somebody
looks.
The sweep path exists for the queue nobody opens, which is the case a queue
rots on. Both paths write the same tail — system.update, and a commentary
that says the request closed because nobody answered — so a request's history
reads the same however it closed, and a reader cannot tell which path ran. That
is deliberate: they are one rule applied twice, not two rules.
2.3. The sweep is a scheduled job, not a timer in a service
ores.scheduler exists to fire
actions at stated times, and it already fires system jobs from a row that
belongs to no tenant. The sweep is such a row: a
job definition carrying
action_type = nats_publish and the subject in its payload.
The job publishes the inbox's own expiry operation. It does not run the SQL
directly, and that is the load-bearing decision on this page: closing the
requests and telling the people who asked are one act, the notice is raised in
the service rather than in the store, and an execute_sql job would close
requests silently. The job reaches the service for the same reason any other
caller does.
A timer inside the service was the first shape this took, and it is the wrong
one. Work that lives only inside a steady_timer shares its fate with the
lifecycle hook that spawned it, and that hook can silently not fire — see the
capture that raised it. A job in
the catalogue survives the service restarting, appears in the job list, and can
be rescheduled by an operator.
2.4. The inbox declares the job; the installation says when it fires
Two things are being decided, and they belong to different people.
That the sweep recurs is the inbox's own behaviour. The inbox owns the requests, so it owns the statement that a request nobody answers must close, and it registers the job itself rather than leaving a row in a populate script that nothing connects back to the component. The job name, the action and the subject are therefore fixed in code and are not configurable.
How often it recurs is the installation's answer, not the inbox's. How often
is worth sweeping depends on how many requests a deployment raises and how
long its administrators take to answer, and neither the component nor the
person writing it can know that. So the schedule is a
system setting, carrying a default
that suits a small installation. The setting is the same mechanism
iam.token.access_lifetime_seconds and system.signup_requires_authorization
already use, and the sweep is its first non-variability consumer.
The inbox reads that setting over the wire, through
variability.v1.ops.get_setting, and it holds the
variability::system_settings:read permission to do it. Reading the table
directly would have been shorter and is what this page argues against: a
setting is spoken about by name everywhere, the entity read is addressed by an
identifier no caller outside variability holds, and a component that reads
another's tables has taken a dependency its owner cannot see. The operation
answers with the value and its declared type, or says the setting does not
exist, so the caller decides what to do about absence rather than discovering
it as an empty string.
The split is the point. A component that hardcodes its own schedule is asserting something about somebody else's deployment, and it will be wrong; a component that lets the schedule be set to nothing at all is not offering a sweep. The inbox gets to insist that the work happens and the installation gets to say how often.
2.5. A registration that fails must be loud
Registering at start makes the scheduler a startup dependency, and the failure mode is the one this page exists to prevent: a job that is not there, and nothing saying so. So the service refuses to start when it cannot register the job, and the operator sees why. An installation whose inbox is up but whose requests never expire is worse than one whose inbox is down and says so.
Not every failure is the same, and the two are handled differently. A dependency that is not answering yet — the scheduler has not started, or variability has not — is waited out, because a later attempt can cure it. A setting that is absent, a value that is not a cron expression, or a scheduler that refuses the write are decisions, and repeating them cannot change them, so the service reports the decision at once instead of retrying a mistake twelve times.
The job is recognised by job_name, which is the key the store holds it
under, and the registration reuses the identifier of the job already carrying
that name. This matters more than it looks: the store keeps one live row per
name, so a registration that invented a fresh identifier on every restart would
fail against that uniqueness rather than quietly adding a second sweep. A job
that already holds the wanted schedule and is active is left alone, so a
restart writes no new version of a row nothing changed.
2.6. The close is one statement across every tenant
ores_inbox_expire_approval_requests_fn closes every open request whose
deadline has passed, and answers one row per request it closed so the caller
can tell each person. It is a security-definer function because it reads every
tenant, where the rest of the inbox is tenant-scoped by row-level security, and
the inbox service is the only role granted execute on it. The read-write role
keeps execute too, so the suites can test it.
2.7. An expiry is not a decision
The notice a decision raises reads "{decider} wrote: {comment}", which for an
expiry would name somebody who did nothing. So the expiry has its own
notification kind, inbox.approval_expired, whose sentence says the request
ran out of time. See
Notification requirements.
2.8. The warning is its own sweep, and the notice is its mark
A request about to run out is read by
ores_inbox_remind_expiring_approval_requests_fn, which answers every open
request whose expires_at falls between now and now plus the window. The
service then warns the holders of the kind's decide permission in the request's
own tenant, leaving out the person who asked, because a person never decides
their own request.
Warning once is what makes it a warning rather than a nuisance, and the mark is
the notice itself: the function leaves out any request a
inbox.approval_expiring notification already names. A column on the request
was the obvious alternative and was not taken. It would be a second record of
the same fact, it would have to be cleared wherever the notice is cleared, and
the two could then disagree about whether anybody had been told. Asking the
notice costs one not exists and cannot disagree with itself.
The window is the setting inbox.approval_expiry.reminder_window_seconds and
the cadence is inbox.approval_expiry.reminder_schedule, so an installation
sets both without a build. A window of zero warns nobody, which is what an
installation that cannot read the setting gets: a warning nobody receives is
better than a service that refuses to start over one.
The reminder does not close anything and the close does not warn. A request answered before its deadline stops matching the warning, and a request the close reaches is no longer open for the warning to find; the two sweeps can run in either order and in any number of repetitions.
2.9. The sweep is idempotent, and it races safely
A request the sweep already closed is no longer open, so a repeated sweep
closes nothing. A sweep that runs while a decider is deciding converges on one
answer, because both paths lock the row and both read the same rule. A service
that was down for a week sweeps what lapsed in that week on its next run,
because the rule is stated over expires_at rather than over an elapsed
interval.
3. Interaction
The diagram draws one request from the ask to its close. The ask carries the kind's deadline; nobody answers; the scheduler fires the job, which publishes the inbox operation; the inbox closes every overdue request in one statement and tells the asker. The last exchange is the decider who arrives too late, and shows that the same rule closes the request rather than deciding it.
Figure 1: Approval request expiry: the ask takes the kind's deadline, the inbox registers the job whose schedule is a system setting, the scheduler fires it, the job publishes the inbox's expiry operation, the inbox closes every overdue request across every tenant in one statement and tells the asker, and a decider who reaches for a lapsed request meets the same rule rather than deciding it.
Source: approval_request_expiry_interaction.puml. Regenerate with
plantuml -tpng doc/knowledge/architecture/approval_request_expiry_interaction.puml.
4. Patterns used
- Scheduled Work Acting as a Tenant — the job belongs to no tenant and its close reads every tenant, so the work a schedule fires is not scoped the way a person's call is.
- Idempotent Receiver — the sweep states its rule over the deadline rather than over an interval, so a repeated run and a run after an outage both converge.
5. See also
- Approval requests — the record an expiry closes, and why a request is its own row rather than a waiting workflow.
- Role requests — the first kind, and what a lapsed request means to the member who asked.
- Notification requirements — how a notice is raised, to whom, and why an expiry carries its own kind.
- Ask for more access — the journey whose gap this closes: a request nobody answers used to wait forever.
- ores.scheduler — the component the job row belongs to, and the jobs it already fires this way.
- ores.variability.system_setting — the setting the schedule is read from, and the first consumer outside variability.
- ores.variability — component group overview, and the installation-level knobs it carries.
- ores.variability.operations —
the
get_settingoperation the schedule is read through, and why a setting is asked for by name rather than listed. - ores.inbox.approval_operations — the operations a request is raised, decided and swept through.
- ores.inbox — component group overview.
- NATS Entity Protocol Specification — the grammar the expiry subject obeys.
- Compute job lifecycle — a sibling page, where a schedule and a durable consumer carry work instead.