learn / 04·4 — under org · timestamps

when, on theSAMEline as what

An org timestamp is a date a machine can act on, written one line under the work it schedules. <2026-06-06 Sat 06:00 +1d> sits on the same headline as the code that runs — and an eighteen-line parser turns it into {at, repeat, active} that rides through every plan and every run record. This page is the grammar, and the honest path that data takes.

timestamps11 min read
A small figure standing beneath a colossal bright wall-clock built into the spine of an open book, its single hand a beam of light marking one luminous line of text — 1970s sci-fi style, monumental and warm

the crontab nobody READS

Every system keeps "when" somewhere, and that somewhere is almost always unreadable. A crontab on a box no one logs into. A scheduler dashboard behind a console. A block of CI yaml with a five-field incantation at the top. The plan, meanwhile, says "every morning" in prose — in a doc, a ticket, a README — and the machine's copy of that same fact lives in a different universe entirely.

So they drift. The prose says daily; the cron says weekly; the person who knew which was right left in March. The cost isn't the syntax — five star-fields are learnable. The cost is the distance: the schedule and the thing it schedules are two artifacts, in two formats, reviewed by two different nobody.

Org closes the distance by refusing to move the schedule out of the plan. The "when" sits one line under the "what" — on the same headline, in the same parseable tree as the code that runs. The timestamp isn't metadata about the plan. It's a fact in the plan.

the DEFINITION

time·stamp /ˈtaɪm·stamp/ noun

1. a date a machine can act on, written inline on the headline it belongs to. <…> is an appointment — something to fire; […] is a note — something recorded. On a planning line it parses into {at, repeat, active} and rides into every plan and run record the kernel emits.

It's a construct of the grammar — the same tree that holds headlines, tags, TODO states, and drawers. The parser for it is small enough to read in a sitting, and most of this lesson is walking those eighteen lines and the data path they open. The sibling deep-dive schedules owns the engine that fires these; this page owns the grammar and the data, and hands off the clock explicitly.

the token CLASSIFIER

The whole timestamp parser is about eighteen lines of Rust in the kernel crate. It does two things. First, it reads the very first character to decide whether the timestamp is active. Then it splits the inside on whitespace and classifies each token in one pass by its shape — first match wins, per token. Here is that classifier walking left to right over the canonical line:

SCHEDULED: <2026-06-06 Sat 06:00 +1d>
           │ └──────────┘ └─┘ └───┘ └──┘
           │   date       day  time  repeat
           └─ the bracket → the active bit
flowchart TD
  start["a token from inside the brackets"]
  d{"length 10
and a dash at index 4?"} t{"contains a colon?"} r{"starts with + / ++ / .+ ?"} date["it's the DATE
2026-06-06"] time["it's the TIME
06:00"] rep["it's the REPEAT
+1d"] drop["ignored — tolerated, never validated
(the day name Sat lands here)"] start --> d d -- yes, and no date yet --> date d -- no --> t t -- yes --> time t -- no --> r r -- yes --> rep r -- no --> drop style date fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style time fill:#aee5c2,stroke:#121316 style rep fill:#9fc4e8,stroke:#121316 style drop fill:#d9dbd3,stroke:#121316 style start fill:#ffffff,stroke:#121316

Read the diagram as a single funnel each token falls through. A date is the first token of exactly ten characters with a dash at byte four — that's how 2026-06-06 is recognized, and it's also why 2026-6-6 is silently rejected: nine characters, no match, and because a date is required, the whole timestamp evaporates. A time is any token containing a colon. A repeater is any token starting with +, ++, or .+. And everything else is ignored — the day name Sat falls straight to the floor, tolerated and discarded, never once checked against the date. A Sat written on a Wednesday parses without complaint.

What comes out is exactly this, and nothing more — date and time joined with a T, the repeater verbatim, the active bit:

{ "at": "2026-06-06T06:00", "repeat": "+1d", "active": true }

That is the entire contract. The kernel does not interpret the repeater, does not resolve the day name, does not know what "now" is. It reads shape, emits structure, and stops.

one bracket, one BIT

Active versus inactive is the first character and nothing else. Angle brackets mean active; square brackets mean inactive; the shapes inside are identical. Mismatched brackets — an open angle closed with a square — match no arm and parse to nothing at all.

writtenactiveorg's meaningwhat a consumer should do
<2026-08-01 Sat 10:00>truean appointmenttreat as armed — eligible to fire
[2026-08-01 Sat 10:00]falsea recorded notesurface it, but don't fire on it
<2026-08-01 Sat 10:00]nothing — no matchno timestamp parses at all

Here's the part worth internalizing: an inactive SCHEDULED: still flows all the way through. Write SCHEDULED: [2026-08-01 Sat 10:00] on a planning line and the plan carries "active": false — recorded, not armed. schedule_of does not filter on the active bit; it surfaces the fact and leaves the decision to whoever consumes it. The kernel records; the consumer decides. That separation is deliberate — the kernel's job is to never lose information, not to enforce policy.

