feat(core): add temporal qualifiers and time-aware search (SPEC-82) - #1426
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 417a355fe4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56ad933afb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56ad933afb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
2ce4bf5 to
4e1246c
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e1246cdd5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
4e1246c to
64cd016
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64cd0161a6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0180bdfcc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3f7079493
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85c83c5d99
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
ae493d3 to
06cce5f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06cce5f98d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bfafc9c067
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Observations may carry one authored temporal qualifier naming when a claim
applies in the world, and search gains explicit valid-time filtering over
it. Valid time and recorded time stay separate axes; recorded history waits
for SPEC-59 stable identity rather than shipping a half-version that makes
false historical claims.
Authored forms accept what dateparser reads, canonicalized into one portable
range model:
@effective[2026-06-10,2026-07-27) range literal, agents and precision
@effective:2026-07-27 point, role named
@2026-07-27 point, files on valid
A naive timestamp is read as UTC, matching the house convention rather than
enforcing an offset rule that exists nowhere else in the system. Date-only
input never acquires a time of day. Slash-formatted dates resolve under a new
date_order config setting. The only diagnostic is an unknown role: everything
else either reads as time or stays ordinary content, silently, because direct
file editing is a supported path and does not deserve warnings.
Storage is a portable projection table with scalar bounds, so SQLite and
Postgres share one logical contract and pass the same containment, overlap,
inclusivity, unbounded, and empty-range tests. Rows rebuild on every index
pass and die with the entity. Undated notes are unchanged when no temporal
filter is present, and excluded when one is.
Closes the Phase 1 and Phase 2 MVP of SPEC-82.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
Calendar dates are a discrete domain, so preserving authored bounds made the overlap predicate report false positives: (2026-01-01,2026-01-03) holds only Jan 2 and (2026-01-02,2026-01-04) holds only Jan 3, yet each raw endpoint lies inside the other. Date ranges now canonicalize to half-open [lower,upper) at construction, the way PostgreSQL normalizes daterange, so the existing predicate becomes correct without special cases. Instants are a continuous domain and are left alone. The authored token is preserved separately in source_text, so files still round-trip byte-exact. Also: the malformed-qualifier diagnostic interpolated a Path, which renders with backslashes on Windows and failed the assertion there. It now uses as_posix(), matching how entity.file_path, permalinks, and search rows all name files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
Multi-word dates now work by delimiting them explicitly:
- [decision] @occurred:"June 10, 2026" The cutover ran.
Inside quotes the author has marked where the date ends, so the
specific-day guard does not apply and dateparser's full vocabulary is
reachable — including relative and month-only forms whose unquoted
spellings are refused. Unquoted behavior is unchanged: one token, same
guards. The quoted opener must be tried before the bare-point pattern,
whose \S+ would otherwise capture the opening quote and read '"June'
as June. Only the double quote opens a value, matching the one existing
quote-aware scanner; an unterminated quote is reported rather than
raised, so one typo cannot fail a note's whole index.
A refused token now names the fix when the line looks like a truncated
date — a known kind and a following digit — and stays silent otherwise,
so prose is never nagged.
Naming: the effective/valid/occurred/due/mentioned family is now 'kind',
because that is what it says — what kind of time this is. Date-vs-instant
becomes 'axis', which is what the code already called it in prose. Both
names now describe what they hold. The migration is edited in place
rather than stacked, since SPEC-82 has not shipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
@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>
Two SPEC-82 paths turned a malformed temporal value into a plausible-looking answer rather than an error. **All-projects search reported "no matches" for an invalid filter.** With `search_all_projects=True`, each per-project leg converted the API's 400 on a bad `valid_at` / `valid_overlaps` / `time_kind` into a `# Search Failed` string, which the fan-out cannot tell from a project being unavailable: it logged it and skipped on. Every project skipped left an empty response that still reported `temporal_applied=True` -- a typo wearing the shape of a successful search. `search_notes` now parses the three filter strings once, before the fan-out begins, and raises naming the bad value. Per-project availability failures are still logged and skipped exactly as before; only client-side validation failures abort. The parser itself moves to `temporal.parse_temporal_filter`, so the tool's pre-check and `search_service.build_temporal_filter` can never disagree about what is well formed. **An impossible ISO timestamp was indexed as a different instant.** `@occurred:2026-13-01T10:00:00` fell through to dateparser, which reads it as 10:00 on the 13th of January and projected `[2026-01-13T10:00:00.000000Z,)`. Every reindex reproduced the same wrong instant. The date-only branch above it already took a strict path for exactly this reason; the canonical timestamp shape now takes the same one and is refused rather than reinterpreted, leaving the qualifier as ordinary observation content. Flexible spellings the canonical form does not cover (`2026-06-10 10:00 AM`, a timestamp with no seconds) still reach dateparser unchanged. Signed-off-by: phernandez <paul@basicmachines.co>
The strict guards added for the canonical `YYYY-MM-DD` and `T`-separated
timestamp shapes matched a token only when it was *exactly* one of them, so
every other ISO-shaped spelling still reached dateparser:
@occurred:2026-13 -> [2026-09-13,) (on 2026-09-01)
@occurred:"2026-13-01 10:00:00" -> [2026-01-13T10:00:00.000000Z,)
@occurred:2026-13-01T10:00 -> [2026-01-13T10:00:00.000000Z,)
dateparser reads the impossible month as a *day* and then fills the month it
never got from the current date. `2026-13` is the worst of these: the same note
projects a different date on every reindex day, so a query that matched it last
week can stop matching it today with nothing having been edited. Both were also
peeled off the observation content, so the wrong date replaced the author's text
rather than sitting beside it.
Extend the same strict-branch pattern one step further out: validate the ISO
calendar components a point *opens* with, and refuse the point when they name no
real date. Only the leading date is judged, so the flexible spellings keep
working -- `2026-06-10 10:00 AM`, `2026-06-10T14:00`, `2026-06`, `2026-1-5`,
`10/07/2026` and every natural-language phrase go on reaching dateparser
untouched, and an impossible *time* already read as no date at all.
Refusal is `None`, as in the neighbouring branches: the token stays ordinary
observation content, unindexed but still full-text searchable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
The ISO guard matched calendar components at a fixed two-digit width, so a run of the wrong width matched nothing at all and fell through to dateparser -- the one outcome the guard exists to prevent. `@occurred:2026-01-0100` came back as `[2026-01-01,2026-02-01)` and `@occurred:2026-0100` as the whole of 2026, so a slipped keystroke silently widened one day into a range nobody wrote, and every reindex projected it again. Match each component as an unbounded run and judge it, rather than bounding the run and letting a wider one escape. Width is what separates an author's shorthand from an author's typo: a month or a day is written with one or two digits, so `2026-1-5` stays a legitimate unpadded spelling while the `0100` in `2026-01-0100` is no day at all. Width is checked before conversion because `date` takes a C long and raises OverflowError -- not the ValueError the guard catches -- once a run of digits grows past it. Every flexible spelling still reads exactly as before: `2026-06-10 10:00 AM`, `2026-06-10T14:00`, `2026-06`, `9999-12`, `2026-1-5`, `2026/03/04`, `June 10, 2026`. The lookahead still ends the run at the first character that cannot continue a calendar date, so a date carrying a time is matched on its date part alone. Signed-off-by: phernandez <paul@basicmachines.co>
The ISO guard validated only the calendar prefix, so a token that was
well formed in front and malformed behind fell through to dateparser,
which drops a suffix it cannot use and answers with the date alone:
@occurred:2026-01-01T -> [2026-01-01,)
@occurred:2026-01-01Z -> [2026-01-01,)
@occurred:2026-01-01+14:00 -> [2026-01-01,)
The author reached for an instant and the index recorded a whole
open-ended day, peeled off the observation and re-derived identically by
every reindex.
This is the fourth cut at the same guard, and the three before it were
each too narrow in the same way: a bounded or prefix-only test lets a
wider malformed shape slip past, because "no match" means "not this
guard's business". So the fix is not another branch for the reported
suffixes. Naming reserved markers would have caught exactly those three
and missed `2026-01-01UTC`, `2026-01-01,` and `2026-01-01-`, and would
have missed the worse defect entirely: a stray character makes dateparser
abandon the ISO reading and re-guess the components under the configured
order, so `2026-06-10x` came back as *October 6* -- the date itself moved.
Judge the whole token instead. An ISO-shaped point has exactly two
halves, and both are now checked:
* The calendar head must name a real date. Its trailing `(?![\d-])`
lookahead is dropped, because that lookahead was the same hole on the
suffix side: `2026-01-01-` matched nothing at all and so skipped the
guard. A head that always matches leaves a remainder always judged.
* Whatever trails the head must be a time of day on that very date --
the only thing that can legally follow a complete calendar date.
The second test is stated on what the reader *returned*, not on what the
suffix looks like, and that is what closes the class rather than three
examples: any trailing text dateparser silently drops or reinterprets
fails it, anticipated or not. It also catches a shape nobody reported --
`2026-06 10:00` gave dateparser a clock but no day, and it filled the day
from *today*, so the note projected `2026-06-07` in March and
`2026-06-01` in September.
Because the rule asks the reader rather than parsing the suffix, no
readable spelling is lost, including ones no grammar would have admitted:
`2026-06-10 10:00 AM`, `2026-06-10T14:00`, `2026-06-10 14:00:00+02:00`,
`2026-06-10 noon`, `2026-06-10 2pm`, `2026-06`, `9999-12`, `2026-1-5`,
`2026/03/04`, `June 10, 2026` all read exactly as before. The strict
range-literal path already validated its bounds whole -- `[2026-01-01T,)`
was always a hard error -- so this brings the lenient path to the same
standard: the reader must account for everything the author typed.
Refusal is `None`, as in the neighbouring branches: the token stays
ordinary observation content, unindexed but still full-text searchable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
The previous commit closed the dangling-suffix hole by adding a fourth
rejection clause to `parse_authored_point`. That is the shape the file had
been drifting into, and `.agents/skills/pythonic-code/SKILL.md` names it
in its opening paragraph: describe what the program supports, rather than
start from a broad representation and grow a list of invalid combinations.
Four rounds of review had appended four such clauses -- exact
`_DATE_BOUND`, exact `_INSTANT_BOUND`, an ISO-component check, then the
trailing-text check -- and the reason each round found a new gap is
structural, not an oversight. Every clause tested a *shape*, and failing a
shape test meant "not my business", so the token fell through to
dateparser, which guesses. The next round then found another shape that
failed the test.
Model the positive space instead. An author writes a point in one of two
languages, and they come with opposite promises: ISO calendar syntax fixes
its own meaning and must be read literally; anything else (`June 10,
2026`, `2026/03/04`, `yesterday`) has no literal reading for a guess to
contradict, so the flexible reader is trusted with it. A token is
classified into that closed union once:
type _AuthoredPoint = _IsoDay | _IsoMonth | _MalformedIso | _FlexiblePoint
consumed with an exhaustive `match` and `assert_never`, following
`EntityVectorPreparePlan` and the other closed unions in this codebase.
What closes the class is that `_classify_authored_point` is *total*:
opening with ISO syntax settles the question, and the three ISO variants
are all a token can then be. There is no fourth answer and no
fall-through, so no ISO-shaped token can reach an unvalidated guess --
`2026-13-01`, `2026-01-0100`, `2026-0100`, `2026-01-01T`, `2026-01-01Z`,
`2026-01-01+14:00`, `2026-01-01-` and `2026-06-10x` are all just
`_MalformedIso`, with no clause of their own. A fifth shape has nowhere
to arrive.
Two variants carry their invariant in their type rather than in a check:
* `_IsoDay` holds the day the author wrote, so the flexible reader can
only ever supply the *clock* -- its answer is checked against that day.
* `_IsoMonth` has nowhere to put trailing text, which makes `2026-06
10:00` unrepresentable rather than merely rejected. That one mattered:
a clock reading needs a day to fall on, and dateparser filled the
missing day from *today*, so it read as June 7 in March and June 1 in
September.
Observable behavior is unchanged apart from the refusals the previous
commit introduced; the full unit suite (6552 tests) and the ten temporal
suites pass, and
`test_the_iso_guard_leaves_every_readable_spelling_to_the_lenient_reader`
is untouched and green.
The split also surfaced a real gap the old shared code path hid: the
flexible reader's instant branch had only ever been exercised by
ISO-shaped tokens, so nothing pinned `@occurred:"June 10, 2026 2pm"` or
`@occurred:"10/07/2026 14:00"`. Both are supported forms, and both are
now tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
`_INSTANT_BOUND` caps a fractional second at six digits and refuses a longer one rather than truncating it, because dropping digits would store a different instant than the author wrote. That refusal only ever governed the strict path. An over-precise point never matches `_INSTANT_BOUND`, so `_read_iso_day` fell through to the flexible reader, which truncated the fraction and answered with a time of day on the very day the ISO head names. Every check that path makes then passed: `@occurred:2026-01-01T10:00:00.1234567` and its quoted form both indexed as `2026-01-01T10:00:00.123456Z`, re-derived identically on every reindex, with nothing said to the author. The day check is what guards the flexible reading, and a truncated fraction sails straight through it -- the digits it drops were never in the answer to be checked. Judged on the author's text in `_classify_authored_point` instead, so both readers refuse the same token for the same reason, and for the same reason the calendar width rule already exists: a digit run wider than the syntax allows is a typo, not a shorthand. Six digits and fewer are untouched, so `14:00:00.5` and `.123456` still read; the range-literal path already refused these correctly. This also corrects an overstated claim in the classifier's own comment. It said none of the three ISO variants can reach the flexible reader, which is false: an `_IsoDay`'s trailing text is deliberately read by it, and that is what reads `2026-06-10 10:00 AM`. What the classifier actually settles for good is the calendar -- a head naming no date dies there and is never re-guessed. The trailing is fenced by two rules, one on what the reader returned and one on what the author wrote, and the comment now says so. A sweep of 216 ISO-time spellings (T/t/space separators, minute and second precision, Z/z and offset zones with and without a colon, fractions of 0/1/6/7/9 and 30 digits) reported 54 tokens whose stored instant disagreed with a literal reading of the text. All 54 were this one defect; after the fix the sweep reports none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
bfafc9c to
5782845
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cbb2ca7537
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A follow-up hole in the duplicate ordinal added by 3281318, and it put the original bug straight back for one shape of note. A line promoted to an observation by its hashtags alone carries no `[category]` prefix, so it reaches the writer as `None`, while an explicit `[note]` line beside it reaches it as `"note"`. The ordinal was counted on the value as given, so the two looked like different identities and both took ordinal 0. One flush later the column default landed and made them the same category, so they derived the same permalink, the permalink-keyed search index kept only the first, and the second observation's temporal assertion again addressed a row with no search projection: - @effective[2026-06-10,2026-07-27) The cache layer will use Redis. #infra - [note] @effective[2027-06-10,2027-07-27) The cache layer will use Redis. #infra Reproduced before the fix: both rows stored `category='note'` with `duplicate_index=0`, one search row, and the 2027 query returning nothing. Identity has to be computed on the value the row will *hold*, not the value it was handed. `observation_permalink_tail` now normalizes the category the same way the column does, and the default is named once as `OBSERVATION_DEFAULT_CATEGORY` and used by both, so the two cannot drift apart again. No permalink changes: the property already read a flushed row, where the category was always `"note"` -- only the ordinal was being counted on the pre-flush value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c93a92c90
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…them
`{"text": "cache", "valid_at": ""}` ran as an ordinary unfiltered search and
answered with the undated rows the filter existed to exclude, reporting itself as
a plain search the whole way. The three valid-time fields were tested for
truthiness wherever presence was meant, so a present-but-empty value read as
absence and the requested constraint was dropped in silence.
`valid_at`, `valid_overlaps` and `time_kind` are declared `Optional[str] = None`,
so "no valid-time filter" already has a spelling and it is not `""`. Every caller
in the tree is an MCP tool or typed client passing JSON, not a form encoder --
confirmed by grep across src, tests and test-int, which finds no caller or test
passing an empty or whitespace value for any of the three. So rejecting `""`
turns away no real caller; it turns away a caller who believes they applied a
filter and did not, which is the same silent-unfiltered failure the rest of this
PR has been closing.
`None` still means absent everywhere. The only change is that a present but empty
value is an error rather than a no-op, and presence is now tested against `None`
rather than truthiness so the two can never be confused again.
The rule lives once, in `reject_blank_temporal_value`, and is applied at both
places a value can enter:
* `parse_temporal_filter`, the parser every surface shares -- which is what makes
`search_notes` reject a blank before any project is searched, so the all-projects
fan-out cannot resurrect the silent-empty-result bug this PR already fixed once.
* the `SearchQuery` field validator, because `no_criteria()` runs first: a query
carrying only an empty `valid_at` would otherwise be turned away as having no
criteria at all, which is true of the value and false of the request.
Whitespace-only rejects too, and the message names the offending field and says
that omitting it is how you ask for no valid-time filter.
Five presence checks used the truthiness shape; all now test `None`:
`parse_temporal_filter`, `SearchQuery.has_temporal_filter`, the fan-out's
`temporal_requested`, the tool's `has_temporal_filter` span field, and the
post-construction field assignment in `search_notes`. Two truthiness tests are
deliberately left: the mixed `has_filters` aggregate, whose other operands are
lists, and the `valid_at and valid_overlaps` pair check, where letting the blank
diagnostic win over "not both" is the better message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 030b3c2f0e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
cbb2ca7 made a qualifier whose meaning moves with the clock unreadable, but left the instructions promising the opposite. `search_notes`' docstring still said "single-word relative dates (`@occurred:yesterday`) work as they are" and offered `@occurred:"2 days ago"` as a quoted example that files; the man page said the same in its prose and in two gotchas. That is worse than a stale comment. The docstring is the interface an agent programs against, so the promise produced the exact failure the fix closed from the other end: the agent writes `@occurred:yesterday`, the reader declines it silently, the line stays ordinary content, and every valid-time search omits it. Both now say relative dates are not accepted, name the spellings that are refused (`yesterday`, `"2 days ago"`, `"last week"`, a bare `March`), give the reason -- they name a different span on every index pass -- and say what to write instead. They also record that quoting does not rescue one, since quotes settle where a token ends rather than what a date means. Scoped to the authored valid-time axis only. `timeframe` on recent_activity, build_context and the posix tools still takes relative wording and is untouched: those ask about edit time, which really is relative to now. Both documents now say so explicitly, because the two axes reading differently is exactly the kind of thing a reader would otherwise assume was an oversight. Two tests pin the instructions against the behaviour, so the docs cannot drift back while the reader goes on refusing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: afea122934
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two ways a valid-time answer could still come back confidently wrong. **The duplicate ordinal shared a namespace with real content.** 3281318 spelled the second twin of `foo` as `.../foo/1`, which is a tail an author's own text can produce: an observation reading `foo/1` slugs to exactly that. Whichever of the two was indexed second lost its search row to the permalink-keyed index, and if that was the dated one its temporal assertion pointed at no searchable document and the window became unanswerable -- the failure the ordinal was added to fix, reintroduced by the ordinal. I had noted this collision as theoretical when the ordinal landed and judged it far-fetched; it is cheap to close properly, and "far-fetched" is not a property worth relying on. The ordinal now lives in a segment content cannot reach. `generate_permalink` replaces every character outside `[a-z0-9/-.]` (plus CJK) with a hyphen, in both its ASCII and CJK branches, so no generated tail can contain `~`; 60k fuzzed inputs produce none. `.../foo/~1` is therefore a namespace the ordinal owns rather than one it shares. No released permalink changes -- the only permalinks carrying an ordinal were introduced by 3281318 on this branch. **An all-projects valid-time search could confirm a filter that ran nowhere.** A server predating SPEC-82 accepts a valid-time query and answers unfiltered, so `SearchClient` refuses any response omitting `temporal_applied`. The fan-out catches that refusal, logs the project and skips it -- right for one unavailable project, wrong for a whole fleet of old servers, because the merged response then reported `temporal_applied: true` over zero results. The caller could not tell "no note asserts that window" from "the filter ran nowhere", which is precisely what the confirmation field exists to prevent. The fan-out now counts the projects that answered and refuses to confirm a filter none of them applied, raising one error that names the skew instead of returning an empty result wearing the shape of a successful filtered search. A partial failure is unchanged: legs that did answer honored the filter, so the merged rows are genuinely filtered and `total_is_exact` already reports the incompleteness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c6fa26129
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`_ISO_CALENDAR_HEAD` required digits immediately after the year's hyphen, so a
token that opened `YYYY-` and then went wrong matched it not at all and fell
through to the flexible reader -- the one outcome ISO syntax must never have.
That reader does not report failure; it re-guesses:
2026--01 -> [2026-01-01,2026-02-01) January, invented
2026- -> [2026-01-01,2027-01-01) the whole year, invented
2026--01-02 -> [2026-01-02,) a specific day, invented
2026-x01 -> [2026-10-01,) October 1st, found nowhere in the text
Every reindex reproduced these from the same markdown, so a slipped keystroke
became a confident valid-time assertion nobody wrote.
This is the recurring shape the classifier's own comment describes: a test whose
failure means "not my business", leaving the next reviewer to find another token
that fails it. The head still reads components, but the *opening* is now claimed
separately -- a four-digit year followed by a hyphen is a commitment to machine
syntax, and nothing else is spelled that way -- so such a token is judged as ISO
or refused, never handed on because the rest of it was too broken to parse. That
makes the classifier total in the way it already claimed to be.
A bare `2026` carries no hyphen and is untouched, as are `2026/03/04`,
`June 10, 2026` and every other spelling the guard test pins. `2026-W03` and
`2026-2027` are now refused rather than guessed at, which is the honest answer for
syntax this module does not implement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 193aea0221
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`@occurred:2026/13/01` with `date_order="YMD"` was indexed as `[2026-01-13,)`. Month 13 is impossible where YMD puts the month, so dateparser silently moved the 13 into the day slot and answered January 13 -- a date the configured order does not name and the author did not write, refiled identically by every reindex. A year-first numeric date is the one non-ISO shape whose meaning is fixed rather than guessed: the text plus `date_order` determine it exactly, leaving nothing to interpret. So a run the order cannot use where the author put it is a typo, not an invitation to try the other slot. This is `2026-13-01`'s disease in the one syntax the ISO classifier deliberately does not claim, and it is now held to the same standard: refused when the order names no date, and refused when the reader answers with a different date than the order names. Which token is malformed depends on the setting, which is why the check consults it rather than banning a shape. `2026/13/01` is refused under YMD and MDY and *read* under DMY, where it legitimately means day 13 of month 1; `2026/12/31` is the reverse. Both directions are pinned. Only the year-first shape is judged. With the year written last (`01/02/2026`, `10/07/2026`) dateparser abandons the configured order for its own fallback, so this module cannot say what the text should mean without reimplementing those heuristics -- and reimplementing them is how two readings drift apart. Those forms are left exactly as they were, across every order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83e6fe6fd7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
An observation whose qualifier carried more than ~4300 digits aborted the parse of the entire note. Python declines to convert a decimal string longer than `sys.get_int_max_str_digits()` into an int, dateparser converts the runs it finds without catching that, and the `ValueError` came straight back out of the reader and past the qualifier scan. That is the one failure mode worse than a wrong date. Everywhere else an unreadable qualifier costs its own token and nothing else -- the text stays as observation content and the line indexes as it always did. Here one bad token on one line silently cost every other observation on the page its indexing. Refused on the text before either reader sees it. Both of them reach dateparser -- `_read_iso_day` hands a trailing clock reading to the same parser the flexible path uses -- so `2026-06-10 <5000 digits>` crashed by the second route and a guard on the bare numeric form alone would have left it there. The cap is a run of 32 digits: far below the smallest limit the runtime allows (`str_digits_check_threshold` is 640, so no legal setting can bring the crash within reach) and far above the widest run a readable point can carry -- six digits of a fractional second, four of a year. So it separates "no date" from "real date" without ever being the rule that decides a readable token's fate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c1d03b3aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
6c6fa26 moved the duplicate ordinal into a `~`-marked trailing segment so an author's own text could not reach it. It cannot -- and that was only half the requirement. `ContextService.build_context` re-normalizes every memory:// URL through `generate_permalink` before resolving it, and normalization replaces `~` and strips the hyphen it leaves. So `.../redis/~1` was advertised and looked up as `.../redis/1`: the second twin could not be opened through the address a temporal result had just handed back, and on a note that also carries an observation whose content slugs to `redis/1`, the lookup resolved to that different observation instead. The two requirements are not independent, and nothing appended after the content can satisfy both. Content may itself contain `/`, so any trailing segment is a position content can also occupy; the only characters that escape that are exactly the ones normalization strips. A marker that survives content is erased by lookup, and one that survives lookup is reachable by content. The leading segment has neither problem. It is generated here rather than authored, and every tail opens with it literally, so no observation can produce `observations-2` however its category or content is spelled -- including the two shapes that make later segments unsafe, a numeric category (`- [2] note/redis` yields `observations/2/note/redis`) and a category carrying a slash. And it is ordinary lowercase-and-hyphen text, so normalization returns it unchanged. First observations are untouched, as before: only the second and later twins carry the ordinal, now as `.../observations-1/note/redis`. The test asserts the addresses are distinct *and* that each survives `generate_permalink` unchanged, which is the half that was missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f5880ac469
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
83e6fe6 validated only the year-first shape, on the reasoning that a year-last token has dateparser abandoning the configured order for its own fallback and that predicting it would mean reimplementing heuristics. That reasoning was too broad, and the scope-out hid a real bug: `MDY` and `DMY` both describe arrangements that *end* in the year, so for those the order applies as literally as it does with the year first. Only `YMD` cannot describe one. So `@occurred:13/01/2026` under MDY put 13 in the month slot, dateparser moved it to the day, and the qualifier was indexed as January 13 -- the same silent swap the year-first case already refused, in the shape that had been declared out of scope. All six combinations are now named explicitly and the reading is compared against the one the order states. Five are the order read literally. The sixth is not: `YMD` ends in no year, so the reader falls back to day-first, and that fallback is this module's behaviour too -- it was already pinned by the `10/07/2026` cases, so writing it down makes those the same rule rather than an exception to it. If dateparser ever changes that fallback the comparison fails and those tests go red, which is the failure mode to want. Newly refused, each an impossible slot rather than a shape: `13/01/2026` and `31/12/2026` under MDY, `01/13/2026` and `12/31/2026` under YMD, `01/13/2026` under DMY. Everything the guard test pins still reads, in every order. A two-digit year is still left alone. `03/04/26` states no arrangement this rule can name -- which run is even the year is the reader's call -- so there is no stated reading to hold it to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 37ef47f267
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
7c1d03b refused an oversized digit run in `parse_authored_point`, which is one of two ways into the reader. `names_only_a_calendar_period` -- the helper that decides whether a refused word-led token was the truncated head of a date worth quoting -- calls `_read_authored_point` by its own route, so it went on handing dateparser a run Python will not convert. A word-led token is what finds it, and the shape is worth naming: the point itself is refused perfectly safely, and then the truncation diagnostic asks the same question again and raises. `@occurred:x<5000 digits> 2026 statement` aborted the note from the *diagnostic*, so the token stayed content as intended and the page was lost anyway. Moved into `_read_authored_point`, which is the one function every reading passes through. Guarding the public entry was guarding a caller; guarding here is guarding the thing that can actually raise, and there is no third route to miss next time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2f71cedc9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
83e6fe6 and 37ef47f validated fully numeric dates against `date_order`, but wrote the shape as a literal `/`. Every other separator dateparser accepts went straight past the guard: with `date_order="YMD"`, `2026.13.01`, `2026 13 01`, `2026_13_01` and `2026\13\01` all reached the reader, which moved the impossible 13 out of the month slot and stored January 13 -- the exact projection the guard had just been written to refuse, in the spellings it did not enumerate. Year-last forms had the same hole: `13.01.2026` and `13-01-2026` under MDY. Enumerating punctuation is what produced a half-applied rule, so the pattern now describes the shape instead: `(\D)` matched twice by backreference. What makes a token fully numeric is that its runs are digits and whatever stands between them is not -- and a separator that has to be the same on both sides is the whole of it. Dots, spaces, underscores and backslashes are covered because they were never the point; the ISO `-` forms still never arrive here, since a `YYYY-` opening is claimed by the classifier first. Time-shaped tokens are unaffected: the patterns anchor on the whole token with a four-digit run at one end, so `10:00:00`, `2026-06-10T14:00:00` and `2026-06-10 14:00:00.5` are outside them, and `12:30:1990` reads as no date now exactly as it did before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 582d7afe8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`@occurred:2026-01-01T10:00:00.` was stored as `[2026-01-01T10:00:00.000000Z,)`.
dateparser discarded the dangling separator and answered with an instant the token
never named, refiled identically by every reindex.
The defect matters less than its shape. The calendar half of an ISO point has
always been judged strictly -- an ISO head either names a real date or the whole
point is `_MalformedIso`, with no path to the lenient reader. The time half never
got that treatment. It was fenced by one returned-value check plus a text rule per
defect as each was found: one for over-long fractions, one for wrong-width
calendar runs. A dangling separator was simply the next defect no rule happened to
name, and the third rule would not have been the last.
So the time portion is now asked the same question the calendar is: which language
is it written in? **Words mean a human spelling** -- `10:00 AM`, `2pm`, `noon`,
`at 14:00`, `14:00:00 UTC` -- which has no literal reading for a guess to
contradict, so the lenient reader keeps it, as the two-language contract requires.
**Digits and punctuation alone mean machine syntax**, which must match one ISO-time
grammar and then parse as a real time, or the whole point is `_MalformedIso`. `T`
and `Z` are the two exceptions: ISO's own markers, not words.
Testing the shape alone would not have worked, and that is worth recording: a rule
keyed on a `T`-or-space followed by a digit takes `10:00 AM`, `2pm` and
`14:00:00 UTC` with it, all three pinned by the guard test. What actually separates
the languages is that every malformed spelling which reached the reader carried no
letters at all.
Two checks became redundant and are deleted rather than kept alongside:
* `_OVER_PRECISE_FRACTION`, the `\.\d{7,}` text rule -- the grammar's `\.\d{1,6}`
refuses a seven-digit fraction for the same reason it refuses none at all.
* the `_INSTANT_BOUND` branch in `_read_iso_day`, which read only the one canonical
RFC 3339 shape -- strict parsing now covers every machine spelling, including the
second-less and `±HHMM` forms that branch never claimed.
`_INSTANT_BOUND` itself stays: it is the grammar for range-literal bounds, a
different surface with a stricter contract. The calendar width rule and the
`OverflowError` handling stay untouched.
Swept 336 ISO-time spellings under two clocks eight months apart, against an oracle
written out in the probe rather than imported: 78 disagreed with a literal reading
before, 0 after.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a959902c7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`2026-01-01T10:00:00+14:60` was stored as `2025-12-31T19:00:00Z` -- the instant `+15:00` names, not the one the token does. `datetime.fromisoformat` bounds an offset's *total* magnitude below 24 hours but does not bound its minutes field, so `+14:60` is carried into the hour and read as `+15:00`, and `+14:99` as `+15:39`. Both spellings have it (`+1460` too) and both signs. This one sits inside the strict path rather than falling past it. The new grammar admits the offset shape and then delegates validity to the parser, which is the right division of labour for every other component -- and wrong for this one, because the parser normalizes it instead of refusing. The delegation is now a checked claim rather than an assumption, and the check narrows the fix rather than widening it. `fromisoformat` *does* reject `+25:00`, `+9999`, hour 25, minute 60 and second 60; minutes of the offset are the only field it normalizes. And once minutes are held below 60, its total-magnitude check is exactly RFC 3339's `00-23` on the offset hour -- so bounding one field restores the whole rule, and the fields that were already refused are pinned by tests so the claim stays checked. The finding named three surfaces -- `valid_at`, explicit range bounds, and authored qualifiers -- and they reach this through two grammars: a bound is held to the canonical RFC 3339 shape, an authored point to the wider set of machine spellings people write. The grammars stay distinct because their contracts differ, but the validity question underneath is one question, so both now go through one `_iso_instant`, and the offset rule is stated once. A test asserts all three surfaces refuse it, which is what would catch the rule being restated in only one. RFC 3339 bounds each field rather than the real-world offset range, so `+23:59` still reads along with `+14:00`, `-12:00`, `+00:00`, `Z` and `+0530`. Swept 1995 ISO-time spellings under two clocks, now including out-of-range offsets and out-of-range time components: 48 disagreed with a literal reading before, 0 after. The probe's oracle needed correcting too -- it computed offsets without bounding minutes, which would have made it agree with the bug -- and now carries a note that `fromisoformat` must never be used as the oracle, since it is blind to both this defect and the fraction truncation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp Signed-off-by: phernandez <paul@basicmachines.co>
What
SPEC-82 Phases 1+2. Observations may carry one authored temporal qualifier saying when a claim applies in the world, and search gains explicit valid-time filtering over it. Recorded-time history is deliberately not implemented — it depends on SPEC-59 stable identity, and a half-version would make false historical claims.
search("cache layer", role=effective, valid_at=2026-07-28)returns only Memcached; at2026-07-01only Redis; with no temporal filter both compete under ordinary relevance, exactly as today.Authored forms
Role is optional in both; omitted, it files on
valid. The bracket form needs no separator (no role name starts with[/(); the point form needs:because a date can begin with a letter (yesterday).Accepts whatever
dateparserreads — already a dependency, already used for frontmatter dates atentity_parser.py:198. Precision is the meaning: a year is the year, a month is the month, a date is from that date onward.Decisions that changed during the build
Three product calls landed mid-implementation and were applied afterward (decision note):
utils.py,recent_activity.py). A rule enforced by one feature and nowhere else is an inconsistency, not a principle. Replaces acceptance criterion 10 with its inverse.date_orderconfig (YMDdefault) so slash-formatted dates resolve by documented preference rather than silent guess.Storage
Portable projection table with scalar bounds and inclusivity flags, so SQLite and PostgreSQL share one logical contract and pass the same containment, overlap, inclusivity, unbounded, and empty-range tests. Rows rebuild on every index pass and die with the entity — a derived projection, never canonical. Native PostgreSQL range columns remain a later optimization generated from these columns.
Guardrails worth noting
mayas May andv2as February; a bare@wordat the head of an observation is a mention or a version, and accepting it would file wrong valid time onto ordinary prose. Relative dates still work with an explicit role (@occurred:yesterday).Verification
ruff, format,
ty— clean. Full unit suite 6,222 passed, 41 skipped (real captured exit codes). 100% coverage ontemporal.py(178/178),temporal_qualifier.py(53/53),temporal_filters.py(38/38). The migration docstring's claim that full-text always drives temporal queries was corrected —has_temporal_filter()counts as criteria, so temporal-only searches are valid.Open question for review
Point precision currently means: year → the year, month → the month, date → from that date onward. That is right for
effective/valid(a state that begins and persists) but arguably wrong foroccurred/due(an event at a time) —@occurred:2026-06-10currently means "occurred from June 10 onward," so a query for June 20 matches it. Making the interpretation role-dependent (state roles open-ended, event roles bounded by the written precision) would resolve it. Flagged rather than decided, and it matters for the Moby Dick eval, which authors@occurredheavily.🤖 Generated with Claude Code
https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Accepted authored forms (measured, not asserted)
The qualifier is one whitespace-delimited token. dateparser tolerates trailing junk, so there is no signal for where a multi-word date ends — the vocabulary is not the limit, the token boundary is.
valid_during@occurred:2026-06-10[2026-06-10,)@occurred:2026-06[2026-06-01,2026-07-01)@occurred:2026-06-10T10:00:00[2026-06-10T10:00:00Z,)@occurred:03/04/2026[2026-04-03,)YMD·DMY ·[2026-03-04,)MDY@occurred:yesterday[2026-08-31,)@2026-06-10/@2026-06/@2026@effective[2026-06-10,2026-07-27)@occurred:June 10, 2026@occurred:2 days ago@may@v2@1@3.5@yesterday@asserted:2026-06-10The reader (
parse_authored_point) understands more than the grammar can delimit —June 10, 2026,10 June 2026,Jan 15, 2024,2 days ago,last weekall parse correctly. They are unreachable from an observation line only because the token cannot be delimited.A silent-truncation bug found while pinning this
Before this branch,
@occurred:June 10, 2026 The cutover ran.matched only@occurred:June, filed June 2026 as the assertion, and left10, 2026 The cutover ran.as the indexed content — silently valid and wrong. Two shapes that parse are now refused so a truncated read can never become a plausible assertion:1reads as January,3.5as March 5);June,may,v2).A word is taken only when it resolves to a specific day — locale-neutral, no hardcoded English month list. Multi-word dates now stay content whole and byte-identical rather than half-eaten.
Not added, deliberately: an explicit delimiter (
@occurred:"June 10, 2026") would make multi-word dates work with one regex alternative and no ambiguity. That is new authored syntax, so it waits for evidence that people actually reach for it — the same "defer until a real use forces it" rule applied to SPEC-89.Also in this branch
(2026-01-01,2026-01-03)holds only Jan 2 and(2026-01-02,2026-01-04)holds only Jan 3, yet each raw endpoint sits inside the other. Date ranges now canonicalize to half-open[lower,upper)at construction, exactly as PostgreSQL normalizesdaterange, which makes the existing predicate correct with no special-casing. Instants are continuous and untouched. The authored token is preserved insource_text, so files round-trip byte-exact.Path, which renders with backslashes on Windows. Nowas_posix(), matching howentity.file_path, permalinks, and search rows all name files.Contract suite verified on both dialects (441 passed locally, 158 under Postgres). 100% coverage on
temporal.py,temporal_qualifier.py,temporal_filters.py.