Skip to content

Commit 2ce4bf5

Browse files
phernandezclaude
andcommitted
fix(core): keep calendar-edge dates from failing a note's index
@effective:9999-12 constructed date(10000, 1, 1) and raised, and nothing in the chain caught it: not the qualifier reader, not the observation parser, not entity_parser. A note whose second observation carried that qualifier failed the whole document parse — its other observations and all its relations went with it. Auditing the rest of the successor arithmetic found a worse instance the report did not name: _instant_value calls astimezone(UTC), which raises OverflowError when the offset shift crosses the calendar edge. OverflowError is not a ValueError, so it escaped even the existing except clause, and the same bounds reach the search router — where ValueError maps to 400 and this was a 500. Three spellings were reachable, including an underflow at 0001-01-01. Terminal periods now render as the unbounded range they represent (@effective:9999 -> [9999-01-01,), @effective:9999-12 -> [9999-12-01,)); a year beyond the calendar stays content, and an instant that leaves the calendar in UTC is refused at the bound rather than thrown. The other six arithmetic sites were audited and are safe, each for a stated reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 9d19e0e commit 2ce4bf5

4 files changed

Lines changed: 177 additions & 29 deletions

File tree

src/basic_memory/markdown/temporal_qualifier.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ def _truncation_reason(point: str, valid_during: TemporalRange) -> str | None:
248248
249249
A bounded span is how a coarse point announces itself: `parse_authored_point` closes
250250
a year or a month at its successor and leaves a day or a moment open, so
251-
`upper is None` *is* "this names a specific day".
251+
`upper is None` *is* "this names a specific day". The one period with no successor
252+
to close at -- December 9999 -- is left open too, and so reads here as a day; no word
253+
resolves to it, so the guard never sees that shape.
252254
"""
253255
if point[0].isdigit():
254256
return None if len(point) >= _MIN_NUMERIC_POINT_WIDTH else "is narrower than a year"

src/basic_memory/temporal.py

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,26 @@ def _canonical_date(bound: str) -> str:
126126
raise TemporalQualifierError(f"not a calendar date: {bound!r}") from exc
127127

128128

129-
def _instant_value(moment: datetime) -> str:
129+
def _instant_value(moment: datetime) -> str | None:
130130
"""Render one moment as the canonical fixed-width UTC instant.
131131
132132
A naive moment is read as UTC rather than refused. That is the house convention for
133133
every other naive datetime in the codebase, and it is what lets an author write
134134
`2026-07-27T18:42:00` without learning RFC 3339's offset syntax first.
135+
136+
None means the moment has no UTC rendering: shifting it by its offset carries it off
137+
the calendar, as `9999-12-31T23:59:59-05:00` does into year 10000. Reported the way
138+
`_next_calendar_day` reports its own edge -- each caller decides what running off the
139+
calendar means for it -- rather than raised, so the overflow can never escape as a
140+
bare `OverflowError` and fail a whole note's parse.
135141
"""
136142
if moment.tzinfo is None:
137143
moment = moment.replace(tzinfo=UTC)
138-
return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
144+
try:
145+
utc = moment.astimezone(UTC)
146+
except OverflowError:
147+
return None
148+
return utc.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
139149

140150

141151
def _canonical_instant(bound: str) -> str:
@@ -151,7 +161,12 @@ def _canonical_instant(bound: str) -> str:
151161
moment = datetime.fromisoformat(bound.upper())
152162
except ValueError as exc:
153163
raise TemporalQualifierError(f"not a valid timestamp: {bound!r}") from exc
154-
return _instant_value(moment)
164+
value = _instant_value(moment)
165+
if value is None:
166+
raise TemporalQualifierError(
167+
f"timestamp bound leaves the calendar when converted to UTC: {bound!r}"
168+
)
169+
return value
155170

156171

157172
def canonical_bound(bound: str, axis: TemporalRangeAxis) -> str:
@@ -478,12 +493,33 @@ def _date_data_parser(date_order: DateOrder) -> "DateDataParser":
478493
)
479494

480495

481-
def _calendar_span(lower: date, upper: date) -> TemporalRange:
482-
"""The half-open calendar period `[lower,upper)`."""
496+
def _next_month_start(year: int, month: int) -> date | None:
497+
"""The first day of the month after `year`-`month`, or None past the calendar's end.
498+
499+
Only December 9999 has no successor month; year 10000 is not a date `datetime` can
500+
hold. Reported as None for the same reason `_next_calendar_day` reports its own
501+
edge: the caller decides what running off the end of the calendar means for it.
502+
"""
503+
if month < 12:
504+
return date(year, month + 1, 1)
505+
if year == date.max.year:
506+
return None
507+
return date(year + 1, 1, 1)
508+
509+
510+
def _calendar_span(lower: date, upper: date | None) -> TemporalRange:
511+
"""The half-open calendar period `[lower,upper)`, unbounded when it runs to the end.
512+
513+
A period whose successor is off the calendar needs no upper end: nothing follows
514+
9999-12-31, so `[lower,)` holds exactly the days `[lower,successor)` would have. It
515+
is the same equivalence `TemporalRange` applies to an inclusive upper bound on the
516+
last date, and it is why December 9999 is a period this reader can express rather
517+
than one it fails on.
518+
"""
483519
return TemporalRange(
484520
axis=TemporalRangeAxis.DATE,
485521
lower=lower.isoformat(),
486-
upper=upper.isoformat(),
522+
upper=None if upper is None else upper.isoformat(),
487523
lower_inclusive=True,
488524
)
489525

@@ -533,24 +569,26 @@ def parse_authored_point(
533569
# the components `period` vouches for may be read off `moment`.
534570
match date_data.period:
535571
case "time":
572+
instant = _instant_value(moment)
573+
if instant is None:
574+
# A moment that leaves the calendar in UTC names no storable instant,
575+
# so it reads as no date at all -- the token stays content.
576+
return None
536577
return TemporalRange(
537578
axis=TemporalRangeAxis.INSTANT,
538-
lower=_instant_value(moment),
579+
lower=instant,
539580
lower_inclusive=True,
540581
)
541582
case "year":
542-
if moment.year >= date.max.year:
543-
# There is no January 1 after year 9999 to close the span with.
544-
return None
545-
return _calendar_span(date(moment.year, 1, 1), date(moment.year + 1, 1, 1))
583+
# The month after December is the following January 1 -- except at year
584+
# 9999, where there is none and `_calendar_span` leaves the span open at
585+
# `[9999-01-01,)`, which is still exactly that year.
586+
return _calendar_span(date(moment.year, 1, 1), _next_month_start(moment.year, 12))
546587
case "month":
547-
first = date(moment.year, moment.month, 1)
548-
next_month = (
549-
date(first.year + 1, 1, 1)
550-
if first.month == 12
551-
else date(first.year, first.month + 1, 1)
588+
return _calendar_span(
589+
date(moment.year, moment.month, 1),
590+
_next_month_start(moment.year, moment.month),
552591
)
553-
return _calendar_span(first, next_month)
554592
case _:
555593
# Day precision, and any coarser calendar period dateparser resolves to a
556594
# specific day ("last week"): the day it named, onward.

tests/markdown/test_temporal_qualifier.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,9 @@ def test_an_unknown_kind_with_an_unreadable_payload_is_left_alone():
697697
# A date that the calendar does not have.
698698
("- [decision] @effective[2026-02-30,) Use Redis.", "@effective[2026-02-30,)"),
699699
("- [decision] @2026-02-30 Use Redis.", "@2026-02-30"),
700+
# A moment that leaves the calendar once it is shifted to UTC.
701+
("- [decision] @effective[9999-12-31T23:59:59-05:00,) Use Redis.", "@effective["),
702+
("- [decision] @effective:9999-12-31T23:59:59-05:00 Use Redis.", "@effective:"),
700703
# Trailing junk: one broken token, not a qualifier plus content.
701704
("- [decision] @effective[2026-06-10,2026-07-27)x Use Redis.", "@effective["),
702705
],
@@ -710,6 +713,50 @@ def test_a_payload_that_does_not_read_as_time_stays_content(line: str, kept: str
710713
assert observation.content.startswith(kept)
711714

712715

716+
# --- One qualifier never costs the note its index ---
717+
718+
719+
def test_a_qualifier_at_the_end_of_the_calendar_does_not_fail_the_note():
720+
"""Whatever a qualifier says, the rest of the note still parses.
721+
722+
`@effective:9999-12` used to build `date(10000, 1, 1)`; the `ValueError` escaped
723+
`parse_authored_point` and `parse_temporal_qualifier` -- neither of which guards that
724+
call -- into the markdown parser, so *the whole document* failed over one qualifier:
725+
every other observation and relation on the page went with it. December 9999 is
726+
representable as `[9999-12-01,)`, so it files like any other period, and the
727+
instant beside it, which is not representable at all, is simply left as content.
728+
"""
729+
content = "\n".join(
730+
[
731+
"## Observations",
732+
"- [decision] @effective:9999-12 The cache layer will use Redis.",
733+
"- [decision] @effective:9999 The contract holds all year.",
734+
"- [decision] @effective[9999-12-31T23:59:59-05:00,) An unstorable moment.",
735+
"- [note] An ordinary observation that must still index.",
736+
"",
737+
"## Relations",
738+
"- relates_to [[Cache Layer]]",
739+
]
740+
)
741+
742+
parsed = parse(content)
743+
744+
month, year, unstorable, ordinary = parsed.observations
745+
[month_assertion] = month.temporal
746+
[year_assertion] = year.temporal
747+
assert str(month_assertion.valid_during) == "[9999-12-01,)"
748+
assert str(year_assertion.valid_during) == "[9999-01-01,)"
749+
assert month.content == "The cache layer will use Redis."
750+
assert year.content == "The contract holds all year."
751+
# Unreadable, so never peeled: the line keeps its exact text and reports nothing.
752+
assert unstorable.temporal == []
753+
assert unstorable.temporal_error is None
754+
assert unstorable.content == "@effective[9999-12-31T23:59:59-05:00,) An unstorable moment."
755+
# The rest of the note is what the crash used to take with it.
756+
assert ordinary.content == "An ordinary observation that must still index."
757+
assert [relation.target for relation in parsed.relations] == ["Cache Layer"]
758+
759+
713760
def test_qualifier_with_nothing_to_qualify_stays_content():
714761
"""Peeling it would leave an empty observation, which the plugin drops outright."""
715762
observation = _observation("- [decision] @effective[2026-06-10,2026-07-27)")

tests/test_temporal.py

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
of the codebase applies to naive datetimes.
1111
"""
1212

