Split out of #4437 (GAP-1). This is the contentious third of that issue and deliberately separated so it can be argued on its own — the attribution and trigger pieces do not depend on it.
The ask
A per-schedule record of the last run: LastRunAt, LastStatus, LastError (history explicitly not included) on the tracking row and on RecurringScheduleDescriptor, so a console can render a status badge and decide whether to offer a re-trigger without querying an external tracing backend.
The standing position
From the #4437 thread: run state is obtainable from OpenTelemetry data, and that was a deliberate choice rather than an oversight. The counter-argument in the issue is that OTel is sampled, retention-limited and lives in a backend a console cannot cheaply query per schedule, whereas "did the last run fail?" is one small durable fact.
That trade is a product decision. What follows is only what the code says it would cost.
Structural problems, beyond effort
1. The agent cannot be the writer. Its involvement ends at publish (RecurringMessageAgent.publishOccurrenceAsync / recordPublishAsync, RecurringMessageAgent.cs:382-484); afterwards it only counts whether the envelope is still Scheduled (RdbmsRecurringMessageStore.cs:179-198). The occurrence is handled later, through the normal inbox pipeline, on whatever node claims it. So the writer has to be a message-pipeline seam — IMessageTracker is the central one (src/Wolverine/Logging/IMessageLogger.cs:53,59,66,86), with three runtime implementations that would need to stay in sync if hooked below the interface. (IWireTap is opt-in per endpoint, ListenerConfiguration.cs:586, so it is not a default-on hook; IWolverineObserver is node/agent lifecycle only and not a fit.)
2. The handling node may have nowhere to write. IMessageStore.RecurringMessages is built Main-store-only (MessageDatabase.cs:172-176; interface default IMessageStore.cs:157). A node handling the occurrence against an ancillary store gets NullRecurringMessageStore, so last-run state would silently not be recorded in exactly the modular-monolith setups where per-schedule visibility is most wanted. This needs an answer before the feature is coherent.
3. It puts a write on the handling hot path. One additional store round-trip per occurrence handled, on every schedule, forever.
4. Terminal-path discipline. There must be exactly one terminal record per path — the convergence points are MessageSucceededContinuation.cs:26, MoveToErrorQueue.cs:69, NoHandlerContinuation.cs:55-57 and HandlerPipeline.cs:160, and the GH-4136 reasoning is documented at MoveToErrorQueue.cs:63-68. A naive writer double-writes.
Schema notes, if it does go ahead
Adding nullable columns is routine: the recurring tables are plain Weasel Table objects yielded from AllObjects() (PostgresqlMessageStore.cs:922-925 and siblings), with no hand-written migrations, and there is in-family precedent for conditional columns at DeadLettersTable.cs:46-48. Current columns are schedule_name, cron_expression, envelope_ids, dedup_id, next_occurrence, paused, paused_at, last_updated (DatabaseConstants.cs:91-99).
Non-schema work in RdbmsRecurringMessageStore: selectFields (:63-66), the ordinal-based reader readRecordAsync (:303-322), and the SQL at :79-103. Note that _updatePublishSql deliberately preserves pause state — a last-run write needs the mirror-image discipline of not clobbering publish bookkeeping. MessageDatabase.Admin.cs:314-317 clears the table on ClearAll.
Oracle, RavenDb and CosmosDb have no recurring table at all and inherit NullRecurringMessageStore, so they would report no last-run state.
Suggested decision order
Settle whether a small durable last-run fact is wanted at all given the OTel position, and if so how the ancillary-store hole (2) is closed. The rest is mechanical.
🤖 Filed with Claude Code from the #4437 analysis
Split out of #4437 (GAP-1). This is the contentious third of that issue and deliberately separated so it can be argued on its own — the attribution and trigger pieces do not depend on it.
The ask
A per-schedule record of the last run:
LastRunAt,LastStatus,LastError(history explicitly not included) on the tracking row and onRecurringScheduleDescriptor, so a console can render a status badge and decide whether to offer a re-trigger without querying an external tracing backend.The standing position
From the #4437 thread: run state is obtainable from OpenTelemetry data, and that was a deliberate choice rather than an oversight. The counter-argument in the issue is that OTel is sampled, retention-limited and lives in a backend a console cannot cheaply query per schedule, whereas "did the last run fail?" is one small durable fact.
That trade is a product decision. What follows is only what the code says it would cost.
Structural problems, beyond effort
1. The agent cannot be the writer. Its involvement ends at publish (
RecurringMessageAgent.publishOccurrenceAsync/recordPublishAsync,RecurringMessageAgent.cs:382-484); afterwards it only counts whether the envelope is stillScheduled(RdbmsRecurringMessageStore.cs:179-198). The occurrence is handled later, through the normal inbox pipeline, on whatever node claims it. So the writer has to be a message-pipeline seam —IMessageTrackeris the central one (src/Wolverine/Logging/IMessageLogger.cs:53,59,66,86), with three runtime implementations that would need to stay in sync if hooked below the interface. (IWireTapis opt-in per endpoint,ListenerConfiguration.cs:586, so it is not a default-on hook;IWolverineObserveris node/agent lifecycle only and not a fit.)2. The handling node may have nowhere to write.
IMessageStore.RecurringMessagesis built Main-store-only (MessageDatabase.cs:172-176; interface defaultIMessageStore.cs:157). A node handling the occurrence against an ancillary store getsNullRecurringMessageStore, so last-run state would silently not be recorded in exactly the modular-monolith setups where per-schedule visibility is most wanted. This needs an answer before the feature is coherent.3. It puts a write on the handling hot path. One additional store round-trip per occurrence handled, on every schedule, forever.
4. Terminal-path discipline. There must be exactly one terminal record per path — the convergence points are
MessageSucceededContinuation.cs:26,MoveToErrorQueue.cs:69,NoHandlerContinuation.cs:55-57andHandlerPipeline.cs:160, and the GH-4136 reasoning is documented atMoveToErrorQueue.cs:63-68. A naive writer double-writes.Schema notes, if it does go ahead
Adding nullable columns is routine: the recurring tables are plain Weasel
Tableobjects yielded fromAllObjects()(PostgresqlMessageStore.cs:922-925and siblings), with no hand-written migrations, and there is in-family precedent for conditional columns atDeadLettersTable.cs:46-48. Current columns areschedule_name,cron_expression,envelope_ids,dedup_id,next_occurrence,paused,paused_at,last_updated(DatabaseConstants.cs:91-99).Non-schema work in
RdbmsRecurringMessageStore:selectFields(:63-66), the ordinal-based readerreadRecordAsync(:303-322), and the SQL at:79-103. Note that_updatePublishSqldeliberately preserves pause state — a last-run write needs the mirror-image discipline of not clobbering publish bookkeeping.MessageDatabase.Admin.cs:314-317clears the table on ClearAll.Oracle, RavenDb and CosmosDb have no recurring table at all and inherit
NullRecurringMessageStore, so they would report no last-run state.Suggested decision order
Settle whether a small durable last-run fact is wanted at all given the OTel position, and if so how the ancillary-store hole (2) is closed. The rest is mechanical.
🤖 Filed with Claude Code from the #4437 analysis