To turn labour-law rules into code that checks an AI agent's rosters, model each shift with a full start and end time in a stated time zone, group shifts into working days, and encode each rule as a hard gate over the whole roster. Then test each rule at its edges, where rosters break.
This article is general information, not legal advice. It covers the working-time rules of the EU Working Time Directive, the UK Working Time Regulations 1998 and Germany's Arbeitszeitgesetz, each checked on its official text on 17 October 2026, and shows how to encode a stated subset of them for rosters with shifts. It belongs to the series that starts with Why AI agents still struggle with back-office work.
Why do labour-law rules make good hard gates?
Labour-law rules make good hard gates because they are written down, mostly binary and costly to break. A roster either gives a worker 11 hours of rest or it does not, and no amount of savings elsewhere makes up for breaking the law. Written rules are also one of the conditions in Which workflows make good RL environments?.
A gate is a check that sets the whole score to zero when it fails. A verifier is the code that checks the final state of an agent's work and computes the score. Gates suit rules that make a result unusable when broken, and working-time rules are a clear case: a cheaper roster that breaks a rest rule is not a cheaper legal roster, it is an illegal one, so the verifier should test the rules before it scores anything else.
Law also gives the rules a source and a version. A builder can point to the article or regulation behind every check, record which version was encoded, and update the check when the law changes.
What do the working-time rules say?
The EU Working Time Directive, the UK Working Time Regulations 1998 and Germany's Arbeitszeitgesetz all set a minimum daily rest of 11 consecutive hours, but they differ on weekly rest, daily limits and exceptions. The table summarises the core rules as written.
| Rule | EU Directive 2003/88/EC | UK Working Time Regulations 1998 | Germany, Arbeitszeitgesetz |
|---|---|---|---|
| Daily rest | 11 consecutive hours per 24-hour period (Article 3) | Not less than eleven consecutive hours in each 24-hour period (regulation 10) | At least eleven uninterrupted hours after the end of the daily working time (§ 5) |
| Weekly rest | 24 hours plus the 11 hours of daily rest, per seven-day period; 24 hours where objective, technical or work-organisation conditions justify it (Article 5) | 24 hours in each seven-day period, or two of 24 or one of 48 in each 14 days, not including the daily rest unless objectively justified (regulation 11) | No work on Sundays and public holidays from 0 to 24 hours (§ 9), with exceptions and a substitute rest day (§§ 10, 11) |
| Weekly working time | Average of 48 hours per seven days, including overtime (Article 6) | Average of 48 hours per seven days, by default over 17 weeks, unless the worker agrees in writing (regulations 4 and 5) | No separate weekly limit; § 3 limits each working day (Monday to Saturday), capping the week at 48 hours on average and 60 at most |
| Daily working time | No general daily limit; night workers average at most eight hours per 24 hours (Article 8) | No general daily limit; eight hours a day for young workers (regulation 5A) and an average of eight hours per 24 hours for night workers (regulation 6) | Eight hours per working day (werktäglich), up to ten if the average stays at eight over six calendar months or 24 weeks (§ 3); for night workers within one calendar month or four weeks (§ 6(2)) |
| Breaks | A break when the working day exceeds six hours, details set nationally (Article 4) | 20 minutes when daily working time exceeds six hours (regulation 12) | 30 minutes for more than six hours, 45 minutes for more than nine; no more than six hours in a row without a break (§ 4) |
The versions checked are these:
- EU: Directive 2003/88/EC on EUR-Lex. Article 16 allows a reference period of up to four months for the 48-hour average, and Article 17 lets Member States derogate from Articles 3 to 6, 8 and 16 in defined cases.
- UK: the Working Time Regulations 1998 (S.I. 1998/1833), in the latest revised version on legislation.gov.uk, which apply in England, Wales and Scotland. The relevant texts are regulations 4, 5, 5A, 6, 10, 11 and 22.
- Germany: the Arbeitszeitgesetz on gesetze-im-internet.de, last amended by Article 52 of the act of 23 October 2024.
The Directive is addressed to Member States, not to employers: it sets minimums that national law must meet. A roster for a German workplace is checked against the Arbeitszeitgesetz, and one in Great Britain against the Working Time Regulations; the EU column shows the minimum German law must meet; the UK regulations implemented its predecessor, 93/104/EC, and since Brexit are domestic law. Each row also hides definitions, such as what counts as working time or a working day, that a builder must read in the full text before encoding the rule.
How do you model shifts so rules can be checked?
Model each shift as a worker, a start, an end and the length of its breaks, with start and end as full dates and times in a stated time zone. Group each worker's shifts into working days, compute every duration in UTC, and set period boundaries, such as the working week, in local time.
Four details matter:
- Full dates, not day labels. A night shift that starts on Tuesday at 21:00 ends on Wednesday at 07:00. Storing "Tue N" alone hides when the rest period actually starts.
- Working days, not single shifts. The German law speaks of rest after the end of the daily working time, and of working time per working day. A split shift, such as 07:00 to 11:00 and 15:00 to 19:00, is one working day with a long break, not two working days with four hours of rest between them. The sketch below treats any gap shorter than six hours as a break inside one working day; that threshold is a stated simplification, not a legal rule. A real check should also cap a working day, for example at 24 hours from its first start, because a chain of short shifts with short gaps would otherwise count as one endless working day.
- Durations in UTC. Rest rules are about elapsed time, and elapsed time changes when clocks change. In the Europe/Berlin time zone, clocks go forward by one hour in the early hours of 29 March 2026, so a shift from 21:00 on 28 March to 07:00 on 29 March lasts nine hours, not ten. In Python, subtracting two times that share the same time zone object ignores the time zone: the Python documentation says that in this case the tzinfo attributes are ignored, so both times must be converted to UTC first.
- Boundaries in local time. Periods such as a week or a working day start at a local time, for example midnight, so compute the boundaries in the local time zone and only then compare in UTC. A rest gap that crosses a period boundary is still one gap; do not cut it in two.
Illustrative example. Simplified sketch of a shift model and two rule checks. It runs with Python 3.9 or later; where the system has no time zone data, as on many Windows systems, the zoneinfo module needs the tzdata package from PyPI.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Europe/Berlin")
def utc(t: datetime) -> datetime:
return t.astimezone(timezone.utc) # subtract in UTC, never in local time
@dataclass(frozen=True)
class Shift:
staff: str
start: datetime # timezone-aware local time
end: datetime # may fall on the next calendar day
break_minutes: int = 0
def worked(self) -> timedelta:
return (utc(self.end) - utc(self.start)) - timedelta(minutes=self.break_minutes)
def working_days(shifts, split_gap=timedelta(hours=6)):
"""Group each worker's shifts into working days. A gap shorter than split_gap
is treated as a break inside one working day (a stated simplification)."""
days = {}
for s in sorted(shifts, key=lambda s: utc(s.start)):
own = days.setdefault(s.staff, [])
if own and utc(s.start) - utc(own[-1][-1].end) < split_gap:
own[-1].append(s)
else:
own.append([s])
return days
def rest_violations(shifts, min_rest=timedelta(hours=11)):
"""Daily rest: from the end of one working day to the start of the next."""
out = []
for staff, days in working_days(shifts).items():
for prev, nxt in zip(days, days[1:]):
gap = utc(nxt[0].start) - utc(prev[-1].end)
if gap < min_rest:
out.append((staff, prev[-1].end, nxt[0].start, gap))
return out
def daily_limit_violations(shifts, max_work=timedelta(hours=10)):
"""Working time per working day, breaks excluded."""
out = []
for staff, days in working_days(shifts).items():
for day in days:
worked = sum((s.worked() for s in day), timedelta())
if worked > max_work:
out.append((staff, day[0].start, worked))
return out
def at(day, hhmm):
h, m = map(int, hhmm.split(":"))
return datetime(2026, 3, day, h, m, tzinfo=TZ)
if __name__ == "__main__":
roster = [
Shift("W-01", at(23, "06:00"), at(23, "14:00"), 30), # Mon early
Shift("W-01", at(24, "21:00"), at(25, "07:00"), 45), # Tue night, ends Wed
Shift("W-02", at(23, "14:00"), at(23, "22:00"), 30), # Mon late
Shift("W-02", at(24, "06:00"), at(24, "14:00"), 30), # Tue early: 8 h rest
Shift("W-03", at(25, "07:00"), at(25, "11:00")), # Wed split shift, part 1
Shift("W-03", at(25, "15:00"), at(25, "19:00")), # Wed split shift, part 2
Shift("W-04", at(28, "21:00"), at(29, "07:00"), 45), # Sat night, clocks change
]
for staff, end, start, gap in rest_violations(roster):
print("rest too short:", staff, gap)
for staff, start, worked in daily_limit_violations(roster):
print("too long:", staff, worked)
print("W-04 night worked:", roster[6].worked())Run as written, the sketch reports one violation: W-02 has only eight hours between Monday's late shift and Tuesday's early shift. W-03's split shift on Wednesday passes, because its two parts form one working day. W-04's night shift across the clock change counts as 8 hours 15 minutes of work: nine hours elapsed minus a 45-minute break, more than the 30 minutes § 4 of the German law requires for working time of more than six and up to nine hours. The sketch does not check breaks itself.
How do you encode the rest and weekly rules?
Encode each rule as a function over the whole roster that returns every violation with the worker, the times and the measured value. A function that returns only true or false cannot explain a lost point.
Daily rest. Group each worker's shifts into working days and measure the gap from the end of one working day to the start of the next, as the sketch does. The rule compares the gap with the minimum, so exactly 11 hours passes and 10 hours 59 minutes fails. For Great Britain this approximates regulation 10, which is worded per 24-hour period.
Daily working time. Add up the time worked in each working day, breaks excluded, and compare it with the limit. Two six-hour shifts on one day add up to twelve hours and break a ten-hour limit, even though neither shift is long on its own.
Weekly rest. For each seven-day period, find the longest uninterrupted rest that falls within it, and state as a parameter whether a rest that straddles two periods may count for either. Under both the EU Directive and the UK regulations, the default is 24 hours plus the 11 hours of daily rest, so 35 hours; both allow 24 hours where objective reasons justify it, and the UK regulations also allow the 14-day alternatives.
The German law instead protects Sundays and public holidays. The world must state which variant it encodes and when each period starts, because the result depends on both.
Average weekly working time. The 48-hour average is measured over a reference period of weeks, not over one roster. A world that generates one week has two honest options: generate the hours of the earlier weeks as part of the state, or state that the average limit is not modelled.
Shifts at the edge of the roster. The first shift of the week needs the last shift of the week before. Without it, a rest violation across the boundary is invisible, so the generated state should include the previous week's final shifts.
How do exceptions fit in, and why model a stated subset?
Exceptions fit in as explicit parameters. A world is a complete simulated workplace packaged as one RL environment, with its data, rules and tasks, and it should model a stated subset of the rules, because many exceptions depend on facts that are not in the roster. Encoding an exception without its facts turns a legal check into a guess.
The sketch's own roster shows why. W-02's late shift followed by an early shift, with eight hours between them, breaks the German rest rule in § 5(1). In Great Britain, regulation 22 says the daily rest rule does not apply to a shift worker who changes shift and cannot take a daily rest period between shifts, subject to compensatory rest under regulation 24. Whether the same roster passes depends on the jurisdiction and on facts the roster does not contain.
The three texts contain many such exceptions. A few examples:
- UK opt-out. Under regulations 4 and 5, the 48-hour average does not apply where the worker has agreed in writing, and the worker can end the agreement with notice.
- German sectors. § 5(2) allows hospitals and facilities for treatment and care, hospitality, transport, broadcasting, agriculture and animal husbandry to shorten the rest by up to one hour, if each shortening is made up within a calendar month or four weeks by extending another rest to at least twelve hours.
- German collective agreements. § 7 lets a collective agreement, or a works agreement based on one, deviate from several rules, for example shortening the rest by up to two hours with compensation where the type of work requires it.
- EU derogations. Article 17 lets Member States derogate from the daily rest, weekly rest and weekly limit in defined cases.
Each of these needs data: the sector, the agreement, the written consent, the compensation granted later. The practical answer is to treat exceptions as documented parameters: state the jurisdiction, the version of each law and the rules encoded, and leave out exceptions whose facts the world lacks.
How do you test rules with edge cases?
Test every rule at its boundaries, with rosters whose correct result you know in advance. Most bugs in rule code sit at the edges, where a less-than becomes a less-than-or-equal, a date crosses midnight or two shifts belong to the same day.
Illustrative example. Edge cases for the rest and daily-limit checks, with the expected result. Each case was run against the sketch in this article.
| Case | Shifts | Expected |
|---|---|---|
| Exactly the minimum | Ends 14:00, next starts 01:00 the next day | Rest passes: 11 hours |
| One minute short | Ends 14:00, next starts 00:59 | Rest fails: 10 hours 59 minutes |
| Night into the next day | Night ends 07:00, late starts 14:00 the same day | Rest fails: 7 hours |
| Unsorted input | The two shifts of the one-minute case, listed in reverse order | Same result as sorted |
| Split shift | 07:00–11:00 and 15:00–19:00, next day from 07:00 | Rest passes: one working day, then 12 hours |
| Two shifts on one day | 06:00–12:00 and 13:00–19:00 | Daily limit fails: 12 hours worked |
| Clock change, rest | Ends 20:00 on 28 March, next starts 07:00 on 29 March 2026, Europe/Berlin | Rest fails: 10 hours elapsed, although the clock shows 11 |
| Clock change, shift length | 21:00 on 28 March to 07:00 on 29 March 2026 | Nine hours elapsed, although the clock shows ten |
Add cases for the roster boundary and for overlapping shifts, which should fail validity before any rest rule is checked. Keep the tests next to the rule code and run them on every change.
Test the rule code on its own, not only through the environment. A task generator is the code that creates tasks from seeds. If the task generator and the verifier share the same rule functions, a bug in a rule passes both: the generator builds tasks that match the bug, and the verifier accepts them. Hand-checked cases like the ones in the table catch what a shared function cannot.
How do the rules become gates in an environment?
The rule functions become a hard-rules gate: the verifier runs every function on the final roster, and any violation fails the gate with a reason. The same functions can help the task generator build conflicts on purpose.
- In the verifier, every violation becomes a reason, such as "rest rule broken: W-02, 8 h between shifts", and the gate fails if there is one.
- In the task generator, the rules can check that every generated task has at least one legal solution.
- In the reference solution, the same rules become constraints for a constraint solver. A reference solution is a known good roster used as the target when scoring. A rule such as "no late shift followed by an early shift" is a linear constraint over assignment variables, and Solver-computed reference solutions shows one in the open-source CP-SAT solver, as an example.
Real rosters also come with mess that the rules must survive: a works agreement that conflicts with a manager's request, or a sick note with the wrong dates. Realistic mess covers how to add such noise without losing exact checks. Inside an episode walks through an illustrative episode. Vencur builds its environments in Europe.
This article is general information, not legal advice. Laws checked on 17 October 2026: Directive 2003/88/EC on EUR-Lex (original text, OJ L 299, 18.11.2003, in force); the Working Time Regulations 1998 (S.I. 1998/1833), latest revised version on legislation.gov.uk, which applies in England, Wales and Scotland; the Arbeitszeitgesetz as last amended by Article 52 of the act of 23 October 2024, on gesetze-im-internet.de.