Skip to content

feat(core): add temporal qualifiers and time-aware search (SPEC-82) - #1426

Merged
phernandez merged 25 commits into
mainfrom
spec-82-temporal
Sep 2, 2026
Merged

feat(core): add temporal qualifiers and time-aware search (SPEC-82)#1426
phernandez merged 25 commits into
mainfrom
spec-82-temporal

Conversation

@phernandez

@phernandez phernandez commented Sep 1, 2026

Copy link
Copy Markdown
Member

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.

- [decision] @effective:2026-06-10 The cache layer will use Redis.
- [decision] @effective:2026-07-27 The cache layer will use Memcached; Redis dropped.

search("cache layer", role=effective, valid_at=2026-07-28) returns only Memcached; at 2026-07-01 only Redis; with no temporal filter both compete under ordinary relevance, exactly as today.

Authored forms

@[<role>]<range-literal>    @effective[2026-06-10,2026-07-27)   @[2026-06-10,)
@[<role>:]<point>           @effective:2026-07-27               @2026-07-27

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 dateparser reads — already a dependency, already used for frontmatter dates at entity_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):

  • A naive timestamp is UTC, not an error. The spec required explicit offsets; the rest of Basic Memory already coerces naive datetimes to UTC (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.
  • Authored form is a date, not a range literal. People date things; they do not write interval notation. Both forms canonicalize to the same stored range, so storage and the dual-dialect contract are untouched. The MVP is agent-facing and the docs say so.
  • One diagnostic only — unknown role. Direct file editing is a supported path and does not deserve format warnings: a token either reads as time or stays ordinary content, silently.
  • New date_order config (YMD default) 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

  • Date-only never acquires a time of day. Calendar dates and instants stay different kinds; a date query cannot match an instant range or the reverse.
  • A role-less point must start with a digit and be ≥4 characters. dateparser reads may as May and v2 as February; a bare @word at 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).
  • Undated notes are unchanged with no filter, and excluded when a valid-time filter is present.
  • No ranking change. No implicit recency boost anywhere.

Verification

ruff, format, ty — clean. Full unit suite 6,222 passed, 41 skipped (real captured exit codes). 100% coverage on temporal.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 for occurred/due (an event at a time) — @occurred:2026-06-10 currently 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 @occurred heavily.

🤖 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.

authored token accepted canonical valid_during kind
@occurred:2026-06-10 yes [2026-06-10,) date
@occurred:2026-06 yes [2026-06-01,2026-07-01) date
@occurred:2026-06-10T10:00:00 yes [2026-06-10T10:00:00Z,) instant (naive → UTC)
@occurred:03/04/2026 yes [2026-04-03,) YMD·DMY · [2026-03-04,) MDY date
@occurred:yesterday yes [2026-08-31,) date
@2026-06-10 / @2026-06 / @2026 yes day / month / year date
@effective[2026-06-10,2026-07-27) yes as written date
@occurred:June 10, 2026 no — stays content whole
@occurred:2 days ago no — stays content whole
@may @v2 @1 @3.5 @yesterday no — stays content
@asserted:2026-06-10 no — diagnostic (unknown role)

The reader (parse_authored_point) understands more than the grammar can delimit — June 10, 2026, 10 June 2026, Jan 15, 2024, 2 days ago, last week all 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 left 10, 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:

  • a bare number narrower than a year (1 reads as January, 3.5 as March 5);
  • a word naming only a month or a year (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

  • Discrete date ranges: 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 sits inside the other. Date ranges now canonicalize to half-open [lower,upper) at construction, exactly as PostgreSQL normalizes daterange, which makes the existing predicate correct with no special-casing. Instants are continuous and untouched. The authored token is preserved in source_text, so files round-trip byte-exact.
  • Windows fix: the malformed-qualifier diagnostic interpolated a Path, which renders with backslashes on Windows. Now as_posix(), matching how entity.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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T23:24:37.333045Z 79deca7 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/markdown/temporal_qualifier.py
Comment thread src/basic_memory/temporal.py Outdated
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/mcp/tools/search.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated
Comment thread src/basic_memory/repository/search_repository_base.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/markdown/plugins.py
Comment thread src/basic_memory/repository/temporal_filters.py
Base automatically changed from posix-meta-predicates to main September 2, 2026 06:31
phernandez and others added 10 commits September 2, 2026 01:31
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/schemas/search.py Outdated
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/mcp/tools/search.py Outdated
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/models/knowledge.py Outdated
Comment thread src/basic_memory/mcp/tools/search.py
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py
`_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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py
`@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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/models/knowledge.py Outdated
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/models/knowledge.py
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py
`@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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/basic_memory/temporal.py Outdated
`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>
@phernandez
phernandez merged commit 33b40e9 into main Sep 2, 2026
27 checks passed
@phernandez
phernandez deleted the spec-82-temporal branch September 2, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant