Task: Give the role request a deadline

Table of Contents

This page documents a task in the Land the role request journey story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

A role request that nobody answers closes on its own, so a queue cannot rot. The machinery is built: the kind's expires_after_days sets the request's expires_at, and the decide function closes an open request past its deadline as expired instead of deciding it. The iam.role_grant kind is seeded with no deadline, so nothing expires; and expiry fires only when somebody reaches for the decision, so an untouched queue still waits forever.

2. Status

Field Value
State DONE
Parent story Land the role request journey
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-10-08

3. Acceptance

  • The iam.role_grant kind carries a deadline, seeded, and a request raised under it records expires_at.
  • A request past its deadline closes as expired, and the person who asked is told. The deciders are not told: the queue is where they look, and the request leaves it. The journey's separate candidate, a reminder before the deadline, is not built here and is captured instead.
  • An untouched queue does not hold an expired request: the sweep decision is recorded and implemented.
  • The journey records gap 7 as closed.

4. Plan

The deadline is a column the kind already has. approval_kind.expires_after_days and approval_request.expires_at both exist, and the raise path already turns one into the other, so seeding fourteen days on iam.role_grant is the whole of the first acceptance criterion. The seed states it as the review window a member waits, which is a fortnight and not a number the code should hold.

The sweep is the part that does not exist. The decide function closes an overdue request when a decider reaches for it, which is no help to a queue nobody opens, and an untouched queue is exactly the case the journey names. So ores_inbox_expire_approval_requests_fn closes every overdue open request in one statement and answers one row per request it closed, and the inbox runs the sweep as an operation of its own.

The sweep is a job the scheduler fires, and the job publishes inbox.v1.ops.expire_overdue_approvals. It does not run the SQL directly: the close and the notice that tells the person who asked are one act, the notice is raised in the service, and an execute_sql job would close requests silently. A steady_timer inside the service was the first shape this took and is the wrong one – the capture that raised it says why. The design is written up on Approval request expiry, with its interaction diagram.

The component declares that the sweep recurs and the installation says how often. The inbox registers the job itself, so nothing in a populate script has to know the component's name; the frequency is the variability system setting inbox.approval_expiry.schedule, read over the wire through variability.v1.ops.get_setting for the reason that operation exists: a component reads a setting by name rather than reaching into another component's tables. A registration that cannot be made stops the service, because an inbox that is up with no expiry job is worse than one that is down and says so. The job is recognised by job_name, so a restart replaces the row rather than colliding with it.

The function reads every tenant, so it is a security-definer function and the inbox service is the only role granted execute, exactly as ores_iam_unapplied_role_grants_fn is. The read-write role the suites sign in under keeps it too, so the sweep can be tested.

An expiry is not a decision. The message a decision raises reads "{decider} wrote: {comment}", which for an expiry would name somebody who did nothing, so the expiry gets a notification kind of its own rather than borrowing the other's wording.

Two things only running it found, both now fixed. The sweep starts from the scheduler, so the context that runs it has no actor, and the store refuses a write that names nobody: the sweep names the service account, which is the honest answer to who closed the request. And the service account lives in the system tenant while the person to tell lives in theirs, so the raiser is resolved where it lives and the notice is raised where the person is – which in turn is why raised_by now admits a system-tenant account, stated in the notification model rather than in the generated SQL.

5. Measurements

Every number this task claims about a tree, with the command that produced it and the commit it was measured at. A number without both is not a measurement, because a clean-up record describes a tree and trunk moves under a branch: a consumer count, a gate's component count and a suite's size all change with no code changing, and the reader of a bare number cannot tell which tree it came from. Where the raw output matters, put it in an evidence/ file beside the task and name that file in the Command column.

What Value Command Commit
Lifecycle suite 146 assertions in 26 cases, all passing compass build test_ores.inbox.core.tests 647e7c6a56
Component drift 0 files this branch touched would change; 253 elsewhere in the catalogue check_component_drift.py --sweep 647e7c6a56
Scheduler firings of the expiry job 2, both succeeded, both on a */5 boundary compass sql -- -f evidence/expiry_job_instances.sql 647e7c6a56

6. Notes

The walk lived in evidence/ beside this page: expiry_reset.ores and expiry_ask.ores raise the request as the member, expiry_backdate.sql moves its deadline behind us, and expiry_walk_check.sql reads the outcome by the id the ask printed. Build it by resetting the member's password, asking as them, backdating, waiting for the next five-minute boundary, and reading. The ids in the two .ores scripts change on every db recreate, so they are re-read from the tables before each walk.

File What it shows
expiry_registration.txt The service reading the setting and writing the job at start, and writing nothing on a restart because the job already holds that schedule.
expiry_sweep.txt The scheduler's own record of firing the job on the schedule the setting states.
expiry_outcome.txt The closed request, the notice the asker was given, and the one thing the walk does not cover.

This task forced no capture of its own. The two things running it found were regressions in code written here, not facts about the codebase, so they were fixed rather than filed.

7. Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

8. PRs

PR Title
#2784 [inbox] Close a lapsed approval request from a scheduled job

9. Review

Comment summary File Decision Notes
       

10. Result

Built and walked. A role request raised under iam.role_grant now records a deadline fourteen days out, and a request nobody answers closes as expired and tells the person who asked.

The deadline is the kind's: iam.role_grant is seeded with expires_after_days = 14, and the raise path turns it into the request's expires_at once. The walk confirms it – a request raised at 2026-10-07 23:15:35Z records expires_at = 2026-10-21 23:15:35Z.

The close is the scheduler's. The inbox registers ores.inbox.approval_expiry at start, an nats_publish job whose schedule is the inbox.approval_expiry.schedule system setting that the inbox reads over the wire; the job publishes inbox.v1.ops.expire_overdue_approvals; and the inbox closes every overdue request across every tenant and tells each asker. The walk moved one request's deadline behind us, and the firing on the next five-minute boundary closed it – expired, system.update, "Closed because nobody answered before its deadline" – and raised inbox.approval_expired, naming the kind as a person reads it, one fifth of a second later.

Both close paths write the same tail, so a request's history reads the same however it closed. The sweep is idempotent: a request it already closed is not open, so a repeat closes nothing, and a service that was down for a week sweeps what lapsed in that week on its next run.

That the sweep recurs is the component's, and how often it recurs is the installation's. This is the split the whole task turns on, and it is the reason the schedule is a system setting rather than a constant: a component that hardcodes its own schedule is asserting something about somebody else's deployment.

Beyond the acceptance criteria, three things changed because the walk demanded them: the sweep names the service account, because a store that refuses a write naming nobody is right and the sweep must still say who closed the request; the notice's raiser is resolved in the system tenant, because that is where service accounts live; and raised_by admits a system-tenant account, stated in the notification model so the generated SQL and the intent cannot drift apart.

The journey's gap 7 is half closed here: the reminder before the deadline was not built with the deadline, and was captured then. It has since been built, as its own sweep on its own schedule, and is described with the deadline in Approval request expiry.

Emacs 29.3 (Org mode 9.6.15)