13-
from datetime import date, datetime, timedelta
13+
from datetime import datetime, timedelta
1414

1515
import pytest
1616

@@ -138,6 +138,25 @@ def test_timestamp_shaped_bound_on_a_date_that_does_not_exist_is_refused():
138138
canonical_bound("2026-02-30T10:00:00Z", INSTANT)
139139

140140

141+
@pytest.mark.parametrize(
142+
"bound",
143+
[
144+
"9999-12-31T23:59:59-05:00", # 10000-01-01 in UTC
145+
"0001-01-01T00:00:00+05:00", # year 0 in UTC
146+
],
147+
)
148+
def test_instant_bounds_that_leave_the_calendar_in_utc_are_refused(bound: str):
149+
"""Normalizing to UTC *moves* a moment, and the move can run off the calendar.
150+
151+
Refused as a `TemporalQualifierError` like every other unreadable bound, which is
152+
what makes it survivable: `datetime.astimezone` signals this with `OverflowError`,
153+
and an `OverflowError` is not a `ValueError`, so it slipped past every handler above
154+
-- failing a whole note's parse, or a whole search request, over one bound.
155+
"""
156+
with pytest.raises(TemporalQualifierError, match="leaves the calendar"):
157+
canonical_bound(bound, INSTANT)
158+
159+
141160
# --- TemporalPoint ---
142161