trigger vs FACT

Two planning keywords carry timestamps, and they are not symmetric. Only SCHEDULED feeds the workflow schedule. DEADLINE is parsed, surfaced, and carried — but it never becomes a trigger. A workflow with only a DEADLINE resolves to "schedule": null in its plan, verified. A deadline is a fact you do arithmetic on — overdue is now > at — not a clock that wakes anything up.

shows in wb query?in the tangle plan's schedule?fires anything?
SCHEDULEDyes — as scheduledyes — feeds schedule_ofit's the trigger
DEADLINEyes — as deadlineno — always null from a deadlinenever — it's a fact, not a clock

The verdict of that table in one line: both keywords are read and surfaced, but only SCHEDULED can wake work. This is also the single most common misconception about org time — people write a DEADLINE and wonder why nothing ran. Nothing ran because a deadline was never a trigger. It's a date the validator can measure against, the same way a passed deadline on a TODO becomes an overdue flag, not a notification.

One more asymmetry worth stating: a timestamp written in body prose — a bare [2026-06-12 Fri 11:30] sitting in a paragraph — is not picked up as a schedule. Only the planning line feeds scheduled and deadline; headline_of reads them off hl.planning() and nowhere else. A date in prose is documentation; a date on the planning line is structure.

the three REPEATERS

depth rung · skippable — the repeater conventions, for the curious

A repeater is a token like +1d that says "and again, on an interval." Org has three prefixes, and they mean three different things. The crucial fact about the kernel is that it carries all three verbatim as opaque strings — it preserves the distinction without ever computing the next fire. The meaning rides in the string; the scheduler reads it later.

prefixorg conventionif you miss it by three days…
+1dshift from the old date by the intervalnext fire is tomorrow, then the day after — the schedule keeps its original cadence regardless of when you acted
++1dshift until in the future by the intervalit jumps forward repeatedly past today, landing on the next still-future slot — no pile-up of missed fires
.+1dshift from the completion datethe clock restarts from when you actually finished — three days late means the next one is three days later too

The verdict of that table: + is fixed cadence, ++ is catch-up-to-now, and .+ is measured-from-done. Each is a different answer to "what counts as the anchor." The kernel doesn't pick — it captures the prefix exactly so the scheduler can. If two repeaters somehow appear in one timestamp, the last one wins by plain overwrite; there's no error, just the final value.

the cron escape HATCH

depth rung · skippable — when a timestamp isn't enough

Some schedules aren't calendar-shaped. "Every weekday at 6" or "the 1st and 15th" want cron's five fields, not a single appointment with a repeater. For those, drop a :SCHEDULE: key into a properties drawer and write plain cron. Drawer keys are uppercased on parse, so :schedule: in lowercase works exactly the same.

When both are present, the drawer wins. schedule_of checks the SCHEDULE property first; only if it's absent does it fall back to the SCHEDULED timestamp. Here is that dual clock probed against the real surfaces:

* Cron wins                          :workflow:
  SCHEDULED: <2026-07-01 Wed 09:00 ++1w>
  :PROPERTIES:
  :SCHEDULE: 0 6 * * *
  :END:

wb query still reports the timestamp in full — "scheduled": {"at": "2026-07-01T09:00", "repeat": "++1w", "active": true}, with the property visible as {"SCHEDULE": "0 6 * * *"}. But wb tangle resolves the world's schedule to {"cron": "0 6 * * *"} — the drawer overrode the timestamp, and the timestamp is still on record. One snippet teaches two things at once: who wins, and that query shows what was written while tangle shows what was resolved.

The rule of thumb is simple. Calendar-shaped time — "every morning," "this Friday," an appointment a human would write on a wall — wants a timestamp. Machine-shaped time — odd cadences, multiple fixed minutes, anything a human would reach for cron to express — wants :SCHEDULE:.

through the PLAN

Follow one line from text to run record. The journey is short and every hop is a named function in the kernel, so there's no magic to take on faith:

