"Remind me tonight at sunset to close the chicken coop."
Until mid-September, that sentence produced a reminder that would ring every evening, forever. Not because the model misunderstood "tonight": there was simply no way to store a sun reminder that did not repeat. The creation code hard-coded a daily recurrence, the edit tool refused to change the repetition of a sun trigger, and the generic update tool refused to touch the recurrence column of a sun reminder at all. The only exit was to stop the reminder after it had rung.
XNeuronal is an Android assistant with a Node backend in TypeScript and a Postgres database. A language model turns what the owner says into tool calls; the server stores "fiches" (one row per note, task or reminder) and a cron delivers reminders. The fix landed on 15/09/2026 in one backend commit and needed no change to the scheduler. Every block below is copied from the repository and lightly trimmed.
The constraint: an hour that is never the same twice
A fixed-time reminder repeats a wall clock. "Every day at 19:00" can be advanced by adding a day to the stored hour. A sunset cannot. Between June and December it moves by several hours on the clock, and above the Arctic circle there are weeks with no sunset at all. So the next occurrence is searched for, day by day, from the place's coordinates, in nextSolarWall.ts:
export function nextSolarWall(
point: { lat: number; lng: number },
event: 'sunrise' | 'sunset',
offsetMin: number,
timeZone: string,
nowMs: number,
firstDayOffset: 0 | 1
): { wall: string; instant: Date } | null {
const startDay = instantToWall(new Date(nowMs).toISOString(), timeZone).slice(0, 10);
for (let i = firstDayOffset; i <= SOLAR_SEARCH_DAYS; i += 1) {
const day = new Date(Date.parse(`${startDay}T12:00:00Z`) + i * 86_400_000)
.toISOString()
.slice(0, 10);
const occurrence = solarInstantOn(point, day, event, offsetMin, timeZone);
if (!occurrence) continue; // no sunrise / sunset that day, try the next
if (occurrence.instant.getTime() <= nowMs) continue;
return occurrence;
}
return null;
}
SOLAR_SEARCH_DAYS is 400, a polar winter with room to spare. The walk starts today, so tonight's sunset counts while it is still ahead, and an occurrence that has just rung is behind now and skips itself.
That function answers one question: when is the next one? It says nothing about whether there should be a next one. Keep that distinction in mind, because the bug and the fix both live in it.
How a sun reminder was stored
The design choice made earlier was to not invent a new kind of row. A sun reminder is an ordinary dated reminder (reminder_long), with a wall clock, an instant, and a small metadata.solar block that tells the cron to recompute the hour from the sky instead of copying yesterday's. Before the fix, in createSolarReminder:
const { neuron } = await neuronService.create(auth, {
type: 'reminder_long' as never,
content,
due_local: wall,
due_at: next.instant.toISOString(),
recurrence: { type: 'daily' },
metadata: solarCard
? { solar: { event, offset_min: offset, place_id: placeId }, card: solarCard }
: { solar: { event, offset_min: offset, place_id: placeId } },
// ...
} as never);
recurrence: { type: 'daily' }, unconditionally. At the time it read as a tautology: a reminder that follows the seasons repeats, what else would it do?
What the cron already did
Here is the part of RemindersCron.ts that runs once a reminder's time has passed and every lead time has been delivered:
if (plan.archive) {
if (neuron.recurrence) {
if (!recurringTurnOver({ dueAt: neuron.due_at, endAt: neuron.end_at, nowMs: now, maxWaitMs: RECURRING_MAX_WAIT_MS })) {
return;
}
if (await this.rearm(neuron)) return;
}
await this.markAsDone(neuron.id);
}
And inside rearm, before any wall-clock arithmetic:
private async rearm(neuron: PendingNeuron): Promise<boolean> {
if (!neuron.recurrence) return false;
// ... timezone lookup ...
// A SOLAR reminder does not repeat an hour, it repeats a moment of the sky.
const solar = await this.solarRearm(neuron, timezone, now);
if (solar !== undefined) return solar;
// ... ordinary recurrence rules ...
}
Read the order. The cron first asks the recurrence column whether the fiche goes on. Only a recurring fiche reaches solarRearm, which then asks metadata.solar when. A fiche with no recurrence falls straight through to markAsDone, sun block or not.
So the scheduler had supported one-shot sun reminders all along. Nothing ever wrote one.
The tempting fix
The first idea is the local one. The sun reminder has a block of its own, so give the block a flag:
metadata: { solar: { event, offset_min, place_id, once: true } }
and teach solarRearm to return false when it sees once. Five lines, and the chicken coop closes once.
It would also have created a second truth about repetition, and the first one has readers. The fiche screen in the app prints its schedule line from recurrence (recurrenceLabel.ts). The export to the phone's calendar builds its RRULE from recurrence (neuronToCalendarEvent.ts). The guard that protects a sun reminder from generic edits lists recurrence among the fields the server owns. With a flag in the sun block, a one-shot reminder would carry recurrence: daily next to once: true: the screen would say "every day", the calendar would say "every day", and the cron would say "once". Each reader right about its own source, and the owner looking at a fiche that lies.
The rule I applied instead: the repetition of a fiche lives in the column every reader already reads. A type-specific block may say how to compute the next occurrence, never whether there is one.
Creation: one argument, one column
The creation tool already had a recurrence parameter (once or always), used by weather watches. The sun path now honours it:
// « Ce soir au coucher du soleil » rings once : no recurrence, so RemindersCron
// marks the fiche done after it rang instead of re-arming it on the sun.
const once = args.recurrence === 'once';
const { neuron } = await neuronService.create(auth, {
type: 'reminder_long' as never,
content,
due_local: wall,
due_at: next.instant.toISOString(),
recurrence: once ? null : { type: 'daily' },
// ...
} as never);
return {
ok: true,
data: { neuron_id: neuron.id, first_occurrence: wall, place_label: placeLabel, event, recurrence: once ? 'once' : 'always' },
message: once ? SOLAR_ONCE_SPEAK_TIME_MSG : SOLAR_SPEAK_TIME_MSG
};
The default stays daily: "remind me to bring the hens in at sunset" is a habit, and a habit that silently stopped after one evening would be the worse failure.
Editing: reading a column you were not always given
Editing is where it got subtle. The edit engine is pure: it reads a Trigger from a row, applies a list of operations, and hands back the next trigger. set_recurrence existed for location reminders and weather watches, and its table of allowed kinds now includes the sun:
const OP_KINDS: Readonly<Record<string, readonly TriggerKind[]>> = {
// ...
set_recurrence: ['location', 'condition', 'solar'],
// ...
};
For a sun trigger, though, the repetition is not in the metadata the engine reads. It is a column. readTrigger is called from several places, and not all of them select that column. So the reader only fills it when it was actually passed:
if (neuron.type === 'reminder_long') {
const solar = readSolar(metadata);
if (solar) {
// A sun reminder repeats through the fiche's `recurrence` column (RemindersCron
// re-arms only a recurring fiche) : read when the caller passed the column,
// never guessed when it did not.
if ('recurrence' in neuron) solar.recurrence = neuron.recurrence ? 'always' : 'once';
return { kind: 'solar', solar };
}
}
'recurrence' in neuron rather than neuron.recurrence. A row selected without the column would otherwise read as undefined, which is falsy, which is "once". A partial select would have quietly announced every daily sun reminder as a one-shot. Absent means unknown, and unknown stays absent.
The field on the in-memory trigger is documented as a projection, not storage:
export interface SolarTrigger {
event: 'sunrise' | 'sunset';
offset_min: number;
place_id: string;
/** The fiche's own repetition, read from its `recurrence` column : 'once' when it has
* none (it rings the next time, then RemindersCron marks it done), 'always' when it
* repeats every day. Absent when the reader did not pass the column. Never stored
* inside metadata.solar. */
recurrence?: 'once' | 'always';
}
And the single write path of a trigger edit takes it back out before writing the block:
if (next.kind === 'solar') {
const occurrence = await this.nextSolarOccurrence(auth, next.solar, existing);
if (!occurrence.ok) return occurrence;
// The repetition lives in the fiche's `recurrence` column (RemindersCron
// re-arms only a recurring fiche), never in the stored sun block.
const { recurrence: solarRecurrence, ...solarBlock } = next.solar;
metadata.solar = solarBlock;
if (solarRecurrence !== undefined) patch.recurrence = solarRecurrence === 'once' ? null : { type: 'daily' };
delete metadata.reminders_fired;
patch.due_local = occurrence.wall;
nextOccurrence = occurrence.wall;
}
The destructuring is the whole rule in one line: the engine may reason with the repetition next to the event and the offset, but storage splits them again. The test pins exactly that:
test('a sun reminder set to ring once writes no recurrence, and the outline says once', async (t) => {
// ...
const result = await makeHandlers(emitted).dispatch(
'edit_reminder_trigger',
{ neuron_id: REMINDER_ID, operations: [{ op: 'set_recurrence', recurrence: 'once' }] },
AUTH
);
assert.equal(result.ok, true, result.error);
const patch = written[0].patch;
assert.equal(patch.recurrence, null, 'no recurrence : RemindersCron marks it done after it rang');
assert.deepEqual(metaOf(patch).solar, { event: 'sunset', offset_min: 0, place_id: ANCHOR_ID }, 'the repetition is never stored in the sun block');
assert.equal((result.data as { trigger_outline: { recurrence?: string } }).trigger_outline.recurrence, 'once');
});
A second test goes back the other way, from once to always, and expects { type: 'daily' }. A third one on the pure engine checks that a row passed without the column yields a trigger with no recurrence key at all.
The model reads descriptions, not code
With a language model in front of the tools, the server change is half of the fix. The model cannot see rearm. It sees a JSON schema and a few sentences, and it will not ask for recurrence: "once" on a sunset unless those sentences say it can. The prompts are in French; the recurrence parameter's description now ends with, translated: "Sun reminder: always (default) = every day; once = only the next time ("tonight at sunset"), then the fiche is done." The solar_event description and the set_recurrence line of the edit tool say the same.
The tool result matters as much. The assistant confirms out loud, from what the server returns rather than from what it meant to do, so a one-shot creation comes back with its own message asking the model to say the hour and "just once", instead of the daily one that promises the reminder will follow the seasons. A reminder announced as "every evening, following the seasons" when it will ring once is the same lie as the flag in the wrong block, told by voice.
This project already has guard tests that read tool descriptions (editReminderTriggerTool.test.ts checks that every op the engine knows is named, and that schema bounds equal the server constants). They catch a description that forgets a capability. They cannot catch a sentence that still asserts an old limit. The same morning, in the same two files, I found four texts (two tool descriptions, one tool-result message, one code comment) still saying that the moment a weather threshold is read aloud was the owner's only chance to correct it, four days after an edit tool had made that false. A model told that nothing can be changed has no reason to look for the tool that changes it.
Where it stands today
The change is server-side only, deployed, and covered by tests on the pure engine, the creation path and the edit handler. Because the decision was already in the cron, it applies to every installed version of the app without a release.
Two limits are true today and worth stating.
The phone's local alarm is the primary delivery channel for reminders (it works offline), with a server push as the safety net. A sun reminder's hour changes every day on the server, and the phone learns the new hour when it fetches its upcoming reminders, which happens at app start or after a timezone change, or when the reminder is edited. Neither the creation of a sun reminder nor the nightly re-arm pushes the new hour to the phone. Between those moments, the server push is what delivers it. For a one-shot reminder created in conversation the same applies to its single occurrence.
And the refusal returned when the model tries to change a sun reminder's recurrence through the generic update tool still points it to the offset and the event, not to set_recurrence. The right tool exists and is described; the error message that should steer the model to it does not name it yet. That is exactly the class of drift described above.
The lesson I kept is small and general. When a new kind of row borrows a generic mechanism, check which question each field answers before adding one. metadata.solar answers when. recurrence answers whether again. The bug was a hard-coded answer to the second question, and the fix was to stop answering it in the wrong place.
Sun reminders, one-shot or daily, ship in the Android app at xneuronal.com.
Top comments (0)