143162

@@ -359,16 +378,58 @@ def test_text_that_names_no_date_reads_as_nothing(written: str):
359378
assert parse_authored_point(written) is None
360379

361380

362-
def test_a_year_with_no_successor_is_unread():
363-
"""Year 9999 has no January 1 after it to close the span with."""
364-
assert parse_authored_point("9999") is None
365-
# The year before it still resolves, so the guard is the calendar edge, not 4 digits.
366-
assert parse_authored_point("9998") == TemporalRange(
367-
axis=DATE,
368-
lower=date(9998, 1, 1).isoformat(),
369-
upper=date(9999, 1, 1).isoformat(),
370-
lower_inclusive=True,
371-
)
381+
@pytest.mark.parametrize(
382+
("written", "literal"),
383+
[
384+
# The last year and the last month have no successor to close at, so the
385+
# canonical form for them is unbounded -- exactly as it is for an inclusive
386+
# upper bound on the last date.
387+
("9999", "[9999-01-01,)"),
388+
("9999-12", "[9999-12-01,)"),
389+
# The last day was always open-ended, like every other day.
390+
("9999-12-31", "[9999-12-31,)"),
391+
],
392+
)
393+
def test_periods_at_the_end_of_the_calendar_run_to_the_end_of_it(written: str, literal: str):
394+
"""Unbounded above loses no days: nothing follows 9999-12-31.
395+
396+
`[9999-12-01,)` holds exactly the days a closed `[9999-12-01,10000-01-01)` would --
397+
and year 10000 is not a date Python can build. Constructing it raised `ValueError`
398+
straight through `parse_authored_point` and `parse_temporal_qualifier` into the
399+
markdown parser, failing the *whole note* over one qualifier, which is the one thing
400+
the qualifier contract promises can never happen.
401+
"""
402+
span = parse_authored_point(written)
403+
404+
assert span is not None
405+
assert str(span) == literal
406+
assert span.is_empty is False
407+
408+
409+
@pytest.mark.parametrize(
410+
("written", "literal"),
411+
[("9998", "[9998-01-01,9999-01-01)"), ("9999-11", "[9999-11-01,9999-12-01)")],
412+
)
413+
def test_the_period_before_the_calendar_edge_still_closes(written: str, literal: str):
414+
"""The open upper end is the calendar's edge, not "four digits" or "December"."""
415+
span = parse_authored_point(written)
416+
417+
assert span is not None
418+
assert str(span) == literal
419+
420+
421+
def test_a_year_beyond_the_calendar_is_unread():
422+
"""Year 10000 is not a date at all, so the token names nothing and stays content."""
423+
assert parse_authored_point("10000") is None
424+
425+
426+
def test_an_authored_instant_that_leaves_the_calendar_in_utc_is_unread():
427+
"""The flexible reader has no bound to refuse, so it reads no date at all.
428+
429+
Its contract is None-for-unreadable, not an exception: `parse_temporal_qualifier`
430+
does not guard this call, so anything raised here fails the note.
431+
"""
432+
assert parse_authored_point("9999-12-31T23:59:59-05:00") is None
372433

373434

374435
# --- TemporalRange normalization ---

0 commit comments

Comments
 (0)