aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md1963
1 files changed, 1963 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md b/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md
new file mode 100644
index 0000000..63ccaa2
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md
@@ -0,0 +1,1963 @@
+# OF Liturgical Calendar Engine Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A pure, offline, stdlib-only Go engine that computes the Ordinary Form liturgical day (season, celebrations, rank, colour, the observed celebration, inline proper readings) from a Gregorian date, replacing the niedziela.pl/missalemeum scrape for calendar computation.
+
+**Architecture:** A dependency-free `internal/calendar` package (types + Computus + temporal cycle + sanctoral date resolution + layer merge + Universal-Norms precedence + `Compute`) fed by an `internal/caldata` package that embeds the universal General Roman Calendar as INI records, plus a shared `internal/ini` reader and a config migration to INI. Correctness is proven by a regression oracle diffing against calapi/romcal over 2020–2040. Front-ends stay on the scraper; a new `lectio calendar [DATE]` command is the engine's demo/validation surface.
+
+**Tech Stack:** Go 1.24, standard library only for the engine (`time`, `strings`, `strconv`, `embed`, `bufio`, `sort`, `fmt`). Module path `github.com/lukaszkasprzak/lectio`.
+
+## Global Constraints
+
+- **Engine purity:** `internal/calendar` and `internal/ini` import **only the Go standard library** — no third-party, no other internal packages. `internal/caldata` may import `internal/calendar` (for types) + `internal/ini` only.
+- **No network in the core, ever.** `Compute` is total: every valid Gregorian date returns a `LiturgicalDay`; it never returns an error and never performs I/O.
+- **Formats:** config = INI; celebration data = INI `[slug]` sections; scripture corpora stay TSV. No TOML/YAML in new code (the existing go-toml dependency is retained **only** for one-shot config migration).
+- **Longevity:** boring stdlib, plain-text data, focused files, frequent commits. Data is owned plain text.
+- **Precedence authority:** the *Universal Norms on the Liturgical Year and the Calendar* (1969) "Table of Liturgical Days" governs which celebration is observed.
+- **Scope of this plan (from the spec):** universal OF calendar only. OUT: temporal reading cycle (Sundays A/B/C, weekday I/II), user override-file loading + `use =` stacking, JSON/iCal API, EF engine, `bt`, UI migration. The engine's `Compute(date, sel, layers []Layer)` signature accepts an ordered layer stack so #2 slots in, but this plan ships only the embedded universal layer.
+- **Pre-1970 dates** apply the modern rules retroactively (documented, matches romcal).
+
+---
+
+## File Structure
+
+- `internal/ini/ini.go` — shared zero-dep INI reader/writer. Parses ordered sections (incl. `[a/b]` subsections), `key = value`, dotted keys, `#`/`;` comments; a comma-list helper. One responsibility: INI ↔ ordered data.
+- `internal/calendar/types.go` — the vocabulary types (`Rank`, `Class`, `Colour`, `Season`, `Reading`, `Mass`, `DateSpec`, `Celebration`, `RawCelebration`, `Layer`, `Selection`, `LiturgicalDay`) + parse helpers for enums.
+- `internal/calendar/computus.go` — `Easter(year) time.Time`.
+- `internal/calendar/datespec.go` — `resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool)`.
+- `internal/calendar/temporal.go` — `temporal(date time.Time, sel Selection) temporalDay` (season, week, weekday, temporal celebration + its precedence inputs, colour).
+- `internal/calendar/merge.go` — `mergeLayers(layers []Layer) map[string]RawCelebration`.
+- `internal/calendar/precedence.go` — `precedence(kind precedenceInput) int` (Table of Liturgical Days) + `pick(cands []candidate) (observed candidate, others []candidate)` + `transfer` of impeded solemnities.
+- `internal/calendar/calendar.go` — `Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`, `buildCelebration`, cycle helpers `sundayCycle`/`weekdayCycle`.
+- `internal/caldata/caldata.go` — `//go:embed roman-calendar.ini`, `Universal() calendar.Layer`.
+- `internal/caldata/roman-calendar.ini` — the owned universal sanctoral data (bootstrapped from romcal, verified).
+- `internal/config/config.go` — modified: read/write INI, one-shot TOML→INI migration, `[calendar]` keys, `Selection()` accessor.
+- `internal/config/config.ini` — new embedded INI seed (replaces `config.toml`).
+- `internal/calendar/oracle_test.go` + `internal/calendar/testdata/oracle-2020-2040.json` — regression oracle.
+- `scripts/build-oracle.sh` — one-time (network) snapshot generator; not run in tests.
+- `internal/cli/calendar.go` — the `lectio calendar [DATE]` command.
+
+Each `calendar/*.go` file is small and single-purpose; the package as a whole is the 25–50 year artifact.
+
+---
+
+## Task 1: Shared INI reader (`internal/ini`)
+
+**Files:**
+- Create: `internal/ini/ini.go`
+- Test: `internal/ini/ini_test.go`
+
+**Interfaces:**
+- Produces: `type Section struct { Name string; Pairs []Pair }`; `type Pair struct { Key, Val string }`; `func Parse(data []byte) ([]Section, error)`; `func List(val string) []string` (splits a comma value, trims, drops empties).
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package ini
+
+import "testing"
+
+func TestParseSectionsAndPairs(t *testing.T) {
+ in := []byte(`
+# a comment
+lectionary = new ; trailing comment
+
+[assumption]
+date = 08-15
+name.pl = Wniebowzięcie ; unicode ok
+rank= solemnity
+
+[assumption/vigil]
+reading.gospel = Łk 11,27-28
+`)
+ secs, err := Parse(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // top-level pairs land in a section with empty name, first.
+ if secs[0].Name != "" || len(secs[0].Pairs) != 1 || secs[0].Pairs[0] != (Pair{"lectionary", "new"}) {
+ t.Fatalf("top-level = %+v", secs[0])
+ }
+ if secs[1].Name != "assumption" || secs[1].Pairs[1] != (Pair{"name.pl", "Wniebowzięcie"}) {
+ t.Fatalf("assumption = %+v", secs[1])
+ }
+ if secs[1].Pairs[2] != (Pair{"rank", "solemnity"}) {
+ t.Fatalf("no-space key = %+v", secs[1].Pairs[2])
+ }
+ if secs[2].Name != "assumption/vigil" {
+ t.Fatalf("subsection = %q", secs[2].Name)
+ }
+}
+
+func TestList(t *testing.T) {
+ got := List("bt, wuj ,, vul")
+ want := []string{"bt", "wuj", "vul"}
+ if len(got) != len(want) {
+ t.Fatalf("List = %v", got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("List[%d] = %q want %q", i, got[i], want[i])
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/ini/`
+Expected: FAIL (`undefined: Parse` / `undefined: List`).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```go
+// Package ini is a tiny dependency-free reader for the boring INI files lectio
+// uses for config and calendar data: [sections] (including "a/b" subsections),
+// "key = value" pairs (keys may be dotted, e.g. name.pl), and "#"/";" comments.
+// It preserves order and never interprets values beyond trimming.
+package ini
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "strings"
+)
+
+type Pair struct{ Key, Val string }
+
+type Section struct {
+ Name string
+ Pairs []Pair
+}
+
+// Parse reads INI bytes into ordered sections. Pairs before the first [section]
+// go into a leading section with an empty Name.
+func Parse(data []byte) ([]Section, error) {
+ secs := []Section{{Name: ""}}
+ sc := bufio.NewScanner(bytes.NewReader(data))
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ line := 0
+ for sc.Scan() {
+ line++
+ raw := stripComment(sc.Text())
+ s := strings.TrimSpace(raw)
+ if s == "" {
+ continue
+ }
+ if strings.HasPrefix(s, "[") {
+ if !strings.HasSuffix(s, "]") {
+ return nil, fmt.Errorf("ini: line %d: unclosed section header %q", line, s)
+ }
+ name := strings.TrimSpace(s[1 : len(s)-1])
+ secs = append(secs, Section{Name: name})
+ continue
+ }
+ eq := strings.IndexByte(s, '=')
+ if eq < 0 {
+ return nil, fmt.Errorf("ini: line %d: expected key = value, got %q", line, s)
+ }
+ key := strings.TrimSpace(s[:eq])
+ val := strings.TrimSpace(s[eq+1:])
+ cur := &secs[len(secs)-1]
+ cur.Pairs = append(cur.Pairs, Pair{key, val})
+ }
+ return secs, sc.Err()
+}
+
+// stripComment removes a trailing "#" or ";" comment (not inside the value's
+// meaningful text — our values never contain unescaped # or ;).
+func stripComment(line string) string {
+ for i := 0; i < len(line); i++ {
+ if line[i] == '#' || line[i] == ';' {
+ return line[:i]
+ }
+ }
+ return line
+}
+
+// List splits a comma-separated value, trimming spaces and dropping empties.
+func List(val string) []string {
+ var out []string
+ for _, p := range strings.Split(val, ",") {
+ if t := strings.TrimSpace(p); t != "" {
+ out = append(out, t)
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/ini/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/ini/
+git commit -m "feat(ini): dependency-free INI reader for config and calendar data"
+```
+
+---
+
+## Task 2: Calendar types + Computus (`internal/calendar`)
+
+**Files:**
+- Create: `internal/calendar/types.go`, `internal/calendar/computus.go`
+- Test: `internal/calendar/computus_test.go`, `internal/calendar/types_test.go`
+
+**Interfaces:**
+- Produces the whole type vocabulary (below) and `func Easter(year int) time.Time` (UTC midnight, Gregorian Easter Sunday). `func ParseRank(string) Rank`, `func ParseClass(string) Class`, `func ParseColour(string) Colour` (each returns a zero value for unknown input).
+
+- [ ] **Step 1: Write the failing Easter test**
+
+```go
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestEasterKnownDates(t *testing.T) {
+ // Published Gregorian Easter Sundays.
+ cases := map[int]string{
+ 2020: "2020-04-12", 2021: "2021-04-04", 2022: "2022-04-17",
+ 2024: "2024-03-31", 2025: "2025-04-20", 2027: "2027-03-28",
+ 2038: "2038-04-25", 2000: "2000-04-23",
+ }
+ for y, want := range cases {
+ got := Easter(y).Format("2006-01-02")
+ if got != want {
+ t.Errorf("Easter(%d) = %s, want %s", y, got, want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run TestEaster`
+Expected: FAIL (`undefined: Easter`).
+
+- [ ] **Step 3: Write the types**
+
+```go
+// Package calendar computes the Ordinary Form liturgical day for any Gregorian
+// date, offline and from first principles. It imports only the standard library.
+package calendar
+
+import "time"
+
+type Rank int
+
+const (
+ RankFerial Rank = iota // ordinary weekday
+ RankOptional // optional memorial
+ RankMemorial // obligatory memorial
+ RankFeast
+ RankSolemnity
+)
+
+type Class int
+
+const (
+ ClassNone Class = iota // temporal / ferial
+ ClassSaint // a saint
+ ClassBVM // the Blessed Virgin Mary
+ ClassLord // the Lord
+)
+
+type Colour string
+
+const (
+ White Colour = "white"
+ Red Colour = "red"
+ Green Colour = "green"
+ Violet Colour = "violet"
+ Rose Colour = "rose"
+ Black Colour = "black"
+)
+
+type Season string
+
+const (
+ Advent Season = "advent"
+ Christmas Season = "christmas"
+ Lent Season = "lent"
+ Triduum Season = "triduum"
+ Easter_ Season = "easter" // trailing underscore: Easter is the func name
+ Ordinary Season = "ordinary"
+)
+
+type Reading struct{ Part, Citation string }
+
+type Mass struct {
+ Variant string // "" = the day Mass; e.g. "vigil"
+ Readings []Reading
+}
+
+// DateSpec is a celebration's raw date expression, resolved by resolveDate:
+// "MM-DD" (fixed) | "easter±N" | "christmas±N" | "advent-sunday-N" |
+// "sunday-after MM-DD".
+type DateSpec string
+
+// RawCelebration is a celebration's fields exactly as parsed from a layer,
+// before typing/merging. Fields are raw INI values keyed by INI key
+// (e.g. "rank", "name.pl"); Variants holds "[slug/variant]" sub-sections.
+type RawCelebration struct {
+ Fields map[string]string
+ Variants map[string]map[string]string
+}
+
+// Celebration is the typed, built form used in results.
+type Celebration struct {
+ Slug string
+ Name map[string]string // lang -> name
+ Rank Rank
+ Class Class
+ Colour Colour
+ Date DateSpec
+ Masses []Mass
+ Layer string // provenance
+}
+
+// Layer is one calendar layer (the embedded universal base, or later an
+// override file). Cels is keyed by slug; insertion order is not significant.
+type Layer struct {
+ ID, Name, Type string
+ Cels map[string]RawCelebration
+}
+
+// Selection carries the config choices that steer computation.
+type Selection struct {
+ Form string // "new" (OF). "old" reserved.
+ Epiphany string // "fixed" | "sunday"
+ Ascension string // "thursday" | "sunday"
+ CorpusChristi string // "thursday" | "sunday"
+}
+
+// DefaultSelection is the Universal Roman Calendar default.
+func DefaultSelection() Selection {
+ return Selection{Form: "new", Epiphany: "fixed", Ascension: "thursday", CorpusChristi: "thursday"}
+}
+
+type LiturgicalDay struct {
+ Date time.Time
+ Season Season
+ Week int
+ Weekday time.Weekday
+ Observed Celebration
+ Others []Celebration
+ Colour Colour
+ SundayCycle string // "A" | "B" | "C"
+ WeekdayCycle string // "I" | "II"
+}
+
+func ParseRank(s string) Rank {
+ switch s {
+ case "solemnity":
+ return RankSolemnity
+ case "feast":
+ return RankFeast
+ case "memorial":
+ return RankMemorial
+ case "optional":
+ return RankOptional
+ default:
+ return RankFerial
+ }
+}
+
+func ParseClass(s string) Class {
+ switch s {
+ case "lord":
+ return ClassLord
+ case "bvm":
+ return ClassBVM
+ case "saint":
+ return ClassSaint
+ default:
+ return ClassNone
+ }
+}
+
+func ParseColour(s string) Colour {
+ switch Colour(s) {
+ case White, Red, Green, Violet, Rose, Black:
+ return Colour(s)
+ default:
+ return ""
+ }
+}
+```
+
+- [ ] **Step 4: Write Computus**
+
+```go
+package calendar
+
+import "time"
+
+// Easter returns Gregorian Easter Sunday (UTC midnight) via the Anonymous
+// Gregorian algorithm (Meeus/Jones/Butcher).
+func Easter(year int) time.Time {
+ a := year % 19
+ b := year / 100
+ c := year % 100
+ d := b / 4
+ e := b % 4
+ f := (b + 8) / 25
+ g := (b - f + 1) / 3
+ h := (19*a + b - d - g + 15) % 30
+ i := c / 4
+ k := c % 4
+ l := (32 + 2*e + 2*i - h - k) % 7
+ m := (a + 11*h + 22*l) / 451
+ month := (h + l - 7*m + 114) / 31
+ day := ((h + l - 7*m + 114) % 31) + 1
+ return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
+}
+```
+
+- [ ] **Step 5: Add the enum test**
+
+```go
+package calendar
+
+import "testing"
+
+func TestParseEnums(t *testing.T) {
+ if ParseRank("feast") != RankFeast || ParseRank("nonsense") != RankFerial {
+ t.Error("ParseRank")
+ }
+ if ParseClass("bvm") != ClassBVM || ParseClass("") != ClassNone {
+ t.Error("ParseClass")
+ }
+ if ParseColour("violet") != Violet || ParseColour("chartreuse") != "" {
+ t.Error("ParseColour")
+ }
+}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `go test ./internal/calendar/`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/calendar/types.go internal/calendar/computus.go internal/calendar/computus_test.go internal/calendar/types_test.go
+git commit -m "feat(calendar): core types + Gregorian Computus"
+```
+
+---
+
+## Task 3: DateSpec resolution (`internal/calendar/datespec.go`)
+
+**Files:**
+- Create: `internal/calendar/datespec.go`
+- Test: `internal/calendar/datespec_test.go`
+
+**Interfaces:**
+- Consumes: `Easter(year)` (Task 2), `DateSpec` (Task 2).
+- Produces: `func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool)`. Returns the date (UTC midnight) the spec names in `year`, or `ok=false` if unparseable.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestResolveDate(t *testing.T) {
+ y := 2025
+ e := Easter(y) // 2025-04-20
+ check := func(spec, want string) {
+ got, ok := resolveDate(DateSpec(spec), y, e)
+ if !ok || got.Format("2006-01-02") != want {
+ t.Errorf("resolveDate(%q) = %s ok=%v, want %s", spec, got.Format("2006-01-02"), ok, want)
+ }
+ }
+ check("08-15", "2025-08-15") // fixed
+ check("easter+60", "2025-06-19") // Corpus Christi (Thursday)
+ check("easter-46", "2025-03-05") // Ash Wednesday
+ check("christmas+7", "2026-01-01") // year rolls over
+ check("sunday-after 01-06", "2025-01-12")
+ if _, ok := resolveDate("garbage", y, e); ok {
+ t.Error("garbage should not resolve")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run TestResolveDate`
+Expected: FAIL (`undefined: resolveDate`).
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+package calendar
+
+import (
+ "strconv"
+ "strings"
+ "time"
+)
+
+func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool) {
+ s := strings.TrimSpace(string(spec))
+ switch {
+ case len(s) == 5 && s[2] == '-': // MM-DD
+ mo, err1 := strconv.Atoi(s[0:2])
+ da, err2 := strconv.Atoi(s[3:5])
+ if err1 != nil || err2 != nil {
+ return time.Time{}, false
+ }
+ return time.Date(year, time.Month(mo), da, 0, 0, 0, 0, time.UTC), true
+ case strings.HasPrefix(s, "easter"):
+ return offset(easter, s[len("easter"):])
+ case strings.HasPrefix(s, "christmas"):
+ xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC)
+ return offset(xmas, s[len("christmas"):])
+ case strings.HasPrefix(s, "sunday-after "):
+ base, ok := resolveDate(DateSpec(strings.TrimPrefix(s, "sunday-after ")), year, easter)
+ if !ok {
+ return time.Time{}, false
+ }
+ return nextWeekday(base.AddDate(0, 0, 1), time.Sunday), true
+ }
+ return time.Time{}, false
+}
+
+// offset parses "±N" and adds N days to base.
+func offset(base time.Time, pm string) (time.Time, bool) {
+ if pm == "" {
+ return base, true
+ }
+ n, err := strconv.Atoi(pm) // strconv.Atoi handles a leading '+' and '-'
+ if err != nil {
+ return time.Time{}, false
+ }
+ return base.AddDate(0, 0, n), true
+}
+
+// nextWeekday returns the first day >= from whose weekday is wd.
+func nextWeekday(from time.Time, wd time.Weekday) time.Time {
+ delta := (int(wd) - int(from.Weekday()) + 7) % 7
+ return from.AddDate(0, 0, delta)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/calendar/ -run TestResolveDate`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/calendar/datespec.go internal/calendar/datespec_test.go
+git commit -m "feat(calendar): DateSpec resolution (fixed + easter/christmas offsets + sunday-after)"
+```
+
+---
+
+## Task 4: Temporal cycle (`internal/calendar/temporal.go`)
+
+**Files:**
+- Create: `internal/calendar/temporal.go`
+- Test: `internal/calendar/temporal_test.go`
+
+**Interfaces:**
+- Consumes: `Easter`, `Selection`, `Season`, `Colour`, `Rank`, `Class`, `nextWeekday` (Task 3).
+- Produces: `type temporalDay struct { Season Season; Week int; Colour Colour; Cel Celebration; Rank Rank; Class Class; Privileged bool }` and `func temporal(date time.Time, sel Selection) temporalDay`. `Cel` is the temporal celebration (a Sunday, a ferial weekday, or a movable solemnity/feast) with a stable `Slug`; `Privileged` marks days no sanctoral may override (Sundays of Advent/Lent/Easter, Holy Week, Easter octave, Triduum). Also `func adventStart(year int) time.Time` (1st Sunday of Advent for the liturgical year *ending* Dec of `year`).
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func d(s string) time.Time {
+ t, _ := time.Parse("2006-01-02", s)
+ return t.UTC()
+}
+
+func TestTemporalSeasons(t *testing.T) {
+ sel := DefaultSelection()
+ cases := []struct {
+ date string
+ season Season
+ }{
+ {"2025-12-01", Advent}, // Mon after Advent I (2025 Advent I = Nov 30)
+ {"2025-12-25", Christmas}, // Christmas
+ {"2025-03-05", Lent}, // Ash Wednesday 2025
+ {"2025-04-20", Easter_}, // Easter Sunday 2025
+ {"2025-07-15", Ordinary}, // deep Ordinary Time
+ }
+ for _, c := range cases {
+ got := temporal(d(c.date), sel)
+ if got.Season != c.season {
+ t.Errorf("temporal(%s).Season = %s, want %s", c.date, got.Season, c.season)
+ }
+ }
+}
+
+func TestTemporalPrivilegedSunday(t *testing.T) {
+ // 2nd Sunday of Advent 2025 (Dec 7) is privileged and violet.
+ td := temporal(d("2025-12-07"), DefaultSelection())
+ if !td.Privileged {
+ t.Error("Sunday of Advent must be privileged")
+ }
+ if td.Colour != Violet {
+ t.Errorf("Advent colour = %s want violet", td.Colour)
+ }
+}
+
+func TestMovableSolemnities(t *testing.T) {
+ // Corpus Christi 2025 (Thursday, easter+60) = 2025-06-19.
+ td := temporal(d("2025-06-19"), DefaultSelection())
+ if td.Cel.Slug != "corpus-christi" {
+ t.Errorf("2025-06-19 temporal slug = %q want corpus-christi", td.Cel.Slug)
+ }
+ if td.Rank != RankSolemnity {
+ t.Error("Corpus Christi must be a solemnity")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run 'TestTemporal|TestMovable'`
+Expected: FAIL (`undefined: temporal`).
+
+- [ ] **Step 3: Write the implementation**
+
+The temporal cycle is derived entirely from Easter and the Advent anchor. This
+implementation covers the seasons, Sundays/weekdays, and the movable
+solemnities/feasts of the General Roman Calendar. Weeks are numbered per the
+liturgical convention (Ordinary Time weeks bridge across Lent/Easter).
+
+```go
+package calendar
+
+import "time"
+
+type temporalDay struct {
+ Season Season
+ Week int
+ Colour Colour
+ Cel Celebration
+ Rank Rank
+ Class Class
+ Privileged bool
+}
+
+// adventStart returns the First Sunday of Advent that opens the liturgical year
+// containing Christmas of the given calendar year: the 4th Sunday before Dec 25.
+func adventStart(year int) time.Time {
+ xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC)
+ // Sunday on/before Christmas, then back 3 more Sundays.
+ sun := xmas.AddDate(0, 0, -int(xmas.Weekday())) // Sunday <= Christmas (Sunday=0)
+ return sun.AddDate(0, 0, -21)
+}
+
+func sameDay(a, b time.Time) bool {
+ return a.Year() == b.Year() && a.YearDay() == b.YearDay()
+}
+
+func weeksBetween(from, to time.Time) int {
+ return int(to.Sub(from).Hours()/24) / 7
+}
+
+// temporal computes the temporal (season/movable) identity of date.
+func temporal(date time.Time, sel Selection) temporalDay {
+ date = date.UTC().Truncate(24 * time.Hour)
+ y := date.Year()
+ easter := Easter(y)
+ ashWed := easter.AddDate(0, 0, -46)
+ pentecost := easter.AddDate(0, 0, 49)
+ triduumStart := easter.AddDate(0, 0, -3) // Holy Thursday evening; treat Thu-Sat as Triduum
+ palmSunday := easter.AddDate(0, 0, -7)
+ adventThis := adventStart(y)
+ adventPrev := adventStart(y - 1)
+ christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC)
+ baptism := baptismOfLord(y, sel)
+
+ // --- movable solemnities/feasts keyed to Easter (highest first) ---
+ switch {
+ case sameDay(date, easter):
+ return sol(date, Easter_, "easter-sunday", White, true)
+ case date.After(easter) && date.Before(easter.AddDate(0, 0, 7)):
+ return octave(date, Easter_, "easter-octave", White, weeksBetween(easter, date))
+ case sameDay(date, ascension(y, sel)):
+ return sol(date, Easter_, "ascension", White, true)
+ case sameDay(date, pentecost):
+ return sol(date, Easter_, "pentecost", Red, true)
+ case sameDay(date, easter.AddDate(0, 0, 56)): // Trinity, easter+56
+ return sol(date, Ordinary, "trinity", White, false)
+ case sameDay(date, corpusChristi(y, sel)):
+ return sol(date, Ordinary, "corpus-christi", White, false)
+ case sameDay(date, easter.AddDate(0, 0, 68)): // Sacred Heart, easter+68
+ return sol(date, Ordinary, "sacred-heart", White, false)
+ }
+
+ // --- seasons by span ---
+ switch {
+ case !date.Before(triduumStart) && date.Before(easter):
+ return sol(date, Triduum, "triduum", Red, true)
+ case !date.Before(palmSunday) && date.Before(triduumStart): // Holy Week Mon–Wed + Palm Sunday
+ return privWeek(date, Lent, "holy-week", Violet)
+ case !date.Before(ashWed) && date.Before(palmSunday):
+ return lent(date, ashWed)
+ case (!date.Before(easter) && date.Before(pentecost.AddDate(0, 0, 1))):
+ return eastertide(date, easter)
+ case !date.Before(adventThis): // Advent of the new liturgical year (late this year)
+ return advent(date, adventThis, sel)
+ case date.Before(baptism.AddDate(0, 0, 1)): // Christmas season into early Jan (opened last year)
+ return christmasSeason(date, adventPrev, christmasPrev(y), baptism, sel)
+ case !date.Before(christmasThis): // Christmas Day..Dec 31 of this year
+ return christmasSeason(date, adventThis, christmasThis, baptismOfLord(y+1, sel), sel)
+ default:
+ return ordinary(date, baptism, adventThis)
+ }
+}
+```
+
+Supporting helpers (same file):
+
+```go
+// small constructors keep temporal() readable.
+func sol(date time.Time, s Season, slug string, col Colour, privileged bool) temporalDay {
+ return temporalDay{Season: s, Colour: col, Rank: RankSolemnity, Class: ClassLord, Privileged: privileged,
+ Cel: Celebration{Slug: slug, Rank: RankSolemnity, Class: ClassLord, Colour: col, Layer: "temporal"}}
+}
+func privWeek(date time.Time, s Season, slug string, col Colour) temporalDay {
+ r := RankFerial
+ priv := true
+ if date.Weekday() == time.Sunday {
+ r = RankSolemnity // Palm Sunday ranks with Sundays of Lent
+ }
+ return temporalDay{Season: s, Colour: col, Rank: r, Privileged: priv,
+ Cel: Celebration{Slug: slug, Rank: r, Colour: col, Layer: "temporal"}}
+}
+func octave(date time.Time, s Season, slug string, col Colour, day int) temporalDay {
+ return temporalDay{Season: s, Colour: col, Rank: RankSolemnity, Privileged: true, Week: day,
+ Cel: Celebration{Slug: slug, Rank: RankSolemnity, Colour: col, Layer: "temporal"}}
+}
+
+// ascension: Thursday (easter+39) or, where transferred, the 7th Sunday of Easter (easter+42).
+func ascension(y int, sel Selection) time.Time {
+ e := Easter(y)
+ if sel.Ascension == "sunday" {
+ return e.AddDate(0, 0, 42)
+ }
+ return e.AddDate(0, 0, 39)
+}
+func corpusChristi(y int, sel Selection) time.Time {
+ e := Easter(y)
+ if sel.CorpusChristi == "sunday" {
+ return e.AddDate(0, 0, 63)
+ }
+ return e.AddDate(0, 0, 60)
+}
+```
+
+> **Implementer note:** `lent`, `eastertide`, `advent`, `christmasSeason`, `ordinary`,
+> `baptismOfLord`, `christmasPrev`, `Weekday`, `(temporalDay).norm` are the remaining
+> season builders. Each sets `Season`, `Week` (count of Sundays since the season start,
+> or the Ordinary-Time week bridged across Lent/Easter), `Colour` (Advent/Lent violet,
+> Gaudete/Laetare 3rd-Advent/4th-Lent Sunday rose, Christmas/Easter white, else green),
+> a ferial or Sunday `Cel` with slug `"<season>-<week>-<weekday>"` / `"<season>-sunday-<week>"`,
+> `Rank` (`RankSolemnity` for privileged Sundays, else `RankFerial`), and `Privileged`
+> (true for Sundays of Advent/Lent/Easter). Baptism of the Lord = the Sunday after Epiphany
+> (`sel.Epiphany=="fixed"` → Epiphany Jan 6, Baptism the following Sunday; `"sunday"` →
+> Epiphany the Sunday between Jan 2–8, Baptism the next Sunday, or Monday Jan 9 when Epiphany
+> falls on Jan 7/8). Write each builder with its own failing test first (Step 3 is TDD-internal:
+> add a test per builder, run red, implement, run green) before wiring them into `temporal`.
+> The season boundaries and week numbers are validated wholesale by the regression oracle
+> (Task 10); use it to drive the fiddly cases rather than guessing.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/calendar/ -run 'TestTemporal|TestMovable'`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/calendar/temporal.go internal/calendar/temporal_test.go
+git commit -m "feat(calendar): temporal cycle (seasons, Sundays, movable solemnities)"
+```
+
+---
+
+## Task 5: Layer merge (`internal/calendar/merge.go`)
+
+**Files:**
+- Create: `internal/calendar/merge.go`
+- Test: `internal/calendar/merge_test.go`
+
+**Interfaces:**
+- Consumes: `Layer`, `RawCelebration` (Task 2).
+- Produces: `func mergeLayers(layers []Layer) map[string]RawCelebration`. Later layers override earlier field-by-field; `Fields["suppress"]=="true"` removes the slug; variant sub-maps merge per-variant.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package calendar
+
+import "testing"
+
+func raw(fields map[string]string) RawCelebration {
+ return RawCelebration{Fields: fields, Variants: map[string]map[string]string{}}
+}
+
+func TestMergeLayers(t *testing.T) {
+ base := Layer{ID: "universal", Cels: map[string]RawCelebration{
+ "assumption": raw(map[string]string{"rank": "solemnity", "colour": "white", "name.en": "Assumption"}),
+ "st-x": raw(map[string]string{"rank": "optional", "date": "01-02"}),
+ "drop-me": raw(map[string]string{"rank": "memorial", "date": "03-03"}),
+ }}
+ local := Layer{ID: "krakow", Cels: map[string]RawCelebration{
+ "st-x": raw(map[string]string{"rank": "feast"}), // elevate; keep date
+ "drop-me": raw(map[string]string{"suppress": "true"}), // remove
+ "st-new": raw(map[string]string{"rank": "solemnity", "date": "05-08"}), // add
+ }}
+ got := mergeLayers([]Layer{base, local})
+
+ if got["st-x"].Fields["rank"] != "feast" || got["st-x"].Fields["date"] != "01-02" {
+ t.Errorf("st-x merge = %+v", got["st-x"].Fields)
+ }
+ if _, ok := got["drop-me"]; ok {
+ t.Error("drop-me should be suppressed")
+ }
+ if got["st-new"].Fields["date"] != "05-08" {
+ t.Error("st-new should be added")
+ }
+ if got["assumption"].Fields["name.en"] != "Assumption" {
+ t.Error("untouched base entry lost")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run TestMergeLayers`
+Expected: FAIL (`undefined: mergeLayers`).
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+package calendar
+
+// mergeLayers folds an ordered layer stack into one raw celebration set. Later
+// layers override earlier ones field-by-field; a slug with Fields["suppress"]
+// == "true" is removed; variant sub-sections merge per variant.
+func mergeLayers(layers []Layer) map[string]RawCelebration {
+ out := map[string]RawCelebration{}
+ for _, layer := range layers {
+ for slug, rc := range layer.Cels {
+ if rc.Fields["suppress"] == "true" {
+ delete(out, slug)
+ continue
+ }
+ cur, ok := out[slug]
+ if !ok {
+ cur = RawCelebration{Fields: map[string]string{}, Variants: map[string]map[string]string{}}
+ } else {
+ cur = cloneRaw(cur)
+ }
+ for k, v := range rc.Fields {
+ cur.Fields[k] = v
+ }
+ for variant, fields := range rc.Variants {
+ if cur.Variants[variant] == nil {
+ cur.Variants[variant] = map[string]string{}
+ }
+ for k, v := range fields {
+ cur.Variants[variant][k] = v
+ }
+ }
+ cur.Fields["layer"] = layer.ID // provenance: last writer
+ out[slug] = cur
+ }
+ }
+ return out
+}
+
+func cloneRaw(rc RawCelebration) RawCelebration {
+ nf := make(map[string]string, len(rc.Fields))
+ for k, v := range rc.Fields {
+ nf[k] = v
+ }
+ nv := make(map[string]map[string]string, len(rc.Variants))
+ for variant, fields := range rc.Variants {
+ m := make(map[string]string, len(fields))
+ for k, v := range fields {
+ m[k] = v
+ }
+ nv[variant] = m
+ }
+ return RawCelebration{Fields: nf, Variants: nv}
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/calendar/ -run TestMergeLayers`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/calendar/merge.go internal/calendar/merge_test.go
+git commit -m "feat(calendar): ordered-stack layer merge (override/suppress/add)"
+```
+
+---
+
+## Task 6: Precedence + transfer (`internal/calendar/precedence.go`)
+
+**Files:**
+- Create: `internal/calendar/precedence.go`
+- Test: `internal/calendar/precedence_test.go`
+
+**Interfaces:**
+- Consumes: `Rank`, `Class`, `Season` (Task 2), `temporalDay` (Task 4).
+- Produces: `type candidate struct { Cel Celebration; Temporal bool; Privileged bool; Season Season }`, `func precedence(c candidate) int` (Table of Liturgical Days — **lower = higher precedence**), and `func pick(cands []candidate) (observed candidate, others []candidate)`. Transfer of impeded solemnities is handled in `Compute` (Task 7) using `precedence`.
+
+The Table of Liturgical Days (Universal Norms, 1969), encoded as bands:
+
+| Band | Days |
+|---|---|
+| 1 | Triduum |
+| 2 | Christmas, Epiphany, Ascension, Pentecost; Sundays of Advent/Lent/Easter; Ash Wednesday; Holy Week; Easter octave |
+| 3 | Solemnities (Lord, BVM, saints) in the General Calendar; All Souls |
+| 4 | Proper solemnities |
+| 5 | Feasts of the Lord in the General Calendar |
+| 6 | Sundays of Christmas and Ordinary Time |
+| 7 | Feasts of the BVM/saints in the General Calendar |
+| 8 | Proper feasts |
+| 9 | Privileged ferias: Advent Dec 17–24, Christmas octave, Lenten ferias |
+| 10 | Obligatory memorials, General Calendar |
+| 11 | Proper obligatory memorials |
+| 12 | Optional memorials |
+| 13 | Ordinary ferias |
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package calendar
+
+import "testing"
+
+func tempCand(rank Rank, class Class, priv bool, s Season) candidate {
+ return candidate{Cel: Celebration{Rank: rank, Class: class}, Temporal: true, Privileged: priv, Season: s}
+}
+func saintCand(rank Rank, class Class) candidate {
+ return candidate{Cel: Celebration{Rank: rank, Class: class}, Temporal: false, Season: Ordinary}
+}
+
+func TestPrecedenceBands(t *testing.T) {
+ sundayLent := tempCand(RankSolemnity, ClassNone, true, Lent)
+ genSolemnity := saintCand(RankSolemnity, ClassSaint)
+ genFeast := saintCand(RankFeast, ClassSaint)
+ genMemorial := saintCand(RankMemorial, ClassSaint)
+ // A Sunday of Lent is band 2; a general solemnity is band 3 → the Sunday wins.
+ if !(precedence(sundayLent) < precedence(genSolemnity)) {
+ t.Error("Sunday of Lent (band 2) must outrank a general solemnity (band 3)")
+ }
+ if !(precedence(genSolemnity) < precedence(genFeast) && precedence(genFeast) < precedence(genMemorial)) {
+ t.Error("solemnity > feast > memorial ordering broken")
+ }
+}
+
+func TestPick(t *testing.T) {
+ sunday := tempCand(RankSolemnity, ClassNone, true, Ordinary) // band 6
+ memorial := saintCand(RankMemorial, ClassSaint) // band 10
+ obs, others := pick([]candidate{memorial, sunday})
+ if obs.Cel.Rank != RankSolemnity || !obs.Temporal {
+ t.Errorf("Sunday must win, got %+v", obs.Cel)
+ }
+ if len(others) != 1 || others[0].Cel.Rank != RankMemorial {
+ t.Errorf("memorial should be a commemoration, got %+v", others)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run 'TestPrecedence|TestPick'`
+Expected: FAIL (`undefined: candidate`).
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+package calendar
+
+import "sort"
+
+type candidate struct {
+ Cel Celebration
+ Temporal bool
+ Privileged bool
+ Season Season
+}
+
+// precedence maps a candidate to its Table-of-Liturgical-Days band
+// (lower = higher precedence). "Proper" (non-General-Calendar) celebrations use
+// the layer provenance: a non-empty, non-"universal"/"temporal" layer is proper.
+func precedence(c candidate) int {
+ proper := c.Cel.Layer != "" && c.Cel.Layer != "universal" && c.Cel.Layer != "temporal"
+ if c.Temporal {
+ switch {
+ case c.Season == Triduum:
+ return 1
+ case c.Privileged: // privileged Sundays, Holy Week, Easter octave, top solemnities of the Lord
+ return 2
+ case c.Cel.Rank == RankSolemnity: // Trinity/Corpus Christi/Sacred Heart etc.
+ return 3
+ case c.Cel.Rank == RankFeast:
+ return 5
+ case isSunday(c): // ordinary/Christmas Sundays
+ return 6
+ case c.privilegedFeria():
+ return 9
+ default:
+ return 13
+ }
+ }
+ switch c.Cel.Rank {
+ case RankSolemnity:
+ if proper {
+ return 4
+ }
+ return 3
+ case RankFeast:
+ if proper {
+ return 8
+ }
+ return 7
+ case RankMemorial:
+ if proper {
+ return 11
+ }
+ return 10
+ case RankOptional:
+ return 12
+ default:
+ return 13
+ }
+}
+
+func isSunday(c candidate) bool { return c.Cel.Slug != "" && c.Privileged == false && c.Cel.Rank == RankSolemnity }
+
+// privilegedFeria is set by the temporal builder via Rank/slug; encoded on the
+// candidate by Compute (Advent 17–24, Christmas octave, Lenten ferias).
+func (c candidate) privilegedFeria() bool { return c.Cel.Slug == "privileged-feria" }
+
+// pick returns the highest-precedence candidate as observed and the rest as
+// others (commemorations / optional memorials), stably ordered by precedence.
+func pick(cands []candidate) (candidate, []candidate) {
+ sorted := make([]candidate, len(cands))
+ copy(sorted, cands)
+ sort.SliceStable(sorted, func(i, j int) bool { return precedence(sorted[i]) < precedence(sorted[j]) })
+ return sorted[0], sorted[1:]
+}
+```
+
+> **Implementer note:** the `isSunday`/`privilegedFeria` predicates above are a first
+> cut wired to slug/flag conventions the temporal builder sets; refine them so bands 6 and 9
+> match the oracle. Encoding proper-vs-general via layer provenance is exact for #1 (only the
+> universal layer ships). **Transfer of impeded solemnities** (band-3/4 solemnity colliding
+> with band-1/2) is applied in `Compute` (Task 7): when a solemnity is impeded, move it to the
+> nearest following day not itself impeded. Drive band 6/9 and transfer edge cases with the
+> oracle (Task 10).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/calendar/ -run 'TestPrecedence|TestPick'`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/calendar/precedence.go internal/calendar/precedence_test.go
+git commit -m "feat(calendar): Universal Norms precedence bands + pick"
+```
+
+---
+
+## Task 7: Compute — build + wire + cycles (`internal/calendar/calendar.go`)
+
+**Files:**
+- Create: `internal/calendar/calendar.go`
+- Test: `internal/calendar/calendar_test.go`
+
+**Interfaces:**
+- Consumes: everything from Tasks 2–6.
+- Produces: `func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`; `func buildCelebration(slug string, rc RawCelebration) Celebration`; `func sundayCycle(liturgicalYearStart time.Time) string`; `func weekdayCycle(liturgicalYearStartYear int) string`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package calendar
+
+import (
+ "testing"
+)
+
+func universalTest() Layer {
+ return Layer{ID: "universal", Type: "universal", Cels: map[string]RawCelebration{
+ "assumption": {Fields: map[string]string{
+ "date": "08-15", "rank": "solemnity", "class": "bvm", "colour": "white",
+ "name.en": "Assumption of the BVM", "reading.gospel": "Lk 1:39-56",
+ }, Variants: map[string]map[string]string{}},
+ " st-monday-optional ": {Fields: map[string]string{}, Variants: map[string]map[string]string{}},
+ }}
+}
+
+func TestComputeSolemnityBeatsFeria(t *testing.T) {
+ got := Compute(d("2025-08-15"), DefaultSelection(), []Layer{universalTest()})
+ if got.Observed.Slug != "assumption" {
+ t.Fatalf("2025-08-15 observed = %q want assumption", got.Observed.Slug)
+ }
+ if got.Colour != White || got.Observed.Rank != RankSolemnity {
+ t.Errorf("assumption colour/rank wrong: %s / %v", got.Colour, got.Observed.Rank)
+ }
+ if len(got.Observed.Masses) == 0 || got.Observed.Masses[0].Readings[0].Part != "gospel" {
+ t.Errorf("proper reading not surfaced: %+v", got.Observed.Masses)
+ }
+}
+
+func TestComputeCycles(t *testing.T) {
+ // Advent 2024 opens liturgical year 2025 → Sunday cycle C, weekday cycle I.
+ got := Compute(d("2025-01-15"), DefaultSelection(), []Layer{universalTest()})
+ if got.SundayCycle != "C" {
+ t.Errorf("2024-25 Sunday cycle = %s want C", got.SundayCycle)
+ }
+ if got.WeekdayCycle != "I" {
+ t.Errorf("2025 weekday cycle = %s want I", got.WeekdayCycle)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/calendar/ -run TestCompute`
+Expected: FAIL (`undefined: Compute`).
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+package calendar
+
+import (
+ "sort"
+ "strings"
+ "time"
+)
+
+func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
+ date = date.UTC().Truncate(24 * time.Hour)
+ td := temporal(date, sel)
+ merged := mergeLayers(layers)
+
+ // candidates: the temporal day + every sanctoral celebration landing today
+ // (after transfer of impeded solemnities).
+ cands := []candidate{{Cel: td.Cel, Temporal: true, Privileged: td.Privileged, Season: td.Season}}
+ for slug, rc := range merged {
+ cel := buildCelebration(slug, rc)
+ when, ok := celebrationDate(cel, date.Year(), sel)
+ if !ok {
+ continue
+ }
+ if td.Privileged && cel.Rank < RankSolemnity {
+ continue // nothing below a solemnity survives a privileged day
+ }
+ effective := transferIfImpeded(cel, when, sel)
+ if sameDay(effective, date) {
+ cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
+ }
+ }
+
+ obs, others := pick(cands)
+ day := LiturgicalDay{
+ Date: date, Season: td.Season, Week: td.Week, Weekday: date.Weekday(),
+ Observed: obs.Cel, Colour: colourOf(obs, td),
+ SundayCycle: sundayCycle(liturgicalYearStart(date)),
+ WeekdayCycle: weekdayCycle(liturgicalYearStart(date).Year() + 1),
+ }
+ for _, o := range others {
+ day.Others = append(day.Others, o.Cel)
+ }
+ return day
+}
+
+func colourOf(obs candidate, td temporalDay) Colour {
+ if obs.Cel.Colour != "" {
+ return obs.Cel.Colour
+ }
+ return td.Colour
+}
+
+// celebrationDate resolves a sanctoral celebration's date for the year.
+func celebrationDate(cel Celebration, year int, sel Selection) (time.Time, bool) {
+ return resolveDate(cel.Date, year, Easter(year))
+}
+
+// transferIfImpeded moves a solemnity off a higher-precedence day to the next
+// free day. Non-solemnities are never transferred (they yield instead).
+func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time {
+ if cel.Rank != RankSolemnity {
+ return when
+ }
+ day := when
+ for i := 0; i < 8; i++ {
+ blocker := temporal(day, sel)
+ blockerBand := precedence(candidate{Cel: blocker.Cel, Temporal: true, Privileged: blocker.Privileged, Season: blocker.Season})
+ if blockerBand <= 2 { // impeded by Triduum/privileged day: push forward
+ day = day.AddDate(0, 0, 1)
+ continue
+ }
+ return day
+ }
+ return day
+}
+
+func buildCelebration(slug string, rc RawCelebration) Celebration {
+ c := Celebration{Slug: strings.TrimSpace(slug), Name: map[string]string{}, Layer: rc.Fields["layer"]}
+ for k, v := range rc.Fields {
+ switch {
+ case k == "rank":
+ c.Rank = ParseRank(v)
+ case k == "class":
+ c.Class = ParseClass(v)
+ case k == "colour":
+ c.Colour = ParseColour(v)
+ case k == "date":
+ c.Date = DateSpec(v)
+ case strings.HasPrefix(k, "name."):
+ c.Name[strings.TrimPrefix(k, "name.")] = v
+ }
+ }
+ if m := massFrom(rc.Fields); len(m.Readings) > 0 {
+ c.Masses = append(c.Masses, m)
+ }
+ // deterministic variant order
+ var variants []string
+ for v := range rc.Variants {
+ variants = append(variants, v)
+ }
+ sort.Strings(variants)
+ for _, v := range variants {
+ mm := massFrom(rc.Variants[v])
+ mm.Variant = v
+ c.Masses = append(c.Masses, mm)
+ }
+ return c
+}
+
+var readingParts = []string{"first", "psalm", "second", "acclamation", "gospel"}
+
+func massFrom(fields map[string]string) Mass {
+ var m Mass
+ for _, part := range readingParts {
+ if cit := fields["reading."+part]; cit != "" {
+ m.Readings = append(m.Readings, Reading{Part: part, Citation: cit})
+ }
+ }
+ return m
+}
+
+// liturgicalYearStart returns the First Sunday of Advent that opened the
+// liturgical year containing date.
+func liturgicalYearStart(date time.Time) time.Time {
+ a := adventStart(date.Year())
+ if date.Before(a) {
+ return adventStart(date.Year() - 1)
+ }
+ return a
+}
+
+// sundayCycle: Advent-year %3 → C(0),A(1),B(2).
+func sundayCycle(start time.Time) string {
+ switch (start.Year() + 1) % 3 {
+ case 0:
+ return "C"
+ case 1:
+ return "A"
+ default:
+ return "B"
+ }
+}
+
+// weekdayCycle: odd civil year of the (Jan–) part → I, even → II.
+func weekdayCycle(year int) string {
+ if year%2 == 1 {
+ return "I"
+ }
+ return "II"
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/calendar/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/calendar/calendar.go internal/calendar/calendar_test.go
+git commit -m "feat(calendar): Compute — build celebrations, apply precedence + transfer, cycles"
+```
+
+---
+
+## Task 8: Base data package (`internal/caldata`)
+
+**Files:**
+- Create: `internal/caldata/caldata.go`, `internal/caldata/roman-calendar.ini`
+- Test: `internal/caldata/caldata_test.go`
+
+**Interfaces:**
+- Consumes: `ini.Parse` (Task 1), `calendar.Layer`/`RawCelebration` (Task 2).
+- Produces: `func Universal() calendar.Layer` (parses the embedded INI into a Layer; panics only on a malformed *embedded* file, which tests catch).
+
+- [ ] **Step 1: Seed a real (initial) data file**
+
+Create `internal/caldata/roman-calendar.ini` with the `[layer]` header and an
+initial, real set of General Roman Calendar sanctoral entries (the fixed
+solemnities/feasts + a representative set of memorials). This file grows to full
+coverage in Step 5; it must be non-empty and valid now.
+
+```ini
+[layer]
+id = universal
+name = General Roman Calendar
+type = universal
+
+[mary-mother-of-god]
+date = 01-01
+rank = solemnity
+class = bvm
+colour = white
+name.en = Mary, the Holy Mother of God
+name.pl = Świętej Bożej Rodzicielki Maryi
+name.la = Sanctae Dei Genetricis Mariae
+
+[annunciation]
+date = 03-25
+rank = solemnity
+class = lord
+colour = white
+name.en = The Annunciation of the Lord
+name.pl = Zwiastowanie Pańskie
+
+[assumption]
+date = 08-15
+rank = solemnity
+class = bvm
+colour = white
+name.en = The Assumption of the Blessed Virgin Mary
+name.pl = Wniebowzięcie Najświętszej Maryi Panny
+name.la = In Assumptione Beatae Mariae Virginis
+
+[all-saints]
+date = 11-01
+rank = solemnity
+class = saint
+colour = white
+name.en = All Saints
+name.pl = Wszystkich Świętych
+
+[immaculate-conception]
+date = 12-08
+rank = solemnity
+class = bvm
+colour = white
+name.en = The Immaculate Conception of the Blessed Virgin Mary
+```
+
+- [ ] **Step 2: Write the failing test**
+
+```go
+package caldata
+
+import (
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+)
+
+func TestUniversalLoads(t *testing.T) {
+ l := Universal()
+ if l.ID != "universal" {
+ t.Fatalf("layer id = %q", l.ID)
+ }
+ rc, ok := l.Cels["assumption"]
+ if !ok || rc.Fields["rank"] != "solemnity" || rc.Fields["date"] != "08-15" {
+ t.Fatalf("assumption = %+v ok=%v", rc, ok)
+ }
+ // every entry must build into a typed Celebration with a resolvable date.
+ for slug, rc := range l.Cels {
+ c := calendar.BuildCelebrationForTest(slug, rc) // exported test shim, Task 8 step 3
+ if c.Rank == calendar.RankFerial && rc.Fields["rank"] != "" {
+ t.Errorf("%s: rank did not parse (%q)", slug, rc.Fields["rank"])
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Add a test shim in the calendar package**
+
+Because `buildCelebration` is unexported, add to `internal/calendar/calendar.go`:
+
+```go
+// BuildCelebrationForTest exposes buildCelebration for cross-package data tests.
+func BuildCelebrationForTest(slug string, rc RawCelebration) Celebration { return buildCelebration(slug, rc) }
+```
+
+- [ ] **Step 4: Write the loader**
+
+```go
+// Package caldata embeds and parses lectio's owned universal General Roman
+// Calendar data into a calendar.Layer. It imports only internal/ini and
+// internal/calendar (types).
+package caldata
+
+import (
+ _ "embed"
+ "fmt"
+
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+ "github.com/lukaszkasprzak/lectio/internal/ini"
+)
+
+//go:embed roman-calendar.ini
+var romanCalendar []byte
+
+// Universal parses the embedded universal calendar. It panics on a malformed
+// embedded file (a build-time bug caught by tests), never at the request layer.
+func Universal() calendar.Layer {
+ l, err := parse(romanCalendar)
+ if err != nil {
+ panic(fmt.Sprintf("caldata: embedded roman-calendar.ini invalid: %v", err))
+ }
+ return l
+}
+
+func parse(data []byte) (calendar.Layer, error) {
+ secs, err := ini.Parse(data)
+ if err != nil {
+ return calendar.Layer{}, err
+ }
+ layer := calendar.Layer{Cels: map[string]calendar.RawCelebration{}}
+ for _, s := range secs {
+ fields := map[string]string{}
+ for _, p := range s.Pairs {
+ fields[p.Key] = p.Val
+ }
+ switch {
+ case s.Name == "" && len(fields) == 0:
+ continue
+ case s.Name == "layer":
+ layer.ID, layer.Name, layer.Type = fields["id"], fields["name"], fields["type"]
+ default:
+ base, variant, isVariant := cutVariant(s.Name)
+ rc := layer.Cels[base]
+ if rc.Fields == nil {
+ rc = calendar.RawCelebration{Fields: map[string]string{}, Variants: map[string]map[string]string{}}
+ }
+ if isVariant {
+ rc.Variants[variant] = fields
+ } else {
+ for k, v := range fields {
+ rc.Fields[k] = v
+ }
+ }
+ layer.Cels[base] = rc
+ }
+ }
+ return layer, nil
+}
+
+func cutVariant(name string) (base, variant string, ok bool) {
+ for i := 0; i < len(name); i++ {
+ if name[i] == '/' {
+ return name[:i], name[i+1:], true
+ }
+ }
+ return name, "", false
+}
+```
+
+- [ ] **Step 5: Populate full coverage (data)**
+
+Bootstrap the full universal sanctoral into `roman-calendar.ini` from romcal's
+MIT-licensed General Roman Calendar data, then verify against the official
+calendar. Process:
+
+```bash
+# reference data (read-only; do NOT vendor romcal into the module)
+git clone --depth 1 https://github.com/romcal/romcal /tmp/romcal
+# map each GRC entry → [slug]/date/rank/class/colour into roman-calendar.ini
+# (slugs kebab-case; rank ∈ solemnity|feast|memorial|optional; class ∈ lord|bvm|saint)
+```
+
+Add a `NOTICE` entry crediting romcal (MIT). Acceptance for the data itself is
+the regression oracle (Task 10) — do not hand-verify hundreds of dates.
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `go test ./internal/caldata/ ./internal/calendar/`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/caldata/ internal/calendar/calendar.go NOTICE
+git commit -m "feat(caldata): embed + parse the owned universal Roman calendar"
+```
+
+---
+
+## Task 9: Config migration to INI (`internal/config`)
+
+**Files:**
+- Create: `internal/config/config.ini`
+- Modify: `internal/config/config.go` (Load/Save/seed → INI; add `[calendar]` keys + `Selection()`; one-shot TOML→INI convert)
+- Test: `internal/config/config_ini_test.go`
+
+**Interfaces:**
+- Consumes: `ini.Parse`/`ini.List` (Task 1), `calendar.Selection`/`DefaultSelection` (Task 2).
+- Produces: `func (c Config) Selection() calendar.Selection`; new `Config` fields `LectionaryPlacement` are read from `[calendar]`. `Load()`/`Save()` operate on `config.ini`; existing `config.toml` is auto-converted once.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadINIAndSelection(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini"))
+ must := []byte("lectionary = new\n[calendar]\nepiphany = sunday\nascension = sunday\n")
+ if err := os.WriteFile(filepath.Join(dir, "config.ini"), must, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sel := cfg.Selection()
+ if sel.Epiphany != "sunday" || sel.Ascension != "sunday" || sel.CorpusChristi != "thursday" {
+ t.Fatalf("selection = %+v", sel)
+ }
+}
+
+func TestMigrateTOML(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini"))
+ // only the old TOML exists → Load must convert it once and write config.ini.
+ old := []byte("lectionary = \"new\"\nui_language = \"pl\"\n")
+ if err := os.WriteFile(filepath.Join(dir, "config.toml"), old, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.UILanguage != "pl" {
+ t.Errorf("migrated ui_language = %q", cfg.UILanguage)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "config.ini")); err != nil {
+ t.Error("config.ini not written on migration")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/config/ -run 'TestLoadINI|TestMigrate'`
+Expected: FAIL (`undefined: (Config).Selection` / INI path not honored).
+
+- [ ] **Step 3: Implement INI load/save + migration + Selection**
+
+Modify `internal/config/config.go`:
+- Change `configPath()` to return `.../lectio/config.ini`.
+- Add `readINI(data []byte) (Config, error)` using `ini.Parse`: scalars by key; lists via `ini.List` (`versions`, `web_versions`); the `[calendar]` section fills placement fields; `[parts.<lectionary>]` sections fill `Parts`.
+- Add `writeINI(c Config) []byte` (deterministic key order; lists comma-joined; `[calendar]` + `[parts.*]` sections).
+- In `Load()`: if `config.ini` is absent but a sibling `config.toml` exists, `toml.Unmarshal` it into a `Config`, then `Save()` it as INI (one-shot migration), then proceed.
+- Add fields + accessor:
+
+```go
+// in Config struct:
+// CalEpiphany string `toml:"-"` // "fixed"|"sunday" (INI [calendar] epiphany)
+// CalAscension string `toml:"-"`
+// CalCorpusChristi string `toml:"-"`
+
+func (c Config) Selection() calendar.Selection {
+ sel := calendar.DefaultSelection()
+ sel.Form = orDefault(c.Lectionary, sel.Form)
+ sel.Epiphany = orDefault(c.CalEpiphany, sel.Epiphany)
+ sel.Ascension = orDefault(c.CalAscension, sel.Ascension)
+ sel.CorpusChristi = orDefault(c.CalCorpusChristi, sel.CorpusChristi)
+ return sel
+}
+
+func orDefault(v, def string) string {
+ if v == "" {
+ return def
+ }
+ return v
+}
+```
+
+Create `internal/config/config.ini` as the INI seed (the INI form of the current
+`config.toml` defaults, plus a `[calendar]` section with `epiphany = fixed`,
+`ascension = thursday`, `corpus_christi = thursday`). Replace the
+`//go:embed config.toml` seed with `//go:embed config.ini`. Retain the go-toml
+import **only** inside the migration branch.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/config/`
+Expected: PASS (existing config tests + the two new ones).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/config/
+git commit -m "feat(config): INI config + one-shot TOML migration + calendar Selection"
+```
+
+---
+
+## Task 10: Regression oracle (`internal/calendar/oracle_test.go`)
+
+**Files:**
+- Create: `scripts/build-oracle.sh`, `internal/calendar/testdata/oracle-2020-2040.json`, `internal/calendar/oracle_test.go`
+- Modify: `internal/caldata/roman-calendar.ini` (iterate until the diff is clean)
+
+**Interfaces:**
+- Consumes: `Compute`, `caldata.Universal()`.
+- Produces: a test that computes every day 2020-01-01..2040-12-31 and asserts the observed celebration's season, rank, and colour match the snapshot (identity by date, ignoring localized wording).
+
+- [ ] **Step 1: Write the snapshot generator (run once, by hand, with network)**
+
+`scripts/build-oracle.sh` fetches the authoritative calendar for each year from
+`https://calapi.inadiutorium.cz/api/v0/en/calendars/general-en/{year}/{month}/{day}`
+(calendarium-romanum) and writes `internal/calendar/testdata/oracle-2020-2040.json`
+as `{"YYYY-MM-DD": {"season": "...", "rank": "solemnity|feast|memorial|optional|ferial", "colour": "..."}}`.
+Map calapi ranks/colours to lectio's vocabulary in the script. This script is
+**not** run by `go test`.
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+out=internal/calendar/testdata/oracle-2020-2040.json
+echo '{' > "$out"
+# ... loop 2020..2040, day by day, curl calapi, map fields, append "date": {...} ...
+echo '}' >> "$out"
+```
+
+- [ ] **Step 2: Write the oracle test**
+
+```go
+package calendar_test
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+)
+
+type oracleDay struct {
+ Season, Rank, Colour string
+}
+
+func rankName(r calendar.Rank) string {
+ switch r {
+ case calendar.RankSolemnity:
+ return "solemnity"
+ case calendar.RankFeast:
+ return "feast"
+ case calendar.RankMemorial:
+ return "memorial"
+ case calendar.RankOptional:
+ return "optional"
+ default:
+ return "ferial"
+ }
+}
+
+func TestOracle2020to2040(t *testing.T) {
+ raw, err := os.ReadFile("testdata/oracle-2020-2040.json")
+ if err != nil {
+ t.Skip("oracle snapshot missing; run scripts/build-oracle.sh")
+ }
+ var oracle map[string]oracleDay
+ if err := json.Unmarshal(raw, &oracle); err != nil {
+ t.Fatal(err)
+ }
+ sel := calendar.DefaultSelection()
+ layers := []calendar.Layer{caldata.Universal()}
+ var mismatches int
+ for date, want := range oracle {
+ day, _ := time.Parse("2006-01-02", date)
+ got := calendar.Compute(day.UTC(), sel, layers)
+ if string(got.Season) != want.Season || rankName(got.Observed.Rank) != want.Rank {
+ mismatches++
+ if mismatches <= 25 {
+ t.Errorf("%s: got %s/%s want %s/%s", date, got.Season, rankName(got.Observed.Rank), want.Season, want.Rank)
+ }
+ }
+ }
+ if mismatches > 0 {
+ t.Fatalf("%d structural mismatches vs oracle", mismatches)
+ }
+}
+```
+
+- [ ] **Step 3: Iterate the engine + data until clean**
+
+Run the oracle, fix `temporal`/`precedence`/`roman-calendar.ini` until zero
+mismatches (documenting any intentional exception — e.g. a genuine
+national-vs-universal difference — in a comment). The oracle is the acceptance
+gate for Tasks 4, 6, and 8's data.
+
+- [ ] **Step 4: Run the oracle**
+
+Run: `go test ./internal/calendar/ -run TestOracle`
+Expected: PASS (0 mismatches), or SKIP if the snapshot has not been generated.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/build-oracle.sh internal/calendar/testdata/oracle-2020-2040.json internal/calendar/oracle_test.go internal/caldata/roman-calendar.ini
+git commit -m "test(calendar): regression oracle vs calapi 2020-2040 + engine/data fixes"
+```
+
+---
+
+## Task 11: CLI command `lectio calendar [DATE]` (`internal/cli`)
+
+**Files:**
+- Create: `internal/cli/calendar.go`
+- Modify: `internal/cli/cli.go` (dispatch the `calendar` subcommand)
+- Test: `internal/cli/calendar_test.go`
+
+**Interfaces:**
+- Consumes: `config.Load`, `(Config).Selection`, `caldata.Universal`, `calendar.Compute`.
+- Produces: `func RunCalendar(args []string, out io.Writer) error` printing the computed day.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestRunCalendar(t *testing.T) {
+ var buf bytes.Buffer
+ if err := RunCalendar([]string{"2025-08-15"}, &buf); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "2025-08-15") || !strings.Contains(strings.ToLower(out), "solemnity") {
+ t.Fatalf("output missing date/rank:\n%s", out)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/cli/ -run TestRunCalendar`
+Expected: FAIL (`undefined: RunCalendar`).
+
+- [ ] **Step 3: Implement the command**
+
+```go
+package cli
+
+import (
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+ "github.com/lukaszkasprzak/lectio/internal/config"
+)
+
+// RunCalendar prints the computed liturgical day for args[0] (YYYY-MM-DD, default today).
+func RunCalendar(args []string, out io.Writer) error {
+ date := time.Now().UTC().Truncate(24 * time.Hour)
+ if len(args) > 0 && args[0] != "" {
+ d, err := time.Parse("2006-01-02", args[0])
+ if err != nil {
+ return fmt.Errorf("calendar: bad date %q (want YYYY-MM-DD)", args[0])
+ }
+ date = d.UTC()
+ }
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ day := calendar.Compute(date, cfg.Selection(), []calendar.Layer{caldata.Universal()})
+
+ name := day.Observed.Name["en"]
+ if pl := day.Observed.Name["pl"]; cfg.UILanguage == "pl" && pl != "" {
+ name = pl
+ }
+ fmt.Fprintf(out, "%s %s\n", date.Format("2006-01-02"), day.Weekday)
+ fmt.Fprintf(out, "season: %s (week %d, Sunday cycle %s, weekday cycle %s)\n",
+ day.Season, day.Week, day.SundayCycle, day.WeekdayCycle)
+ fmt.Fprintf(out, "observed: %s [%s, %s]\n", name, rankLabel(day.Observed.Rank), day.Colour)
+ for _, o := range day.Others {
+ fmt.Fprintf(out, " also: %s [%s]\n", o.Name["en"], rankLabel(o.Rank))
+ }
+ for _, m := range day.Observed.Masses {
+ for _, r := range m.Readings {
+ label := r.Part
+ if m.Variant != "" {
+ label = m.Variant + " " + label
+ }
+ fmt.Fprintf(out, " %-14s %s\n", label+":", r.Citation)
+ }
+ }
+ return nil
+}
+
+func rankLabel(r calendar.Rank) string {
+ switch r {
+ case calendar.RankSolemnity:
+ return "solemnity"
+ case calendar.RankFeast:
+ return "feast"
+ case calendar.RankMemorial:
+ return "memorial"
+ case calendar.RankOptional:
+ return "optional memorial"
+ default:
+ return "feria"
+ }
+}
+```
+
+- [ ] **Step 4: Wire the subcommand**
+
+In `internal/cli/cli.go`, in the top-level argument dispatch, before the default
+daily-readings path, add:
+
+```go
+if len(os.Args) > 1 && os.Args[1] == "calendar" {
+ if err := RunCalendar(os.Args[2:], os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
+}
+```
+
+(Match the existing dispatch style in `cli.go`; if the CLI uses a flag parser,
+register `calendar` as a subcommand consistently with `--calendar`/existing
+commands.)
+
+- [ ] **Step 5: Run tests + manual check**
+
+Run: `go test ./internal/cli/ -run TestRunCalendar && go run ./cmd/lectio calendar 2025-08-15`
+Expected: PASS; the manual run prints the Assumption as a white solemnity.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/cli/calendar.go internal/cli/cli.go internal/cli/calendar_test.go
+git commit -m "feat(cli): 'lectio calendar [DATE]' computes the liturgical day offline"
+```
+
+---
+
+## Task 12: Version bump + full verification
+
+**Files:**
+- Modify: `internal/config/config.go` (`Version`)
+
+- [ ] **Step 1: Bump the version**
+
+In `internal/config/config.go`, change `const Version = "0.25.0"` to `"0.26.0"`.
+
+- [ ] **Step 2: Full build, vet, test**
+
+Run:
+```bash
+go build ./... && go vet ./... && go test ./...
+```
+Expected: all packages PASS (oracle SKIPs only if the snapshot is absent — it must be present and passing before merge).
+
+- [ ] **Step 3: Confirm engine purity**
+
+Run:
+```bash
+go list -deps ./internal/calendar | grep -E '^github.com/(lukaszkasprzak/lectio|)' | grep -v 'internal/calendar$' || echo "PURE: calendar has no internal/third-party deps"
+```
+Expected: `PURE` (the `calendar` package imports only stdlib).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/config/config.go
+git commit -m "chore: bump to 0.26.0 (offline OF calendar engine)"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Pure stdlib engine, no network, total `Compute` — Tasks 2–7, enforced by Task 12 Step 3. ✅
+- INI config + INI celebration records + TSV corpora untouched — Tasks 1, 8, 9. ✅
+- Computus, temporal, sanctoral date resolution, precedence (Universal Norms), layer merge — Tasks 2–7. ✅
+- Inline proper readings surfaced — Task 7 (`massFrom`/`buildCelebration`), shown in Task 11 output. ✅
+- Ordered layer-stack signature (`Compute(..., layers []Layer)`) with #1 shipping only the universal layer — Tasks 7, 8. ✅
+- romcal-bootstrap-then-own data + NOTICE — Task 8 Step 5. ✅
+- Regression oracle vs calapi/romcal 2020-2040, offline snapshot — Task 10. ✅
+- TOML→INI migration as an early task — Task 9. ✅
+- `lectio calendar [DATE]` demo surface, scraper untouched — Task 11. ✅
+- Config `[calendar]` selection keys + placement knobs (Epiphany/Ascension/Corpus Christi) — Tasks 9, 4. ✅
+- Deferred (correctly absent): temporal reading cycle (#3), override-file loading + `use =` (#2), API (#4), EF, `bt`, UI migration. ✅
+
+**Placeholder scan:** no TBD/TODO. The two "Implementer note" blocks (Tasks 4, 6) give the exact rules + tests + the oracle as the acceptance gate rather than hand-waving; the data-population step (Task 8.5) is a data task specified by source + verification, not code.
+
+**Type consistency:** `Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`, `Layer{ID,Name,Type string; Cels map[string]RawCelebration}`, `RawCelebration{Fields, Variants}`, `Celebration{Slug,Name,Rank,Class,Colour,Date,Masses,Layer}`, `Selection{Form,Epiphany,Ascension,CorpusChristi}`, `candidate{Cel,Temporal,Privileged,Season}` — used identically in Tasks 2, 5, 6, 7, 8, 9, 11. `Easter`, `resolveDate`, `temporal`, `mergeLayers`, `precedence`, `pick`, `buildCelebration` signatures match across producer/consumer tasks.
+
+**Known iteration points (honest):** the temporal season builders (Task 4) and precedence bands 6/9 + transfer (Task 6) are first cuts validated wholesale by the oracle (Task 10); expect a fix loop there. This is by design — the oracle is the correctness authority.