flowchart LR
  org["SCHEDULED: <…06:00 +1d>
on a :workflow: headline"] ho["headline_of
reads the planning line"] so["schedule_of
:SCHEDULE: prop, else SCHEDULED"] bw["build_world
schedule on EVERY world"] run["Workflow.run
schedule in EVERY run record"] who["the keeper · external cron · POST /api/workflow
— something with a clock wakes it"] org --> ho --> so --> bw --> run --> who style org fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style ho fill:#ffffff,stroke:#121316 style so fill:#aee5c2,stroke:#121316 style bw fill:#9fc4e8,stroke:#121316 style run fill:#f3c5a3,stroke:#121316 style who fill:#d9dbd3,stroke:#121316

Read it as a relay. The planning line is read by headline_of. schedule_of resolves it — drawer first, then the timestamp — into the schedule field. build_world attaches that field to every world it builds, and it builds them recursively — which is the quietly important part. Because each world carries its own schedule, a monthly parent workflow can hold an hourly child, each on its own clock. The nesting lesson states this in a sentence; here is the mechanism: per-level schedules, because build_world recurses and schedule_of runs at every level.

Finally, Workflow.run puts the schedule into every run record and every summary, recursively through sub-workflows. So the answer to "what does this run think its schedule is?" is always in the record, never reconstructed. And you can read the whole plan without running it: POST /api/workflow with ?plan=1 returns the schedule and plan and runs nothing.

checking what it EXTRACTED

depth rung · skippable — the three ways to see the parse

You never have to guess what the machine read off your line. Three surfaces show it. wb query <file> shows the raw parse — the scheduled and deadline objects per headline. wb tangle <file> shows the resolved plan — the schedule on each world, after the drawer-vs-timestamp precedence runs. And POST /api/workflow?plan=1 returns that same plan over HTTP without executing anything.

The canonical example is nightly-digest.org — a :workflow: headline scheduled <2026-06-06 Sat 06:00 +1d> over a rust → js → go pipeline. Its two surfaces line up exactly:

wb query  → "scheduled": { "active": true, "at": "2026-06-06T06:00", "repeat": "+1d" }
wb tangle → "worlds"[0]["schedule"]: { "active": true, "at": "2026-06-06T06:00", "repeat": "+1d" }

And the DEADLINE-only case, the one that catches everyone, settled in eight lines of real output:

* Deadline only                      :workflow:
  DEADLINE: <2026-07-04 Sat>

wb query  → "deadline": { "at": "2026-07-04", "repeat": null, "active": true }
wb tangle → "schedule": null

The deadline parsed perfectly — it's right there in the query — and the plan's schedule is still null, because a deadline was never a trigger. If you expected a fire and got silence, this is the check that explains it in one read.

the kernel has no CLOCK

Honesty section, because the grammar makes a promise the kernel deliberately does not keep. The kernel is pure in, pure out — no host calls, no clock. It cannot know what "now" is. It can carry time as data; it cannot feel time pass. Every fact above is about reading and surfacing a date, never about acting on one.

So what actually wakes a workflow? Today, something outside the kernel with a clock of its own. The only callers of Workflow.run in the host are the HTTP endpoint and the demos — there is no daemon walking org timestamps on a timer. The runtime's native clock is the keeper, but it ticks agent definitions on an interval set by WB_KEEPER_INTERVAL_MS — not org timestamps. So the live path is: the timestamp is parsed, surfaced in the plan and the run record, and then an external trigger — cron, CI, or a machine exec hitting POST /api/workflow — or the keeper-by-interval does the waking. The parent workflows lesson says "the engine fires these"; this deep dive is more precise than its parent, on purpose: the engine can fire these, and the firing engine is the sibling lesson schedules, not this one.

A few smaller honest edges, all by construction:

  • Repeater math is the consumer's job. +1d rides through as the string "+1d". The kernel never computes the next fire; it preserves the distinction so the scheduler can.
  • Time ranges survive as non-ISO strings. A token like 06:00-07:30 contains a colon, so it's kept whole as the time — yielding "at": "2026-06-20T06:00-07:30", which is not valid ISO. An honest quirk: the classifier matches on shape, not on validity.
  • No timezones, anywhere. The grammar has no timezone field. All time is engine-local. If you need zone-correct firing, that lives in the engine that runs the plan, not in the timestamp.
  • Org warning cookies are dropped. A -2d warning on a deadline matches no arm and is silently discarded — surfaced data only, never a behavior.

questions people actually ASK

Why didn't my DEADLINE run anything?

Because a deadline was never a trigger. schedule_of only consults SCHEDULED (and the :SCHEDULE: property); a DEADLINE-only workflow resolves to "schedule": null, verified. A deadline is a date you measure against — overdue is now > at — not a clock. Move it to SCHEDULED if you want it to fire.

Do I need the day name, like Sat?

No. The day name is decorative. The classifier matches no arm for it, so it's tolerated and dropped, and it's never validated against the date — a Sat on a Wednesday parses cleanly. Write it for human readability or omit it; the parse is identical either way.

What happens with 2026-6-6?

No timestamp at all. The date rule is exactly ten characters with a dash at byte four; 2026-6-6 is nine characters and matches nothing. Because a date is required, the whole timestamp silently drops. Always zero-pad: 2026-06-06.

Which wins — :SCHEDULE: or SCHEDULED?

The :SCHEDULE: drawer property wins. schedule_of checks the property first and only falls back to the timestamp if it's absent. The plan resolves to {"cron": …} while wb query still shows the timestamp — overridden, but not erased.

Where are timezones?

Nowhere in the grammar. There's no zone field; all time is engine-local. Zone-correct firing is a property of the engine that runs the plan — the kernel only ever carries the wall-clock string you wrote.

Can I put a timestamp in body text instead of the planning line?

You can write one, but it won't schedule anything. Only the planning line feeds scheduled and deadline; a bare […] in prose parses as a date but is never picked up as a schedule. Put it on the planning line under the headline.

keep GOING

This page is the grammar and the data path; its neighbors hold the engine and the rest of the tree.