Verify, don't just trust: checking AI-generated cron jobs and infrastructure
An LLM will hand you a cron expression and a manifest that look right and run wrong. Here is the verification pass that catches them before a scheduler does.
The reason “verify, don’t just trust” is a phrase DevOps engineers actually say out loud is not that models write nonsense. It is that they write something plausible. A cron expression that parses. A manifest the API server accepts. A Compose file that starts. Review passes because there is nothing to see, CI passes because CI does not have opinions about schedules, and the thing runs on a cadence nobody intended until a bill or an incident channel says otherwise.
Generated infrastructure fails differently from hand-written infrastructure. Hand-written config fails at the syntax layer, loudly, in front of the person who wrote it. Generated config fails at the semantic layer, silently, in front of nobody — because the generator optimised for text that looks like the corpus, and the corpus is full of five different cron dialects, two different byte prefixes, and a decade of Stack Overflow answers written for a scheduler you are not running.
Key Takeaways
| Check | What to do | Why it matters |
|---|---|---|
| Field count | Count the columns before you read them | A 6-field expression in a Vixie crontab makes the 6th field the command, not seconds |
| Day columns | If neither day column is *, the two are OR-ed |
0 0 13 * FRI is “the 13th or any Friday” — not “Friday the 13th” |
| Step semantics | A step is a value set, not an interval | */7 in minutes runs at :56 then :00 — a 4-minute gap the “every 7 minutes” reading never predicts |
| Memory suffix | m is milli, M is mega |
memory: 100m requests a tenth of a byte and the API server accepts it |
| Binary vs decimal | 1Gi − 1G = 73,741,824 bytes |
An LLM swaps these freely because both appear in the training data |
| Dropped flags | Diff the generated Compose against the docker run it came from |
A converter — or a model — that drops --security-opt hands back a container with different privileges |
- Reading the output is not verifying the output. Verification means computing the next five run times, the actual byte count, the actual host range.
- Every tool on this site runs in your browser — no accounts, no uploads, nothing you paste leaves the page. Which matters more than usual here, because verifying generated infra means pasting real internal config.
- The whole pass below takes about two minutes per artefact. That is the entire cost of the last line of defense.
Why generated schedules are wrong in a specific way
An LLM produces the most probable continuation. For cron, the training corpus contains at least four incompatible dialects that share a syntax:
- Vixie cron — the crontab on Debian, Ubuntu, Alpine. Five fields. This is what
crontab -eon your box is running. - Quartz — six or seven fields, with
L,W,#and?extensions. Java scheduling, and a large share of the tutorials. - Spring
@Scheduled— six fields, seconds first. - Kubernetes
CronJob— five fields, and the schedule is interpreted in the cluster’s configured timezone unless you setspec.timeZone.
Ask for “every five minutes with second precision” and the most probable output is 0 */5 * * * *, because that is correct Spring. Paste it into a Vixie crontab and the parse succeeds, because Vixie reads five fields and treats the rest of the line as the command:
| Column | Value | Vixie reads it as |
|---|---|---|
| 1 | 0 |
minute = 0 |
| 2 | */5 |
hour = 0, 5, 10, 15, 20 |
| 3 | * |
day of month = every |
| 4 | * |
month = every |
| 5 | * |
day of week = every |
| 6 | * |
the command to execute |
You asked for 288 runs a day. You got 5 — at 00:00, 05:00, 10:00, 15:00 and 20:00 — each one shelling out to *. No error at install time. No error at parse time. The only signal is the run times, which is exactly the thing nobody checks.
Paste the expression into the Cron Expression Parser and it reports the field count it read, the values each column admits, and the next runs. A 6-field expression is read as 6 fields deliberately — the seconds dialect is supported — so the tool tells you which reading you are looking at instead of guessing, and the difference between “every 5 minutes” and “every 5 hours” is one glance at the list of upcoming times.
Did You Know? POSIX specifies that when both the day-of-month and day-of-week fields are restricted, the command runs when either matches — a union, not an intersection. It is the only field pair in the syntax that behaves this way, and it is the single most misread rule in cron.
The five cron failures that survive code review
1. The two day columns are OR-ed, not AND-ed
Ask for “the report runs on Friday the 13th” and you will very often get 0 0 13 * FRI. Every reviewer reads it as the intersection. Vixie reads it as the union: midnight on the 13th of every month, and midnight every Friday. That is roughly 64 runs a year instead of one or two.
The rule switches on the asterisks, not on the values. As long as either day column opens with *, the two are intersected. Only when both are restricted does the match become a union. There is no way to express “Friday the 13th” in Unix cron — you schedule daily and check the date in the script.
2. A step is a set of permitted values, never an interval
*/7 in the minute column does not mean “every seven minutes.” It means the set {0, 7, 14, 21, 28, 35, 42, 49, 56}, and then the hour rolls over and it restarts at 0. The gap from :56 to the next :00 is four minutes, not seven. Any job whose correctness depends on a uniform interval — token refresh, lease renewal, a watchdog with a timeout tuned to the period — is broken by that gap, and it is broken once an hour, forever.
The same applies to any step that does not divide its field range evenly: */7 on minutes (60), */8 on hours (24), */45 on anything. Steps of 5, 10, 15, 20 and 30 on minutes are safe because 60 divides by all of them.
3. Quartz syntax in a Unix crontab
0 0 L * ? is a perfectly good Quartz expression meaning “midnight on the last day of the month.” In a Vixie crontab it is a syntax error at best. At worst it reaches a parser that is lenient about ? and quietly produces a schedule that is not the one you asked for.
L (last), W (nearest weekday), # (nth weekday) and ? (no specific value) do not exist in Unix cron. If a generated expression contains one, the model gave you Java scheduling. The Cron Expression Parser rejects all four by name rather than approximating them, because a plausible-looking wrong list of run times is worse than a refusal.
4. The expression is valid and never fires
0 0 30 2 * parses cleanly. February has never had a 30th. The job is installed, monitored, alerted on — and has literally never run, which no “did the job fail?” alert will ever tell you, because a job that does not start does not fail.
The near-miss version is worse: 0 0 29 2 * runs on leap days only. It will sit in your crontab for up to four years looking completely healthy.
Both are caught by the same check — compute the next runs and look at the list. Empty means never. One entry inside a five-year window means you built a leap-day job by accident.
5. Daylight saving eats the run
A job at 30 2 * * * on a machine in a DST-observing zone does not run on the spring-forward day, because the local clock goes straight from 01:59 to 03:00 and 02:30 never exists. On the autumn day the hour repeats, and whether your job runs once or twice depends on which cron daemon you have and how it handles fixed-time jobs.
There is one reliable fix and it is not a smarter expression: run the daemon in UTC, or set spec.timeZone explicitly on a Kubernetes CronJob and understand that you have opted into the ambiguity. The parser lets you read the same expression against UTC and against local time side by side — the runs that vanish between the two views are the ones DST is going to take.
Did You Know? Kubernetes
CronJobtreats a schedule as missed if it starts more than 100 seconds late, and after 100 missed schedules it stops scheduling the job entirely and records an error. A job wedged behind aconcurrencyPolicy: Forbidpredecessor can therefore switch itself off permanently.Source: Kubernetes — CronJob
Generated manifests: where the units lie
Resource quantities are the other place a model produces something the API server accepts and the cluster misreads. The suffix grammar has three disjoint families that overlap visually and not at all semantically:
| Family | Suffixes | Meaning |
|---|---|---|
| Binary | Ki Mi Gi Ti Pi Ei |
Powers of 1024 |
| Decimal SI | n u m "" k M G T P E |
Powers of 1000, plus fractions below the empty suffix |
| Decimal exponent | 1e3 |
Scientific notation |
Two consequences do all the damage:
mis milli andMis mega.memory: 100mis one tenth of a byte.cpu: 2Mis two million cores. The API server validates the grammar, not your intent, so both are accepted. The first produces a pod that cannot schedule or gets OOM-killed instantly; the second produces a pod that never schedules at all, and an on-call engineer reading2Mat 3am sees “2 megabytes of something” and moves on.Gis notGi.1Giis 1,073,741,824 bytes.1Gis 1,000,000,000. The difference is 73,741,824 bytes — about 7%, which is exactly the size of margin that turns “fits comfortably” into “OOM-kills under load twice a week.”
A model swaps these freely because both forms appear constantly in the training data, usually without the surrounding context that explains which one the author meant. Paste the quantity into the Kubernetes Resource Converter and read the exact byte count. The conversion is done in exact rationals rather than floating point, precisely because the whole point is the difference between values that a float has already rounded together.
Three assertions worth making on every generated manifest:
- Every memory value ends in
ior has no suffix at all. A bareM,GorTin a memory field is either a bug or a deliberate decision nobody documented. - No CPU value has an uppercase suffix. CPU is
1,0.5, or500m. Nothing else is meaningful. - Requests are ≤ limits, per resource, per container. Generated manifests get this backwards often enough that it is worth a linter rule rather than a review comment.
Generated Compose files: check what was dropped
Asking a model to turn a docker run command into a Compose service is one of the highest-value conversions it does, and one of the easiest to get subtly wrong — because the failure is omission, and omission is invisible in a diff against a file that did not previously exist.
The flags that matter most are the ones with no clean Compose equivalent or an easily-forgotten mapping:
--security-opt,--cap-add/--cap-drop,--userns,--privileged— drop any of these and the container runs with different privileges than the command you converted from. Usually more.--read-only,--tmpfs— a filesystem that was immutable is now writable.--restart,--health-cmd,--ulimit,--sysctl— operational behaviour that silently reverts to defaults.--network host— which is a top-level Compose concern, not a service key, and is frequently just lost.
The docker run to Compose Converter is built around this specific risk: every flag ends up in exactly one of three places — translated into the service, listed as having no Compose equivalent with the reason, or listed as unrecognised. Nothing is dropped in silence. Run the model’s output and the tool’s output against the same command and diff them; anything the tool reports as un-translatable that the model translated anyway is a claim worth checking.
Two structural things to check while you are there:
- No top-level
version:key. It has been obsolete since Compose v2 and now only earns a warning. A generated file that includes it was pattern-matched from a 2019 tutorial, which tells you something about the rest of it. - The YAML actually parses as the shape you think. Paste it into the YAML ↔ JSON Converter — the Norway problem (
no→false), sexagesimal-looking version strings, and unquoted values starting with*or&all survive a visual read and change meaning at parse time.
The rest of the pass
Permissions
A generated chmod in a Dockerfile or a provisioning script deserves ten seconds. The special bits print in the execute positions, so 4755 shows as rwsr-xr-x and 4644 shows as rwSr--r--. An uppercase S is not a stronger permission — it is setuid on a file nobody can execute, which is almost always a mistake that got copied from somewhere. And umask 022 produces 644 files but 755 directories, because the mask applies against different bases (666 and 777). Models reproduce that asymmetry incorrectly about as often as humans do.
The chmod Calculator binds the octal, the symbolic form and the ls -l line to the same integer, so you can read the generated number in whichever notation makes the error obvious.
Network ranges
Generated Terraform and security-group rules are where CIDR arithmetic goes wrong quietly. 10.0.0.0/16 split into “four subnets” is not four /18s in most people’s heads until they check. /31 has two addresses and no broadcast (RFC 3021 point-to-point), /30 has two usable hosts, and any generated rule containing 0.0.0.0/0 needs a sentence of justification next to it. The CIDR / Subnet Calculator gives you the host range, the broadcast address and the split, for IPv4 and IPv6.
Environment files
There is no specification for .env. There is a family of implementations — Ruby dotenv, python-dotenv, godotenv, Docker’s --env-file, Compose’s own reader — that agree on the obvious cases and diverge on quoting, escapes and interpolation. A model generating a .env file has no way to know which reader you are using, so it produces the average of all of them.
The specific trap: an unquoted value containing #. Some readers treat it as an inline comment and truncate the value; others take the whole thing literally. DB_PASSWORD=hunter2#secure is two different passwords depending on who loads it. The .env File Converter reports the ambiguity rather than picking a side, and retypes the same variables into the six shapes you might actually need them in.
The verification pass, as a checklist
Run this on every generated schedule and manifest before it reaches a branch. It is deliberately short enough that people will actually do it.
Schedules
- Count the fields. Five or six? Which dialect did you get?
- Any of
L,W,#,?present? Then it is Quartz and you are not running Quartz. - Is either day column restricted? If both are, it is a union — is that what you meant?
- Does every step divide its field range evenly?
- Compute the next five runs. Are they the cadence you asked for? Is the list empty?
- Read it against UTC and local. What disappears?
Manifests
- Every memory suffix binary (
Mi,Gi) or absent. - Every CPU value plain or
m-suffixed. No uppercase. - Requests ≤ limits.
- Convert every quantity to exact bytes and sanity-check the magnitude.
Containers and hosts
- Diff generated Compose against the source
docker run, flag by flag. - No
version:key. YAML round-trips to the shape you expect. - Read every
chmodin symbolic form. - Compute every CIDR’s host range; justify every
/0.
Make it a test, not a habit
Checklists that live in a wiki get skipped. The three that belong in CI:
// 1. The schedule fires on the cadence the runbook claims.
const runs = nextRuns(parseCron(SCHEDULE), new Date('2026-09-01T00:00:00Z'), 5, 'utc');
expect(runs).not.toHaveLength(0); // catches `0 0 30 2 *`
expect(gapsBetween(runs)).toEqual([300, 300, 300, 300]); // catches `*/7`
// 2. Memory is binary-suffixed everywhere.
for (const q of memoryQuantitiesIn(manifest)) {
expect(q).toMatch(/^\d+(\.\d+)?(Ki|Mi|Gi|Ti)?$/); // catches `100m` and `1G`
}
// 3. Requests never exceed limits.
for (const c of containersIn(manifest)) {
expect(bytes(c.resources.requests.memory)).toBeLessThanOrEqual(bytes(c.resources.limits.memory));
}
The point is not that these are hard tests. It is that they fail on the exact class of output a language model produces — syntactically impeccable, semantically off by a factor of 288 — and no existing lint rule in your pipeline covers any of them.
Why “in your browser” is the load-bearing part
Verifying generated infrastructure means pasting real internal config into something: real schedules, real resource limits, real subnet ranges, real .env files with real secrets in them. The convenient tools for this are overwhelmingly server-side, which means the verification step quietly becomes an exfiltration step.
Our constraint here is architectural rather than a policy promise. Every tool on this site runs entirely in your browser — there is no server-side processing, no database and no account system. When you paste a crontab line, a manifest fragment or a .env file, it does not leave your device, because there is nowhere for it to go.
That is what makes the checklist above safe to run against production artefacts rather than sanitised ones. And sanitised artefacts are exactly the ones that pass, because the values you redacted are the values that were wrong.
Conclusion
The model is not the problem, and neither is using it. Generating a crontab line, a Compose service or a resource block is a legitimate speed-up, and refusing to on principle is a worse trade than verifying.
What changed is where the errors live. Generated infrastructure moves the failure out of the syntax layer — where your editor, your linter and your reviewer all catch it — and into the semantic layer, where nothing in a standard pipeline is looking. A cron expression that parses, a manifest the API server accepts and a Compose file that starts are three things that tell you nothing about whether the schedule, the memory limit or the privileges are the ones you asked for.
So compute the run times. Convert the quantity to bytes. Diff the flags. Two minutes, per artefact, in a tab that does not phone home. Verify, don’t just trust — and be specific about what “verify” means, because that is the whole difference between a workflow and a slogan.
Frequently Asked Questions
Why does my AI-generated cron expression run at the wrong frequency?
Almost always a dialect mismatch. Six-field expressions like 0 */5 * * * * are Spring or Quartz syntax with a leading seconds column. A traditional Vixie crontab reads five fields and treats the sixth as the start of the command, so that expression runs at minute 0 of every fifth hour and tries to execute *. Count the columns before reading the values, and compute the next run times rather than trusting the description.
Does 0 0 13 * FRI mean Friday the 13th?
No. When both the day-of-month and day-of-week fields are restricted, cron takes the union — it runs at midnight on the 13th of every month and at midnight every Friday, roughly 64 times a year. POSIX specifies this behaviour. Unix cron cannot express an intersection of the two day columns at all; schedule the job daily and test the date inside the script.
Why is */7 in the minute field not every seven minutes?
Because a cron field is a set of permitted values, not an interval between firings. */7 admits minutes {0, 7, 14, …, 56}, and then the hour rolls over and the set restarts at 0 — producing a four-minute gap once every hour. Any step that does not divide its field range evenly has this discontinuity. Steps of 5, 10, 15, 20 and 30 are safe on minutes because 60 divides by each of them.
What does memory: 100m do in a Kubernetes manifest?
It requests one tenth of a byte. In the Kubernetes quantity grammar m is the milli suffix, while M is mega and Mi is mebi. The API server validates the grammar rather than your intent, so 100m is accepted without complaint and the pod fails later in a way that does not point back at the manifest. Memory should always carry a binary suffix (Mi, Gi) or none at all.
How much difference is there between 1G and 1Gi?
73,741,824 bytes. 1Gi is 1,073,741,824 bytes (a power of 1024) and 1G is 1,000,000,000 (a power of 1000) — about 7%. That is a large enough margin to turn a limit that fits under load into one that OOM-kills intermittently, and small enough that nobody notices it in review.
What is the fastest way to check whether a generated cron job will ever run?
Compute its next run times over a multi-year window. Expressions like 0 0 30 2 * are syntactically valid and match no date that exists, so they install cleanly, monitor cleanly, and never fire — and a job that never starts never fails, so failure alerting will not tell you. An empty list of upcoming runs is the only reliable signal.
Is it safe to paste production config into an online verification tool?
Only if it never leaves your machine. Verifying generated infrastructure means handling real schedules, resource limits, subnet ranges and .env files, and most convenient online tools process input server-side — which turns the verification step into an exfiltration step. Every tool on this site runs entirely in the browser, with no server-side processing and no account system, so real artefacts can be checked without being sanitised first.