Appearance
Add a scheduled reminder job
What you're doing
"Flip this record's status once a date passes" — or "N days before it passes". Overdue checkouts, expiring warranties, upcoming audits. You write no Java and no job class. The platform ships one generic job, engine-entity.status-date-sweep, that runs daily and applies every entity_status_date_sweep_config row. Adding a reminder = adding one config row, shipped with your plugin as seed data.
How the generic job works
Once a day (cron 0 5 0 * * *, per tenant) the job:
- reads every active
entity_status_date_sweep_configrow; - for each, lists all rows of that config's
entity_name; - for every row whose
status_fieldis one ofwhen_status_inand whosedate_fieldsatisfiescompare_opagainst now (optionally shifted byday_offsetdays), it setsset_field(defaults tostatus_field) toset_status_to.
Because a swept row no longer matches when_status_in, re-running the job is a harmless no-op — it's naturally idempotent.
The complete example
spk-assembly/metadata/seed-data/office-equipment-sweep-configs.json — real file:
json
{
"entity": "entity_status_date_sweep_config",
"keyFields": ["entity_name", "date_field", "set_status_to", "day_offset"],
"source": "office-equipment",
"rows": [
{
"entity_name": "oeq_checkout",
"status_field": "status",
"when_status_in": "CHECKED_OUT",
"date_field": "due_date",
"compare_op": "lte",
"day_offset": 3,
"set_status_to": "DUE_SOON",
"active": true,
"seeded_by": "office-equipment"
},
{
"entity_name": "oeq_checkout",
"status_field": "status",
"when_status_in": "CHECKED_OUT,DUE_SOON",
"date_field": "due_date",
"compare_op": "lt",
"day_offset": 0,
"set_status_to": "OVERDUE",
"active": true,
"seeded_by": "office-equipment"
}
]
}You also need the shared config entity to exist. Ship it (idempotent-additive): spk-assembly/metadata/entities/entity_status_date_sweep_config.json — real file.
Line by line
The seed-data wrapper
entity—entity_status_date_sweep_config, the shared platform config table.keyFields— the natural key. On re-install, a row matching all of these is updated (only if a value changed) rather than duplicated. Includeday_offsetso the 3-day and 0-day rows for the same date column are treated as distinct.source— your plugin id; stamped intoseeded_by.rows— the config rows.
Each config row
entity_name— the entity to sweep.status_field— which column holds the lifecycle status (defaultstatus).when_status_in— comma-separated statuses a row must currently be in to be eligible.date_field— the date/timestamp column to compare against now.compare_op—lte | lt | gte | gt.day_offset—0= compare against now exactly.3= compare againstnow + 3 days, i.e. "fires whendue_dateis within 3 days" — this is the "N days before" reminder. Negative shifts earlier.set_field— the column to write. Omit it to writestatus_fielditself.set_status_to— the new value.
The two rows together
- Row 1: a
CHECKED_OUTcheckout whosedue_dateis within 3 days →DUE_SOON. - Row 2: a
CHECKED_OUTorDUE_SOONcheckout whosedue_datehas passed →OVERDUE.
This two-row shape (a "soon" warning then an "it happened" flip) is the exact pattern the platform's own leave_policy sweep uses.
Flip a status, not a boolean
Write to an enum/text status column, as above. Writing set_status_to: "true" to a boolean set_field currently fails inside the generic job with a SQL type error — the job passes the value as a string. Model reminders as status values (DUE_SOON, OVERDUE) rather than boolean flags until that is fixed. (This was found while building this tutorial.)
The job registers itself
The platform ships an AFTER_CREATE rule on entity_status_date_sweep_config that calls ensureEntityStatusDateSweepJobRegistered — so the first config row your plugin seeds auto-registers the job for your tenant. You don't ship that rule.
Ground yourself first
bash
erp schema pull entity-status-date-sweep-config
erp schema pull plugin-seed-data
erp examples patterns --kind jobsHow to verify it worked
After publishing, the job is registered and enabled:
bash
erp api get "/api/v1/jobs/engine-entity.status-date-sweep"json
{ "jobCode": "engine-entity.status-date-sweep", "status": "ENABLED",
"cronExpression": "0 5 0 * * *", "concurrencyPolicy": "PER_TENANT" }Run it on demand and check a record flips. This exact sequence was run against the tutorial module:
bash
# a checkout that is CHECKED_OUT with a due_date in the past
erp api post "/api/v1/entities/oeq_checkout/records" \
--body '{"checkout_number":"CO-100","equipment_id":1,"employee_id":1,"checkout_date":"2026-08-01","due_date":"2026-09-04","status":"CHECKED_OUT"}'
erp api post "/api/v1/jobs/engine-entity.status-date-sweep/execute" --body "{}"
# → { "executionId": 534 }
erp api get "/api/v1/jobs/engine-entity.status-date-sweep/executions?size=1"
# → resultJson: {"swept": 4, "failed": 0, "rowsScanned": 102, "configsScanned": 30}
erp api get "/api/v1/entities/oeq_checkout/records/2"
# → { "checkout_number": "CO-100", "status": "OVERDUE" }CHECKED_OUT → OVERDUE. Verified.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
job execution reports "failed": N | set_field is a boolean column | flip a status value instead |
| nothing sweeps | when_status_in casing doesn't match the enum | match exactly |
| "N days before" never fires | forgot day_offset (defaults to 0) | set day_offset to the window |
| re-install duplicates the config | keyFields don't uniquely identify the row | include enough fields (add day_offset, set_field) |
| job not registered | plugin seeded no config row, or the config entity wasn't shipped | ship entity_status_date_sweep_config.json and at least one seed row |