Skip to content
Back to skills

Temporal

ASecurity

Build a bitemporal memory: facts with validity intervals and belief time, point-in-time queries, invalidation instead of deletion

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 17, 2026
ai-agentspythongosqldebuggingapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 17, 2026

npx -y skills add ArttuAn/agent-memory-skills --skill temporal --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Temporal?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Temporal
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/arttuan-temporal/badge)](https://www.skillsdirectory.com/skills/arttuan-temporal)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: memory-temporal
description: "Build a bitemporal memory: facts with validity intervals and belief time, point-in-time queries, invalidation instead of deletion"
---

# Temporal Memory

Most memory systems have one implicit timestamp — *when we wrote the row* — and
answer exactly one question: what do we believe now. Temporal memory separates
two clocks and can answer two more:

- **Valid time** — when the fact was true *in the world*. Arttu lived in
  Helsinki from 2019 to 2024.
- **Belief time** — when *we knew it*. We learned about the Berlin move in
  September 2026, three months after it happened.

With both, "what did the agent believe in March, and was it right?" is a query
rather than an archaeology project. With only the write timestamp it is
unanswerable, and every agent incident review eventually needs the answer.

```
  valid time  ──────────────────────────────────────────────►
              │  Helsinki  ────────────────►│ Berlin ───────►
              2019                        2024            now
  belief time ──────────────────────────────────────────────►
              │  we believed "Helsinki" ───────────────►│ we learned "Berlin"
                                                      2026-09

  as_of(valid=2025-01, belief=2025-06) ──► "Helsinki"   (what we thought then)
  as_of(valid=2025-01, belief=now)     ──► "Berlin"     (what we now know was true then)
```

Those two queries returning different answers is the entire product. If they
never differ for your data, you do not need this skill.

## Use this when

- You must explain a past decision: "why did the agent say that in March?"
  Audit, incident review, compliance, or debugging a production agent.
- Facts have genuine validity periods — employment, pricing, configuration,
  ownership, versions — and questions are asked about past states.
- Corrections arrive late and must not rewrite history. Learning in September
  that something changed in June is the normal case, and an ordinary store
  handles it by silently destroying the record of what you believed in July.

**Do not use this when** you can just overwrite. Bitemporal modelling doubles
the size of every query in your head and every index in the schema, and most
systems genuinely only need "what is true now". `memory-semantic`'s
supersession — one `valid_to` column and a lineage pointer — is the 80% version
and is the right answer far more often than this skill is.

The honest test: write down two questions your system must answer that differ
only in belief time. If you cannot, use supersession.

## Workflow

1. **Ask which clocks matter.** Do you need valid time, belief time, or both?
   Many systems need valid time only (facts with periods, no need to reconstruct
   past beliefs) — that is *uni*-temporal and is half the complexity. Confirm
   before building both.

2. **Ask where valid time comes from.** Explicit dates in the source? Extracted
   from prose ("since last March")? Assumed to be the write time? This decides
   how much of the extractor you need.

3. **Scaffold the project** into the directory the user names. Kebab-case
   folder, snake_case package, `hatchling` + `pyproject.toml`, `src/` layout.

4. **Write the five modules**: `interval.py` (the time algebra), `fact.py`
   (bitemporal record), `store.py` (the schema and the three query forms),
   `assert_.py` (the write path that closes intervals), `resolve.py` (overlap
   handling). Then `cli.py`.

5. **Verify against the contract** in `references/evaluation.md`, plus the
   temporal-specific tests below.

## The decisions that matter

### 1. Half-open intervals, always

`[valid_from, valid_to)` — inclusive start, exclusive end. Not negotiable, and
the source of most bugs when violated.

With closed intervals, a fact ending on the 5th and its successor starting on
the 5th both match a query for the 5th, and you get two contradictory answers
for one instant. With half-open, adjacent intervals tile the timeline exactly
once, and `valid_from == valid_to` is a clean empty interval rather than a
one-instant fact.

Use `None` for "still true" rather than a sentinel like `9999-12-31`. The
sentinel invites arithmetic on a date that does not exist and sorts correctly
only by accident.

```python
def contains(interval, t) -> bool:
    start, end = interval
    return start <= t and (end is None or t < end)
```

### 2. Belief time is set by the system, valid time by the world

A confusion worth stating explicitly because it causes real bugs:

- **Belief time (`asserted_at`) is never supplied by a caller.** It is the
  system clock at write. Letting callers set it destroys the audit property —
  the whole point is that you cannot retroactively change what you believed.
- **Valid time is data.** It comes from the source and can be in the past, the
  future, or unknown.

When valid time is unknown, **do not default it to now**. Store `valid_from =
None` and mark it `inferred`. A fact silently stamped with the write date
becomes a false claim about when something started, and it is indistinguishable
from a real one later.

### 3. Correction versus change — the distinction the schema must carry

Two things look identical in a naive store and mean opposite things:

| | What happened | What to do |
| --- | --- | --- |
| **Change** | The world changed. Helsinki → Berlin in 2024. | Close the old interval at the change date, open a new one. Both were true. |
| **Correction** | We were wrong. It was never Helsinki. | Mark the old assertion `retracted` in belief time. It was never true. |

A change writes `valid_to` on the old row. A correction writes `retracted_at`
and leaves `valid_to` alone. Get this wrong and "what was true in 2020" returns
a fact you know to be false, or "what did we believe in 2020" loses the belief
you actually held.

Both are non-destructive. **Nothing in this skill deletes.**

### 4. The three query forms

```python
current(key)                       # valid now, believed now - the common case
as_of(key, valid_at)               # what was true then, as best we know today
as_believed(key, valid_at, belief_at)   # what we thought then about then
```

The third is the expensive one and the reason the skill exists. Implement all
three; make `current()` the fast path with its own index, because it is 95% of
traffic and should not pay for the other 5%.

```sql
CREATE INDEX facts_current ON facts(scope, subject, predicate)
       WHERE valid_to IS NULL AND retracted_at IS NULL;
```

A partial index on the current slice keeps the common query cheap no matter how
much history accumulates.

### 5. Overlap policy, decided once

Two assertions on the same `(subject, predicate)` with overlapping valid
intervals. Three options, and you must pick one and enforce it in code:

- **Last-write-wins on overlap** — truncate the earlier interval to start where
  the later one begins. Simple, and correct when a later assertion means "and
  this is when it changed".
- **Reject the overlap** — refuse the write, surface the conflict. Correct when
  both sources are authoritative and a human must decide.
- **Allow overlap** — store both, return both, let the caller reconcile.
  Correct only when the predicate is genuinely multi-valued (`works_on` can have
  three simultaneous answers; `lives_in` cannot).

Mark predicates as single- or multi-valued in a small table. Applying
last-write-wins to a multi-valued predicate silently deletes true facts — the
second project someone works on truncates the first.

## Build it

```
<project>/
├── pyproject.toml
├── .env.example
├── README.md
└── src/<package>/
    ├── __init__.py
    ├── config.py        # predicate arity, overlap policy, db path
    ├── interval.py      # half-open interval algebra
    ├── fact.py          # the bitemporal record
    ├── store.py         # schema + current / as_of / as_believed
    ├── assert_.py       # the write path: close, open, retract
    ├── extract.py       # prose -> valid intervals (optional)
    └── cli.py           # assert / correct / current / as-of / history / timeline
```

### `interval.py`

```python
"""Half-open [start, end) algebra over epoch seconds. None = unbounded."""

from __future__ import annotations

Instant = float | None


def contains(start: Instant, end: Instant, t: float) -> bool:
    if start is not None and t < start:
        return False
    return end is None or t < end


def overlaps(a_start: Instant, a_end: Instant, b_start: Instant, b_end: Instant) -> bool:
    if a_end is not None and b_start is not None and a_end <= b_start:
        return False
    if b_end is not None and a_start is not None and b_end <= a_start:
        return False
    return True


def truncate(start: Instant, end: Instant, at: float) -> tuple[Instant, Instant]:
    """Close an interval at `at`. Returns an empty interval if `at` precedes it."""
    if start is not None and at <= start:
        return (start, start)                      # empty: start == end
    return (start, at if end is None else min(end, at))


def is_empty(start: Instant, end: Instant) -> bool:
    return start is not None and end is not None and start >= end
```

### `fact.py`

```python
from __future__ import annotations

import hashlib
import time
from dataclasses import dataclass, field


@dataclass
class TemporalFact:
    subject: str
    predicate: str
    object: str
    text: str = ""                       # the human-readable form the model reads

    # Valid time: when this was true in the world. Data.
    valid_from: float | None = None      # None = unknown, NOT "now"
    valid_to: float | None = None        # None = still true

    # Belief time: when we knew it. System-controlled, never caller-supplied.
    asserted_at: float = field(default_factory=time.time)
    retracted_at: float | None = None    # set when we learn it was never true

    scope: str = "default"
    source: str = "user"
    confidence: float = 0.9
    valid_from_inferred: bool = False    # True when valid_from was not in the source
    supersedes: str | None = None
    id: str = ""

    def __post_init__(self):
        if not self.id:
            payload = (f"{self.scope}\x1f{self.subject}\x1f{self.predicate}\x1f"
                       f"{self.object}\x1f{self.asserted_at}").encode()
            self.id = "tf_" + hashlib.blake2b(payload, digest_size=8).hexdigest()
        if not self.text:
            self.text = f"{self.subject} {self.predicate.replace('_', ' ')} {self.object}"

    @property
    def key(self) -> tuple[str, str, str]:
        return (self.scope, self.subject, self.predicate)
```

### `store.py`

```python
"""The schema, and the three query forms. Nothing here deletes."""

from __future__ import annotations

import sqlite3
from pathlib import Path

from .fact import TemporalFact
from .interval import contains

SCHEMA = """
CREATE TABLE IF NOT EXISTS facts (
    id                  TEXT PRIMARY KEY,
    scope               TEXT NOT NULL,
    subject             TEXT NOT NULL,
    predicate           TEXT NOT NULL,
    object              TEXT NOT NULL,
    text                TEXT NOT NULL,
    valid_from          REAL,
    valid_to            REAL,
    asserted_at         REAL NOT NULL,
    retracted_at        REAL,
    source              TEXT NOT NULL DEFAULT 'user',
    confidence          REAL NOT NULL DEFAULT 0.9,
    valid_from_inferred INTEGER NOT NULL DEFAULT 0,
    supersedes          TEXT
);
CREATE INDEX IF NOT EXISTS facts_key ON facts(scope, subject, predicate);
CREATE INDEX IF NOT EXISTS facts_current ON facts(scope, subject, predicate)
       WHERE valid_to IS NULL AND retracted_at IS NULL;
CREATE INDEX IF NOT EXISTS facts_belief ON facts(scope, asserted_at);

CREATE TABLE IF NOT EXISTS predicate_arity (
    predicate TEXT PRIMARY KEY,
    multi     INTEGER NOT NULL DEFAULT 0
);
"""


class TemporalStore:
    def __init__(self, path: str | Path):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.db = sqlite3.connect(self.path)
        self.db.row_factory = sqlite3.Row
        self.db.executescript(SCHEMA)

    def put(self, fact: TemporalFact) -> str:
        self.db.execute(
            "INSERT OR REPLACE INTO facts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
            (fact.id, fact.scope, fact.subject, fact.predicate, fact.object, fact.text,
             fact.valid_from, fact.valid_to, fact.asserted_at, fact.retracted_at,
             fact.source, fact.confidence, int(fact.valid_from_inferred), fact.supersedes),
        )
        self.db.commit()
        return fact.id

    def is_multi_valued(self, predicate: str) -> bool:
        row = self.db.execute(
            "SELECT multi FROM predicate_arity WHERE predicate=?", (predicate,)
        ).fetchone()
        return bool(row["multi"]) if row else False

    # --- the three query forms -------------------------------------------

    def current(self, subject: str, predicate: str | None = None, *, scope: str = "default"):
        """Valid now, believed now. The fast path."""
        sql = ("SELECT * FROM facts WHERE scope=? AND subject=? "
               "AND valid_to IS NULL AND retracted_at IS NULL")
        args: list = [scope, subject]
        if predicate:
            sql += " AND predicate=?"
            args.append(predicate)
        return [self._hydrate(r) for r in self.db.execute(sql, args)]

    def as_of(self, subject: str, valid_at: float, predicate: str | None = None,
              *, scope: str = "default"):
        """What was true at `valid_at`, as best we know today."""
        rows = self._by_key(scope, subject, predicate)
        return [f for f in rows
                if f.retracted_at is None and contains(f.valid_from, f.valid_to, valid_at)]

    def as_believed(self, subject: str, valid_at: float, belief_at: float,
                    predicate: str | None = None, *, scope: str = "default"):
        """What we believed at `belief_at` about `valid_at`. The reason this store exists."""
        rows = self._by_key(scope, subject, predicate)
        return [
            f for f in rows
            if f.asserted_at <= belief_at                                  # we knew it by then
            and (f.retracted_at is None or f.retracted_at > belief_at)     # not yet retracted
            and contains(f.valid_from, f.valid_to, valid_at)
        ]

    def history(self, subject: str, predicate: str, *, scope: str = "default"):
        """Every assertion ever made on this key, in belief order."""
        return sorted(self._by_key(scope, subject, predicate), key=lambda f: f.asserted_at)

    def _by_key(self, scope, subject, predicate):
        sql = "SELECT * FROM facts WHERE scope=? AND subject=?"
        args: list = [scope, subject]
        if predicate:
            sql += " AND predicate=?"
            args.append(predicate)
        return [self._hydrate(r) for r in self.db.execute(sql, args)]

    def _hydrate(self, row) -> TemporalFact:
        return TemporalFact(
            id=row["id"], scope=row["scope"], subject=row["subject"],
            predicate=row["predicate"], object=row["object"], text=row["text"],
            valid_from=row["valid_from"], valid_to=row["valid_to"],
            asserted_at=row["asserted_at"], retracted_at=row["retracted_at"],
            source=row["source"], confidence=row["confidence"],
            valid_from_inferred=bool(row["valid_from_inferred"]),
            supersedes=row["supersedes"],
        )
```

Note what `as_believed` does *not* do: it never looks at `valid_to` set by a
later correction, because `valid_to` is part of the row, not a separate belief
event. That is the one simplification in this implementation. If you need
"we believed in March that it ended in June, then in July we learned it ended in
May", you need a full six-column bitemporal model with belief intervals on every
attribute — which is genuinely what banks do, and almost certainly more than an
agent needs. Say so in the README rather than half-implementing it.

### `assert_.py`

```python
"""The write path. Three operations: assert, change, correct. None delete."""

from __future__ import annotations

import time

from .fact import TemporalFact
from .interval import is_empty, overlaps, truncate


class Asserter:
    def __init__(self, store, *, overlap_policy: str = "last_write_wins"):
        assert overlap_policy in ("last_write_wins", "reject", "allow")
        self.store, self.policy = store, overlap_policy

    def assert_fact(self, fact: TemporalFact) -> TemporalFact:
        """Belief time is ours. Never accept it from the caller."""
        fact.asserted_at = time.time()

        if self.store.is_multi_valued(fact.predicate):
            self.store.put(fact)                 # multi-valued: overlap is normal
            return fact

        existing = [
            f for f in self.store._by_key(fact.scope, fact.subject, fact.predicate)
            if f.retracted_at is None
            and overlaps(f.valid_from, f.valid_to, fact.valid_from, fact.valid_to)
            and f.object != fact.object
        ]

        if existing and self.policy == "reject":
            raise ValueError(
                f"{fact.subject}.{fact.predicate} already has an overlapping value: "
                f"{[f.object for f in existing]}. Resolve it explicitly."
            )

        if existing and self.policy == "last_write_wins":
            boundary = fact.valid_from if fact.valid_from is not None else fact.asserted_at
            for old in existing:
                start, end = truncate(old.valid_from, old.valid_to, boundary)
                if is_empty(start, end):
                    # The new fact starts at or before the old one: the old was never
                    # true as recorded. That is a correction, not a change.
                    old.retracted_at = fact.asserted_at
                else:
                    old.valid_to = end
                self.store.put(old)
            fact.supersedes = existing[0].id

        self.store.put(fact)
        return fact

    def correct(self, fact_id: str, *, reason: str = "") -> TemporalFact:
        """We were wrong: this was never true. Belief-time retraction, nothing deleted."""
        fact = next(f for f in self.store._by_key_any(fact_id))
        fact.retracted_at = time.time()
        fact.text = f"{fact.text}  [retracted: {reason}]" if reason else fact.text
        self.store.put(fact)
        return fact
```

The `is_empty` branch is the subtle one and worth reading twice: when a new
assertion starts before the old one did, the old interval truncates to nothing,
which means the old fact was never true as recorded. That is a correction
discovered through a change, and treating it as a change would leave a
zero-length interval in the store that matches no query and confuses every
later reader.

### `cli.py`

- `<command> assert "<subject> <predicate> <object>" [--from 2024-06-01] [--to ...]`
- `<command> correct <id> --reason "..."`
- `<command> current <subject>`
- `<command> as-of <subject> --at 2025-01-01 [--believed-at 2025-06-01]` — the
  command that demonstrates the whole point. Print both answers side by side
  when they differ.
- `<command> history <subject> <predicate>` — every assertion in belief order,
  with a timeline. This is the audit view.

Accept dates as ISO strings and print them as ISO strings. Epoch seconds in a
CLI make temporal bugs impossible to see.

## Failure modes

- **Closed intervals.** Two facts match one instant, queries return
  contradictions at every boundary, and the bug only appears on the exact dates
  in your fixtures. Half-open, everywhere, tested at the boundary.

- **Valid time defaulted to now.** A fact with no date in the source, stamped
  with the write time, becomes a false claim about when something started — and
  is indistinguishable from a real one afterwards. `valid_from = None` plus the
  `inferred` flag.

- **Caller-supplied belief time.** Someone adds `asserted_at` to the API "for
  backfill", and the audit property is gone: history can now be rewritten. If
  you genuinely must backfill, use a separate, clearly-named ingestion path that
  records the real write time in a second column.

- **Corrections modelled as changes.** "It was never Helsinki" recorded by
  closing the Helsinki interval leaves a period where the store still asserts
  Helsinki was true. `as_of` then returns a fact you know to be false.

- **Last-write-wins on a multi-valued predicate.** Someone works on two
  projects; the second assertion truncates the first. Silent data loss, and the
  hardest failure here to notice. Declare arity per predicate before the first
  write.

- **Retracted facts leaking into recall.** Every query path must filter
  `retracted_at`. Miss it in one code path and the agent asserts something you
  explicitly marked false. Test each of the three query forms separately.

- **The store never shrinks.** Bitemporal stores grow monotonically by design.
  That is correct, but it needs a stated retention policy for the history slice
  — archive assertions older than N years, keep the current slice forever — and
  a scheduled job that actually runs it.

## Required tests

All offline. See `references/evaluation.md` for the universal set; these are
mandatory:

```python
def test_as_of_and_as_believed_diverge(store, asserter):
    helsinki = TemporalFact(subject="arttu", predicate="lives_in", object="Helsinki",
                            valid_from=iso("2019-01-01"))
    asserter.assert_fact(helsinki)
    freeze(iso("2026-09-01"))
    asserter.assert_fact(TemporalFact(subject="arttu", predicate="lives_in",
                                      object="Berlin", valid_from=iso("2024-06-01")))
    assert store.as_of("arttu", iso("2025-01-01"))[0].object == "Berlin"
    assert store.as_believed("arttu", iso("2025-01-01"), iso("2025-06-01"))[0].object == "Helsinki"


def test_half_open_boundary_returns_exactly_one(store):
    put(store, "a", "p", "x", valid_from=iso("2020-01-01"), valid_to=iso("2021-01-01"))
    put(store, "a", "p", "y", valid_from=iso("2021-01-01"))
    assert [f.object for f in store.as_of("a", iso("2021-01-01"))] == ["y"]


def test_unknown_valid_from_is_none_not_now(extractor):
    fact = extractor.parse("arttu uses postgres")
    assert fact.valid_from is None and fact.valid_from_inferred is True


def test_belief_time_is_not_caller_supplied(asserter):
    fact = TemporalFact(subject="a", predicate="p", object="x", asserted_at=0.0)
    stored = asserter.assert_fact(fact)
    assert stored.asserted_at > 1_700_000_000


def test_correction_retracts_without_closing_validity(store, asserter):
    fact = asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="wrong",
                                             valid_from=iso("2020-01-01")))
    asserter.correct(fact.id, reason="never true")
    assert store.get(fact.id).retracted_at is not None
    assert store.get(fact.id).valid_to is None
    assert store.as_of("a", iso("2021-01-01")) == []


def test_change_closes_the_old_interval(store, asserter):
    asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="old",
                                      valid_from=iso("2020-01-01")))
    asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="new",
                                      valid_from=iso("2022-01-01")))
    old = [f for f in store.history("a", "p") if f.object == "old"][0]
    assert old.valid_to == iso("2022-01-01")
    assert old.retracted_at is None


def test_multi_valued_predicate_keeps_both(store, asserter):
    store.db.execute("INSERT INTO predicate_arity VALUES ('works_on', 1)")
    asserter.assert_fact(TemporalFact(subject="a", predicate="works_on", object="herdr"))
    asserter.assert_fact(TemporalFact(subject="a", predicate="works_on", object="gateway"))
    assert len(store.current("a", "works_on")) == 2


def test_reject_policy_surfaces_the_conflict(store):
    asserter = Asserter(store, overlap_policy="reject")
    asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="x"))
    with pytest.raises(ValueError, match="overlapping value"):
        asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="y"))


def test_retracted_facts_are_excluded_from_all_three_queries(store, asserter):
    fact = asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="x",
                                             valid_from=iso("2020-01-01")))
    asserter.correct(fact.id)
    assert store.current("a", "p") == []
    assert store.as_of("a", iso("2021-01-01")) == []
    assert store.as_believed("a", iso("2021-01-01"), time.time()) == []


def test_nothing_is_ever_deleted(store, asserter):
    fact = asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="x"))
    asserter.assert_fact(TemporalFact(subject="a", predicate="p", object="y"))
    asserter.correct(fact.id)
    assert len(store.history("a", "p")) == 2
```

## Verify

Follow `references/evaluation.md`. For this skill specifically:

1. **Tier 0** — install, import, `--help`, then assert a fact and read it back
   with `current`.
2. **Tier 1** — `pytest -q`, offline. Every test above, especially the boundary
   test — off-by-one at an interval edge is the defining bug of this skill.
3. **Tier 2** — a probe suite where at least five probes have *different*
   answers for `as_of` and `as_believed`. If none do, the bitemporal model is
   not earning its complexity, and the honest recommendation is `memory-semantic`
   supersession instead. Report that finding rather than shipping the complexity.
4. **Tier 3** — replay a real sequence of assertions and corrections, then run
   `history` and read the timeline. Every row should be explicable: what we
   learned, when, and what it changed.

Then tell the user what actually ran, including whether the `as_of` /
`as_believed` divergence probes actually diverged. That number is the honest
answer to "did we need this".

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.