Keel specification¶
Version 0.34 · 2026-10-22 · Status: normative for the 0.34.x line; anything marked planned is not implemented yet.
This document is the contract. CLAUDE.md says how to work on the code; README.md says why Keel exists. If the code and this document disagree, one of them is wrong; open an issue.
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
1. Scope and terms¶
Keel is a context layer for the AI agents inside a product: a vertical SaaS, a consumer app, an internal tool. It turns domain events into a bitemporal context graph per tenant, declared by a cartridge, and exposes it to agents through five tools.
| Term | Meaning |
|---|---|
| Product | What runs Keel: a SaaS, a consumer app, an internal tool. Has its own database and its own tenants. |
| Tenant | The product's unit of data: one end-customer of a SaaS, a household, a team. Every graph is partitioned by tenant. |
| Event | A fact that happened in the product, with world time and source, in the standard envelope (§2). |
| Subject | The entity an event or fact is about, as <kind>:<id>. |
| Entity | A node in the graph: a kind, an id, a pointer (ref) to the system of record, and a few label attributes. |
| Relation | A typed, time-bounded edge between two entities. |
| Fact | A time-bounded (predicate, value) about a subject, with provenance. |
| Cartridge | The declaration of a domain: ontology, event catalog, handlers, rules, memory policy (§5). |
| Handler | Cartridge code that maps one event type's payload to store primitives (§4). |
| Scope | The sharing level of a fact: global, tenant, task, agent (§6). |
| Tool | One of the five operations agents use to read and write the graph (§7). |
2. Event envelope¶
Every event, from any adapter, MUST be an envelope with these fields.
| Field | Type | Req. | Meaning |
|---|---|---|---|
id |
string | no | The source's own id when it has one. Otherwise a content hash of tenant, type, subject, occurred_at, source and payload, so the same stream yields the same ids on replay and a duplicate is recognizable. Used as evidence on derived facts. |
tenant |
string | yes | Tenant key. Opaque to Keel. |
type |
string | yes | Dotted lowercase event type from the cartridge catalog, e.g. inspection.created. |
subject |
string | yes | <kind>:<id>. kind MUST be an entity kind declared in the cartridge. |
occurred_at |
datetime (tz-aware) | yes | When it happened in the world. Valid time. |
recorded_at |
datetime (tz-aware) | no | When the source recorded it: transaction time. When the event declares it, what its handler writes carries it (a replay rebuilds the same history, a late event keeps its own time); when it does not, it defaults to now (UTC) and the facts carry the time they are ingested. |
source |
string | yes | System of record or adapter, e.g. fleet.vehicles, webhook:telematics. |
payload |
object | no | Event-specific data. The core MUST NOT read it. Only handlers do. |
Rules:
- A naive datetime is UTC, wherever a time enters: the envelope (a naive datetime never reaches a handler), the store primitives and every time argument of the tools.
occurred_atMAY be in the past relative to earlier events (late-arriving data). Handlers MUST useoccurred_atasvalid_from, neverrecorded_at.- Envelopes are immutable. An error in an event is corrected by a later event or by an
annotatewithcorrected_by, never by editing the log. - Serialized form is JSON, one object per event. The reference development adapter reads one envelope per line (
.jsonl), ignoring blank lines and lines starting with#.
3. Graph model¶
The store holds three kinds of records, each partitioned by tenant. There is no record that spans tenants.
3.1 Entity¶
| Field | Type | Meaning |
|---|---|---|
tenant |
string | Partition key. |
kind |
string | Entity kind from the cartridge ontology. |
id |
string | Id within the kind. key = <kind>:<id>. |
ref |
string? | Pointer to the row in the system of record (<source>#<id> by convention). |
attrs |
object | Label attributes a tool needs to show the entity (plate, name). Never a copy of the row. |
upsert_entity creates or updates in place. Entities are not bitemporal; their history is carried by facts and relations.
3.2 Relation¶
| Field | Type | Meaning |
|---|---|---|
tenant, src, rel, dst |
string | Edge from entity key src to entity key dst of type rel. |
valid_from |
datetime | World time the relation started. |
valid_to |
datetime? | World time it ended. null = open. |
recorded_at |
datetime | When Keel learned it: the event's declared recorded_at, else when it was ingested. |
source |
string? | Provenance. |
changed_at |
datetime? | Bookkeeping: the ingest clock of the link or unlink, the cursor of relation_changes. |
closed_at |
datetime? | Transaction time of the unlink: when Keel learned valid_to (the event's declared recorded_at, else when it was ingested). as_known_at counts the unlink only from then. |
link is idempotent on an open edge with the same (src, rel, dst). unlink closes the open edge by setting valid_to and closed_at. Edges are never deleted.
3.3 Fact¶
| Field | Type | Meaning |
|---|---|---|
tenant, subject |
string | Partition and entity key. |
predicate |
string | What is asserted, e.g. depth_mm, status, damage:D2. |
value |
any JSON | The asserted value. |
valid_from |
datetime | World time the fact became true. |
valid_to |
datetime? | World time it stopped being true. null = open. |
recorded_at |
datetime | When Keel learned it: the event's declared recorded_at, else when it was ingested (or when annotate wrote it). |
source |
string? | Adapter or annotate:<by>. |
scope |
enum | global, tenant, task, agent (§6). Default tenant. |
evidence |
string? | Event id, row ref, photo URL or other pointer that justifies the fact. |
corrected_by |
string? | A person or agent id when the fact is a correction or an annotation. |
expires_at |
datetime? | Memory lifetime (not world time). Set from the scope's TTL, or to the close time when its task closes. |
closed_at |
datetime? | Transaction time of the close: when Keel learned valid_to. With recorded_at it makes "as known at" queries possible. |
changed_at |
datetime? | Bookkeeping, never a query time: the ingest clock of the fact's last write (open or close), the cursor of changes and the watermark. An event declaring an old recorded_at is still a new change. Not in tool answers. |
task |
string? | The job a task-scope annotation belongs to (annotate(task=)). |
idempotency_key |
string? | annotate(key=): a retried write with the same key returns this fact. A key is its author's: annotate stores it as annotate:<author>/<key>, so another author's key never finds or returns this fact. |
status |
string? | null = accepted (the truth rules and context.facts read); proposed = waiting for a decision; rejected = a decision, closed at once. |
trace |
string? | The caller's W3C traceparent and tool call id at write time (HTTP traceparent / X-Keel-Tool-Call headers, MCP _meta), so a Langfuse or LangSmith trace joins the audit export. |
cartridge |
string? | Annotations and consolidations: the cartridge they were made under, name@version#rules (Cartridge.ident; the hash covers the rules and the calendar, so a rule edited without bumping version still shows). To replay a decision under its rules, check out that cartridge and replay the log. Facts from events leave it empty: the event log is their record. |
Bitemporal rule. open_fact(subject, predicate, value, valid_from) MUST place the new fact in the predicate's history by valid_from: it closes the fact current at that instant (valid_to = valid_from) and, when a later fact already exists, closes itself at that fact's start. Arrival order does not change the result (§4). Facts that start at the same instant follow one another by recorded_at, then by evidence and value. recorded_at is the event's own when its source declares one, so the history is then the same in any arrival order; when the source declares none it is the ingest clock, which is when Keel did learn it, and two such facts at one instant follow the order they were ingested in. A source that can deliver out of order and has same-instant changes should declare recorded_at. A predicate's history is one slot per memory scope: a fact never closes one of another scope, so a private note does not end a shared diagnosis and a promotion leaves the narrower fact to its own lifetime. Task memory has one slot per task, agent memory one per writing agent, and with per_source=True (multi-valued predicates and proposals) one per author. Facts are never mutated or deleted by handlers.
Explicit closes. close_fact(subject, predicate, valid_to) is part of the predicate's history too: the store keeps it as a close mark (a row with source keel:close that no read returns) and it ends every fact of its predicate, status and scope (any author) that started before valid_to and still runs through it, including a fact that arrives later. With value=v it ends only facts whose value is v, so the end of one term never ends an overlapping later one. At one instant the close goes first: a close never ends a fact that starts at its own valid_to (closing a term and opening the next at one instant leaves the next open, even with the same value), so it never leaves an empty fact. As with open_fact, arrival order does not change the result. scope=None (every scope, how a decision settles proposals) closes only what is open at the call and takes no value. The store MAY physically drop facts whose expires_at has passed (consolidation, §8).
sequenceDiagram
participant H as Handler
participant S as Store
participant Q as Tool
H->>S: open_fact(tire:T-9001, depth_mm, 5.2, valid_from=Sep 10)
Note over S: depth_mm=5.2 valid [Sep 10, open)
H->>S: open_fact(tire:T-9001, depth_mm, 2.6, valid_from=Oct 3)
Note over S: close 5.2 with valid_to=Oct 3, append 2.6 valid [Oct 3, open)
Q->>S: facts(at=Sep 20)
S-->>Q: depth_mm=5.2
Q->>S: facts(at=now)
S-->>Q: depth_mm=2.6
Queries the store MUST answer:
- facts true at a world time
t:valid_from <= t < valid_to(or open). - facts between
sinceanduntil(overlap). - facts as known at a transaction time
t(facts(known_at=t)): recorded byt, with closes counted only whenclosed_at <= t(§7.6).
Values are for what a tool compares or shows. A long text (a comment body, a description, a document) belongs in the system of record: store a short label or a classification as the value and the pointer as evidence or in the entity's ref, not the text itself. The graph holds pointers and semantics (design rule 2).
3.4 Store protocol¶
Writes (the only write path; handlers call these and nothing else):
upsert_entity(tenant, kind, id, ref=None, **attrs) -> Entity
link(tenant, src, rel, dst, valid_from, source=None, recorded_at=None) -> Relation
unlink(tenant, src, rel, dst, valid_to, recorded_at=None) -> None
open_fact(tenant, subject, predicate, value, valid_from, **provenance) -> Fact
close_fact(tenant, subject, predicate, valid_to, status=None, scope="tenant", recorded_at=None, value=ANY) -> None
# status: only proposals, or only rejections; scope: only that scope's facts (None: every scope).
# The tenant default is what a handler means: it never closes agent or task memory.
# value: only facts with that value (§3.3, explicit closes).
recorded_at (on link, close_fact, and open_fact's provenance) is the transaction time to write, closed_at for a close; None is now. Handlers do not pass it: for an event that declares recorded_at, the Ingestor hands its handler a store that fills it in, so the whole event is learned at the source's time. Every write also stamps changed_at from the ingest clock (§3.2, §3.3).
open_fact provenance keywords: source, scope, evidence, corrected_by, expires_at, recorded_at, task, idempotency_key, status, trace, cartridge, and per_source (one history per author, §3.3). A store MUST reject a second fact with the same idempotency_key on a subject by raising DuplicateKey (carrying the fact already written), atomically, so two writers racing on one key write once; the SQL stores do it with a unique index.
Reads:
entity(tenant, key) -> Entity | None
facts(tenant, subject, at=None, since=None, until=None, known_at=None) -> [Fact]
entities(tenant, kind=None) -> [Entity]
relations(tenant, key, rel=None, at=None) -> [Relation]
search(tenant, text) -> [Entity | Fact]
changes(tenant, after) -> [Fact] # written (changed_at) after a watermark, oldest change first
watermark(tenant) -> datetime | None # optional: latest changed_at of facts and relations
relation_changes(tenant, after) -> [Relation] # optional: links and unlinks after a watermark (keel index)
facts_of(tenant, subjects, **filters) -> {subject: [Fact]} # optional: facts() for many subjects at once
entities_of(tenant, keys) -> {key: Entity | None} # optional: entity() for many keys at once
relations_of(tenant, keys, at=None) -> {key: [Relation]} # optional: relations() for many keys at once
Tools calls entities and changes on every store. known_at is passed only when a caller asks for transaction time, so a store without it still serves every other read. Global-scope memory is stored under the reserved tenant __global__, or <account>/__global__ for a tenant <account>/<t> (§6); a store needs nothing special for it. API keys (optional, for the HTTP transport): put_key(ApiKey), api_key(sha256) -> ApiKey | None, api_keys(account=None) -> [ApiKey] and revoke_key(id, at) -> bool; they are not tenant data, so forget and reset leave them. Accounts (optional, for the HTTP transport's quotas): put_account(Account) (insert or replace), account(id) -> Account | None and accounts() -> [Account], with rps, events_month, store and retention. Metering (optional): meter([(tenant, day, item, n)]) adds to daily counters and usage(since, until) -> [(tenant, day, item, n)] reads them; the Ingestor meters events applied for the first time after each commit (never on replay), and Tools(meter=) counts each tool call. Tools(clock=) sets what "now" is for reads without as_of and for the valid_from of what annotate writes: running_from(instant) starts a clock there and lets it run, for a demo or a test pinned to a day (keel serve --clock, KEEL_CLOCK). Retention (expires_at) and ingest keep real time.
Ingest bookkeeping (called by the Ingestor, never by handlers): seen(tenant, event_id) and remember(tenant, event_id), the dedupe behind at-least-once delivery; reset forgets them too. A store MAY offer transaction(), a context manager the Ingestor wraps each event or batch in. Maintenance (jobs and operators): expire(tenant, now), close_task(tenant, task, now) (expires a task's task-scope memory at once), reset(tenant), dump(tenant) (everything about one tenant, closed facts included; keel export writes it as JSONL), and, optionally, tenants() (every tenant with something stored, never a global partition; --all-tenants on the jobs) and backup(tenant) / restore(tenant, backup): everything a restore needs (rows as stored, close marks included, applied event ids, the retention cutoff) and writing it as it was into an empty tenant, behind keel export, keel import and keel replay --check. Erasure, optional but required by the Ingestor when the cartridge declares an event that forgets: tenant: forget(tenant) -> {entities, relations, facts, annotations, ingested} deletes, in one transaction, everything stored for the tenant (facts of every scope, annotations, dedupe marks) and leaves a tombstone (the tenant and the time); it is idempotent, keeps the first tombstone's time and refuses a global partition. forgotten(tenant) -> datetime | None reads the tombstone and forgotten_tenants() -> {tenant: datetime} lists them. Retention, optional but required by the Ingestor when the cartridge declares an event that forgets: before: forget_before(tenant, before) -> {facts, annotations, relations} deletes, in one transaction, the tenant's facts (annotations included) and relations with valid_to <= before, keeps open ones and entities, and keeps the cutoff (the latest wins); it is idempotent and refuses a global partition. cutoff(tenant) -> datetime | None reads it.
A store implementation MUST pass tests/test_core.py and tests/test_store.py unchanged. Run the suite against your own store with KEEL_STORE=your_pkg.module:factory uv run pytest tests/test_store.py, where factory is a zero-argument callable (or class) returning a fresh, empty store. Reference: keel/store/memory.py (in memory). Durable: keel/store/sql.py holds one plain-SQL implementation with the same filters as the reference, and keel/store/sqlite.py (one file, WAL) and keel/store/mysql.py (MySQL or MariaDB, mysql:// URL) are its dialects; all answer identically and the conformance suite runs on SQLite and MySQL in CI. A Graphiti-backed store remains planned for graph-native queries.
4. Ingestion¶
The ingestor reads envelopes from an adapter and dispatches each to the handlers registered for its type.
- The event log is the source of truth. Keel's own copy of the log (
Ingestor.log, opt-in withkeep_log=True) is a development convenience that grows with every event; in production the bus holds it. - Dispatch is deterministic: no network, no LLM, no randomness in the ingest path. Replaying the same log in the same order MUST produce an identical graph.
- An event with no handler is reported as unhandled; in strict mode it raises. A handler exception is reported with the event id and does not stop the stream, unless strict.
- An event is atomic: when its handler raises (not strict), whatever it wrote is rolled back and the event is not remembered, so it applies when it comes again with the handler fixed; the other events of its batch still land (the chunk is rolled back and applied again one event per commit). A store without transactions cannot roll back, and there the failed event's writes stay.
- An event whose subject is not the kind its catalog entry declares (
tire.depth_measuredabout aunit:) is refused before it is remembered: reported as an error, or raised in strict mode;keel validate --eventsfinds the same ahead of time. replay(tenant, events=None)wipes that tenant's event-derived subgraph (entities, relations, facts from handlers, dedupe marks) and re-dispatches its events from the external log given (the bus, the outbox table, a stream) or, without one, from the in-memory log (only kept withIngestor(..., keep_log=True); otherwise replay withouteventsis an error). It keeps annotations (facts whosesourceisannotate:...orconsolidate:..., closed history included): they are earned memory, not in the log, and the events land around them as they did the first time (a store'sreset(tenant, keep_annotations=True)). It is the recovery path, not the update path.- Ordering: adapters SHOULD deliver events in
occurred_atorder per subject, but the result MUST NOT depend on it for facts:open_factplaces a fact in its predicate's history byvalid_from, closing the fact current at that instant and closing itself at the next fact's start, so a late event lands in its slot instead of closing a newer fact backwards. Relations (link/unlink) still assume order per subject. - Transactions: when the store has them (the SQL stores do), each event is applied in one, so the handler's primitives and the dedupe mark land together (
rememberruns first, in the same transaction, and is one statement); a bulk caller passesbatch=to commit every n events (the CLI and the HTTP batch do), and a chunk that raises in strict mode is rolled back whole and redelivered whole. A streaming adapter is never buffered. - Batches: a store MAY offer
bulk(events), a context manager the Ingestor uses for chunks larger than one event. The SQL stores read the state of the chunk's subjects in a few statements, serve the handlers from memory, and write back in a few multi-row statements; the result is the same as event by event (the conformance suite checks it on every store, forwards and shuffled), about a thousand times fewer round trips. - Batch reads: a store MAY offer
facts_of,entities_ofandrelations_of, the same answers asfactsandentityfor many subjects.context(depth=1)reads its neighbours with them,search(rule=)its candidates in chunks, and a rule that follows a relation its neighbours: a few statements whatever the neighbourhood's size, where one per subject would be a round trip each on a networked database. Without them Tools reads one subject at a time. - Threads: a store shared by a server's threads MUST keep a transaction, its read cache and a batch in flight to the thread that opened them: another thread waits for it or reads committed state, and never writes into it (a write that joined a batch would be lost with its rollback). The MySQL store reads on one connection per thread, so reads never queue behind a writer.
- Erasure: an event whose type the cartridge declares
forgets: tenantruns no handler; the Ingestor callsstore.forget(tenant)in that event's transaction (a chunk holding one is applied event by event, never throughbulk) and lists what was deleted inIngestReport.forgotten. Every later event for a forgotten tenant, a late redelivery included, is skipped without being applied and counted inIngestReport.skipped, so nothing revives it, and a replay of it yields nothing. Keel deletes only what it stores: the external log (the bus, the outbox table) belongs to the product, and global memory (§6) is every tenant's and is kept. - Retention: an event whose type the cartridge declares
forgets: beforeruns no handler; the Ingestor marks it applied and callsstore.forget_before(tenant, event.occurred_at)in its transaction (never throughbulk), listing the cutoff and what was deleted inIngestReport.retained.replayapplies the tenant's kept cutoff again after rebuilding. Events are not skipped by the cutoff: one arriving later with an earlier valid time stays until the next retention run. - Duplicates: an event whose
idthe store has already applied for that tenant is skipped and counted inIngestReport.duplicates. With content-hash ids this makes at-least-once adapters and client retries safe; a source that can emit two identical events at the same instant MUST give them ids.
4.1 Adapters¶
An adapter is any iterable of envelopes. It MUST NOT interpret payloads.
| Adapter | Status | Notes |
|---|---|---|
| JSONL file | shipped | Development and tests. |
| Postgres table + polling | shipped | PostgresTableAdapter: a seq cursor in a file, so restarts resume; it moves only after the Ingestor commits (ack()). At-least-once. |
| Webhook (HTTP) | shipped | build_app: validates the envelope, ingests, returns the report; bearer token. |
| HTTP API and typed SDKs | shipped | POST /v1/events on keel serve --transport http, called directly or through events() in the generated clients (§7.7); one envelope or a list, the ingest report back. |
| Kafka | shipped | KafkaAdapter (keel.adapters.bus): offset committed after the Ingestor commits the batch (ack()). At-least-once; dead_letter for poison messages. |
| SQS | shipped | SqsAdapter (keel.adapters.bus): message deleted after the Ingestor commits the batch (ack()). Same semantics. keel consume --queue <url> runs it, a batch per transaction (--batch, default 100), retried one message at a time when it fails; a failed message stays for the queue's dead-letter queue, and a store that stops answering ends the process (exit 2). |
| Postgres CDC | shipped | PostgresCdcAdapter, keel cdc: logical replication with pgoutput read through SQL functions; each mapped row change is <kind>.created|updated|deleted, id pgcdc:<commit lsn>:<n>; the slot advances only after the store commits. |
4.2 Handlers¶
A handler is fn(event, store) -> None, registered for one event type with @registry.on("<type>"). It:
- MUST only call store primitives (§3.4).
- MUST use
event.occurred_atforvalid_from/valid_to. - SHOULD pass
source=event.sourceandevidence=event.idon every fact. - MUST NOT contain business definitions (what "critical" means). Those are
rules(§5.4). - SHOULD be about ten lines.
5. Cartridge format¶
A cartridge is a directory with cartridge.yaml and handlers.py.
5.1 cartridge.yaml¶
name: <slug> # required
version: <semver> # optional, recommended
entities: # required: kind -> spec
<kind>:
description: <text>
ref: <table.column> # optional, documents where `ref` points
relations: # optional: rel -> {from: <kind>, to: <kind>}
<rel>: { from: <kind>, to: <kind> }
<rel>: { from: <kind>, to: <kind>, symmetric: true } # recorded from either end, shown once (direction `both`)
events: # required: type -> spec
<type>:
subject: <kind> # required: the kind of event.subject
owner: <team> # recommended: who owns this event's shape
payload: { <field>: <hint>, ... } # the notation in §5.5; `| null` = optional
forgets: tenant # optional: this event erases its tenant (§4), or `before`: deletes what closed before it; it takes no handler
rules: # optional: name -> definition
<rule>:
applies_to: <kind> # optional; absent = applies to every subject
definition: <text> # human-readable, versioned with the cartridge
<param>: <value> # machine-readable parameters agents read
when: # optional: a condition the core evaluates in `context` (§5.4)
predicate: <predicate>
<op>: <operand> # leaf operators in §5.4; compose with all / any / not; follow relations with via
predicates: # optional: write policy per predicate, for annotations
<predicate>: { multi: true, description: <text> } # multi: one open value per author (the `by`, else the agent) instead of last-writer-wins; context lists conflicts
semantic: # optional: what `keel index` puts in each subject's document (§7.3)
kinds: [<kind>, ...] # absent: every kind
predicates: [<predicate>, ...] # what to include; or exclude: [<predicate>, ...]
attrs: true # entity attributes (default); false, or a list of names
relations: [<rel>, ...] # open relations: the other side's key and one label
labels: [name, nombre] # the attribute that labels the other side (first present wins); default: name, nombre, title, titulo, label, plate, model
lexical_weight: 0.3 # hybrid search: weight of the lexical leg (0 = dense only)
min_score: 0.4 # optional cosine floor; model-specific, calibrate with evals/semantic
split: { <kind>: [<predicate>, ...] } # also one document per open fact of these predicates, and per element of a list value (each field of a visual), not only one per subject
consolidate: # optional: episodic memory summarized into durable facts (§8)
<name>:
from: { predicate: <p>, scopes: [task] } # default [task]; agent memory is read only when listed
applies_to: <kind> # optional
within: 30d # optional window on valid_from (world time)
min_count: 3 # default 2
reduce: mode # mode | latest | count | min | max | avg | distinct | summary
into: { predicate: <q>, scope: tenant }
context: # optional: how much `context` carries (§7.1), per relation and direction
relations: 10 # relations listed, the most recent; the rest counted in relations_more (default 10)
neighbors: 5 # neighbours read with depth=1, the most recent; the rest counted in neighbors_more (default 5)
calendar: # optional: business days for `open_for_business_days` (§5.4)
weekend: [saturday, sunday]
holidays: [2026-12-25]
agents: # optional: the agent registry (§6.1). Absent = no registry, any caller
<id>:
description: <text>
kind: agent # or human: a person, signed human:<id> (§6.1)
writes: [task, agent] # scopes it may annotate; never global; absent = [task, agent]
reads: { kinds: [<kind>, ...], relations: [<rel>, ...] } # optional; absent = everything
memory: # optional; defaults in §6
ttl:
global: never | forever | <n>d | <n>h | <n>m | <n> # a bare number is days
tenant: never | ...
task: 14d
agent: 1d
requires_evidence: [global, tenant]
evidence: [event, "photo:", "human:"] # optional; what counts as evidence there. Absent: any non-empty string
5.2 handlers.py¶
MUST define registry = HandlerRegistry() at module level with one or more @registry.on(...) functions.
5.3 Validation (keel validate)¶
A cartridge is valid when:
- every relation's
fromandtoare declared entity kinds; - every event's
subjectis a declared entity kind; - every cataloged event type has at least one handler, except one that
forgets, which has none (forgetstakestenantorbefore); - every handler's event type is in the catalog;
- every rule's
whennames apredicateand uses only the operators in §5.4; - every agent's
kindisagentorhuman, itswritesare known scopes other thanglobal, and itsreadsname declared kinds and relations; - every event's payload hints use the notation in §5.5;
predicates,semantic,consolidateandcontextuse only known keys, with values in range (a weight between 0 and 1, a known reducer, a known scope, a whole number of at least 1);- every
vianames a declared relation with a validdirection, and duration operators (open_for_*,held_for_*) take a comparison mapping.
keel validate <cartridge> --events <stream.jsonl> also checks every envelope in the stream against the catalog: the type is cataloged, the subject's kind is the event's subject, and each declared payload field is present with its declared shape (§5.5; | null makes it optional). It looks at payload keys and types, never at values; it is the one place the core looks at a payload at all.
5.4 Rules (semantic layer)¶
Rules are authored by product, versioned with the cartridge, and returned by the context tool for the subject's kind. Agents MUST read definitions from rules rather than hard-code them. The core interprets two keys: applies_to and when. context evaluates every applicable rule that has a when at as_of and returns true, false, or null (undecidable). A rule without when (an aggregate, a count over time) is returned as a definition only and the agent applies it.
A when is a condition:
- Leaf:
{ predicate: <p>, <op>: <operand>, ... }. Operators on the subject's open fact for<p>:lt,lte,gt,gte,eq,ne,in(value in a list),within_days(a date predicate on or beforeas_of + n days, so dates already past count),open_for_daysandopen_for_business_days(how long the open fact has been open: a mapping oflt/lte/gt/gte/eq, e.g.{ gte: 7 }; business days skip the cartridgecalendarweekend and holidays),held_for_daysandheld_for_business_days(how long the leaf's comparison has held across consecutive facts of the predicate: a waiting clock that survives a change from one waiting status to another; without a comparison, how long the predicate has had any value without a gap).ci: truecompares strings case-insensitively. No open fact for the predicate:null. - Composition:
{ all: [...] },{ any: [...] },{ not: <condition> }. A bare list isall.nullpropagates unless another branch decides: afalseinall, atrueinany. - Neighbours:
{ via: <relation>, direction: out | in | any, any: <condition> }orall:. The condition is evaluated on each neighbour reached by that relation atas_of(directiondefaults toany; symmetric relations ignore it). No neighbours:anyisfalse,allistrue. This is how an inherited state is expressed:{ any: [ { predicate: status, eq: paused }, { via: relates_to, any: { predicate: status, eq: paused } } ] }.
Rules see the whole tenant's graph: they are the product's semantics, not the calling agent's view, so the agent registry's read slice does not apply inside a when.
5.5 Payload notation¶
The payload: mapping of an event is a schema in a short notation. Keel checks streams against it (keel validate --events) and prints it as JSON Schema draft 2020-12 (keel schema <cartridge>), which is what SDK templates read.
| Written as | Means | JSON Schema |
|---|---|---|
string, int, number, bool |
a scalar | type string / integer / number / boolean |
date, datetime |
an ISO 8601 string | type: string with format |
any |
anything | {} |
<hint> \| null |
optional and nullable | type: [<type>, "null"], not in required |
string[] (any scalar) |
array of scalars | type: array, items |
[pending, paid] |
enum of literals | enum |
[new, done, null] |
optional enum (null is a literal in the list; | null is not YAML here) |
enum with null, not in required |
[ { id: string, qty: int } ] |
array of objects | type: array, items: object |
{ source: string, score: number } |
nested object | type: object, properties, required |
Extra fields in a payload are allowed: the catalog documents what handlers need, not everything the source sends. An unknown hint is a validation error, so a typo never passes as any; [string] is rejected in favour of string[].
5.6 The generality test¶
Adding or changing a cartridge MUST require no change under keel/. The repository keeps four cartridges (fleet, crm, finance, clinics) and all MUST pass the test suite after any core change.
6. Memory scopes and policy¶
| Scope | Who reads | Who writes | Default TTL | Evidence required |
|---|---|---|---|---|
global |
every tenant's agents | a privileged process (a Tools bound to no registry agent; never an agent, never an event) |
never | yes |
tenant |
that tenant's agents | handlers (from events); agents/humans via annotate |
never | yes |
task |
calls that pass the fact's task (context(task=), history(task=)); a task fact with no task id is the tenant's |
agents via annotate |
14 days | no |
agent |
the agent that wrote it (a Tools bound to no agent id reads none) |
that agent | 1 day | no |
flowchart LR
g["global<br/>every tenant · never expires · evidence"] --> t["tenant<br/>one end-customer · never expires · evidence"] --> k["task<br/>agents on one task · 14 d"] --> a["agent<br/>one agent · 1 d"]
w["annotate(subject, predicate, value, scope, evidence, by)"] --> p{"MemoryPolicy.check_write"}
p -- "scope requires evidence, none given" --> e["PermissionError"]
p -- "ok" --> f["open_fact with scope, evidence, corrected_by=by<br/>expires_at = now + ttl"]
Rules:
- Policy is enforced in
Tools.annotateviaMemoryPolicy.check_write; the calling agent is not trusted to self-police. - A write to a scope that requires evidence without
evidenceMUST raisePermissionError. - Facts written by handlers from events are
tenantscope withevidence = event.id. - Promotion to a wider scope is a new
annotatewith evidence andby; the narrower fact keeps its own lifetime. expires_at = now + ttlat write time;nullwhen TTL isnever. Reads MUST exclude expired facts even before a job physically removes them.- Reads are enforced in
Tools, on every path that returns facts (contextand its neighbours, rule evaluation,historywith or without a subject, textsearch): anagentfact is returned only to a caller bound to its author (itssourceisannotate:<author>); ataskfact tied to a task only to calls passing that task; ataskfact with no task id to the whole tenant. Rules evaluate on the same view, so another agent's or another task's memory never decides a verdict. - Global memory lives in the reserved partition
__global__:annotate(scope="global")writes there and every tenant's reads merge it, so one fact is seen by every tenant. No event may carry that tenant and noToolsmay be bound to it. Over HTTP and MCP (bound to a tenant or per request) only the operator's secret or an account's--globalkey may write it:Tools(writes_global=False)refuses it below every transport. It is the one deliberate exception to invariant 1, and it holds only what a privileged process wrote. A tenant whose name has an account prefix (acme/t, given by the HTTP transport's API keys) reads and writesacme/__global__instead, which no other account sees; over HTTP only the account's global key writes it.
6.1 Agent registry¶
A cartridge MAY declare agents: (§5.1). Without it there is no registry: any caller, any scope with evidence, and an agent id passed to Tools is attribution only. With it:
- A
Toolsinstance MUST be bound to a declared agent (Tools(..., agent=<id>),keel serve --agent <id>). A missing or unknown id raisesPermissionErrorat construction. An id never contains:(human:anawould sign as the personana):keel validatereports it and binding to it is refused. Agents read only the kinds and relations their entry allows, and a bare-id hint ("did you mean ...?") and the MCPinstructionsname only those. - Writes.
annotateto a scope outside the agent'swritesraisesPermissionError.globalis never writable by an agent. The scope policy (evidence, TTL) applies after the registry check.sourceisannotate:<agent>;corrected_byisbywhen given (a human correcting through the agent), else the agent id. - People. An entry with
kind: humanis a person: it is enforced like any agent, and its annotations are signedannotate:human:<id>withcorrected_by = human:<id>. Approvals are writes by a declared person, so the audit export shows who approved. - Reads.
context,history,relatedandannotateon a subject whose kind is outsidereads.kindsraisePermissionError. Relations outsidereads.relations, or whose other end is a hidden kind, are left out ofcontextandrelated.searchdrops hits on hidden kinds. - Authentication is the host's job. Keel takes the agent id as given and enforces what that id may do; it never trusts the agent to police itself (rule 5).
7. The five tools¶
A Tools instance is bound to one cartridge, one tenant and, when the cartridge declares agents, one agent (§6.1). Cross-tenant access does not exist at this layer. These five names and argument orders are part of the public standard; adding a sixth is a spec change.
A wrong name is an error, not an empty answer: a subject without a <kind>: prefix (with a suggested key when one exists), a kind, relation, rule or scope the cartridge does not declare (with the closest name and the valid ones) and a time that is not ISO 8601 (with the expected format) raise ValueError. A list a tool cuts short says so in its first element, {"more": {...}}, with how many were shown, the total and how to read the rest (next); a caller iterating the list skips the entry that has more. Without as_of or as_known_at, context answers as of the current time rounded up to the next whole second, so the same question asked twice answers in the same bytes, and a write made earlier in that second is still read.
7.1 context(subject, as_of=None, depth=0, as_known_at=None, task=None, detail="full", exclude=None, only=None) -> object¶
subject may be a list: the answers come back in order, one per subject, in one call. exclude (a list of predicates) leaves those predicates out of facts, proposals and the neighbours' facts, for an evaluation in which the agent must not see the answer; rules still evaluate on the whole truth, so a verdict can reveal it. only (a non-empty list of predicates) is the opposite: just those predicates, in the same places, for the evidence a reviewer checks across many subjects without the rest of their facts; exclude still hides what it names. A list of subjects is read in a few statements (facts, proposals, relations and entities in batches), not one or more per subject. A search with a list of texts shares those reads too: each subject's facts are read once for every text, so checking many names against one subject's neighbours (search(names, kind=[...], related_to=s)) is one call.
facts holds accepted facts only. proposals (present when any) holds the open proposals; conflicts (present when any) maps each predicate that has more than one open value to those values and their authors, which happens for multi predicates (§5.1) written by several agents. detail="concise" returns the same answer for a prompt in about a third of the tokens: attrs, facts as {predicate: value} (a list of {value, by} for a multi-valued one), relations as {relation, direction, subject} (direction both for a symmetric relation, as everywhere else), evaluation, as_of, watermark, proposals and conflicts, and compact neighbours, plus since, {predicate: valid_from} of each current value (when it became true, without a history call); rule definitions are left out (their names are in evaluation).
What is true about a subject now, or at as_of (world time). detail="brief" (the cheapest, for an agent; the default over MCP and HTTP, §7.7) is concise with relations grouped as {relation: [subjects]}, an incoming one written <-relation. Relations are listed up to 10 per relation and direction (the cartridge's context.relations, §5.1), the most recent ones, ordered by relation, link time and key; past that, relations_more gives each one's relation, direction, shown and total, and related or search(related_to=...) reads the rest, so an account with hundreds of tickets does not put all of them in every answer. With depth=1 the answer also carries neighbors: for up to 5 open relations per relation and direction (the cartridge's context.neighbors), the most recent, the other subject's entity, open facts and rule evaluation, so one call brings a card and the cards it is linked to; neighbors_more counts the rest per relation and direction (relation, direction, shown, total), and past 50 neighbours in all, neighbors_truncated: true. A well-formed subject Keel knows nothing about (no entity, facts, relations or proposals) returns {subject, found: false, as_of, watermark} at any detail.
{
"subject": "unit:MXK-4821",
"entity": { "kind": "unit", "id": "MXK-4821", "ref": "fleet.vehicles#MXK-4821", "attrs": {...} },
"facts": [ ...open facts at now, expired excluded... ],
"relations": [ ...open relations at now, both directions... ],
"rules": { "<rule>": {...} }, // rules with applies_to == subject kind or absent
"evaluation": { "<rule>": true }, // true | false | null, for the rules above that carry a `when`
"as_of": "2026-10-14T18:41:07Z" // now, or the `as_of` asked for
}
7.2 history(subject=None, since=None, until=None, predicate=None, scope=None, as_known_at=None, task=None, changed_since=None, limit=50, detail="full") -> [Fact]¶
Facts over time, oldest first, including closed ones. predicate, scope and task filter (task returns only what that task wrote; without it, task memory tied to a task is not returned, §6); limit keeps the most recent n, still oldest first (0 or None: all); when it cuts, the first element is {"more": {shown, total, omitted, next}}. as_known_at returns the facts as Keel knew them then (§7.6). With no subject and since/until (and no changed_since), it returns the tenant's facts that started (valid_from) in that window, in world time, oldest first, with predicate and scope as filters: "what changed this week", "whose assignee changed"; each fact's evidence says which event set it. Every fact that replaced a value carries was, the value of the accepted fact of its predicate and scope that ended exactly when it started (null included), read from the subject's whole history even when the window leaves that fact out; a first value, a value after a gap, a proposal and a multi-valued predicate carry no was, so "changed" is the facts with was. changed_since keeps the facts written (opened or closed) after a watermark, by the ingest clock; with no subject it spans the tenant (subjects the caller may read), ordered by the time of the change: the "since my last run" read of an incremental agent. Each fact carries valid_from, valid_to, recorded_at, closed_at, source, evidence, scope, corrected_by, task. detail="concise" (the default over MCP and HTTP, §7.7) returns lean facts, about a quarter of the tokens: no null fields, no tenant, no transaction-time bookkeeping (recorded_at, closed_at, idempotency_key, trace, cartridge), scope only when it is not tenant, and subject only when no subject was given; world time and provenance stay. detail is "full" or "concise".
7.3 search(text="", kind=None (or a list), rule=None, limit=None, after=None, mode="text", min_score=None, related_to=None, via=None, detail="entity", depth=0, per_kind=None, exclude=None, as_of=None, where=None, as_known_at=None) -> [Entity | Fact]¶
where without text returns only the entities it keeps (with no text, no fact is a hit). as_of evaluates rules, where, neighbours and the facts returned at that instant (a text search's fact hits too: given as_of, only facts that held then; without it, every match in the history); as_known_at reads them as Keel knew them then (transaction time, §7.6; as_of defaults to it). detail="count" returns [{"count": n}]: the number of results, each subject once, with no cap and nothing listed (not with depth, after or per_kind), so "how many are blocked" costs a few tokens. A deployment may set a ceiling on one listing (Tools(max_list=n), keel serve --max-list, KEEL_MAX_LIST): a request for all (limit=0) or for more than the ceiling returns the ceiling and a leading {"more": ...} that says so; search, related and history take it, a count never does. per_kind adds up under the ceiling too, a negative limit or per_kind raises, and a list of subjects or texts holds at most the ceiling (1000 without one). Unset, there is no ceiling: a report or an export may want everything. Every time argument also takes "2026-10-03 08:00 UTC", as people and agents write times.
What each entity result carries (detail="brief", for an agent: the key, attrs, score and current facts as {predicate: value}, without tenant, kind, id or ref, which the key carries): detail="key" only the keys, as strings (a fact hit gives its subject; each key once; not with depth), the cheapest listing; detail="entity" (the default) the entity; "concise" also its current facts as {predicate: value}, "full" as facts, so a result needs no context call to be understood. depth=1 adds neighbors (relation, direction, subject, and their facts when detail asks for them; up to 50, then neighbors_truncated). Facts and neighbours of a page are read in a few statements whatever its size. per_kind=n returns the best n of every kind instead of one ranking (in semantic mode one ranking per kind, rule or kind narrowing the kinds; in text mode the text ranking cut per kind; not with after): with related_to for the scope, mode="semantic" and detail="concise", the context a task needs in one call. exclude leaves predicates out of what is returned and drops fact hits on them. as_of evaluates rule, walks related_to and reads the facts and neighbours at that instant (entities are not bitemporal; the semantic index is as of its last keel index). text may be a list: one result list per text, in order. kind may be a list: the listing covers those kinds, ordered by key (after is then a full key), a semantic ranking only them, and per_kind the best n of each (in text mode, the first n of each listing; one kind with per_kind in text mode is an error, that is limit).
where={name: value} keeps the entities whose attribute name, or current accepted fact name (at as_of), is value, or a list holding it; strings compare without case, and the whole value, not a part of it: search(kind="visual", where={"title": "Detalhe Ociosidade"}) finds a visual by its exact title, which a semantic search blurs. It applies in every mode, after the other filters; a name that exclude hides is an error. Each ranked entity says why it is a result: matched is the attribute or predicate whose value matched (or "key"), and snippet the part of that value around the match (up to 160 characters; of a list, the element). In text mode that is the substring; in semantic mode, the split document that ranked it (below) or else the field sharing most words with the query; nothing when no field does, and never a hidden predicate. A listing without text carries neither.
related_to (a subject key the caller may read) keeps only that subject's current neighbours, through relation via when given, in every mode: search(kind="card", rule="en_pausa", related_to="cliente:BAT", via="de_cliente") is the paused cards of one client in one call, walking the client's neighbours rather than the whole kind. after has one format, the key of the last entity received (tire:T-9002); an id alone is accepted when the listing has one kind. It pages the kind/rule listing only: with mode="semantic" or a plain text search it is an error, since those rank and page with limit.
With detail="brief" or "concise" a fact hit is a lean fact (§7.2), with its subject; with "entity" and "full" it is the whole fact (over MCP, "entity" returns lean fact hits too, §7.7). Without limit (and without per_kind), a text search returns its first 20 results and a kind/rule listing its first 50; when more exist, the first element is {"more": {shown, total, next}}, next saying how to continue (the after key for a listing). A given limit is honoured without a marker (0: the whole listing); semantic ranking keeps its default of 10. Every kind must be declared, and rule must exist with a when (§7). A plain text search (no kind, no rule) of more than one word that nothing contains whole, and mode="semantic" with text on a server without an embedder and index, rank subjects instead by how many of the query's words (three letters or more, common stop words left out) their key, attributes or current facts contain, then by key; related_to, via, kind and rule still filter, and the list leads with {"note": ...} saying the ranking changed.
Every entity the tools return (here, in context.entity, in neighbors, and in keel export) carries key, the <kind>:<id> that every other call takes and that after expects.
Without kind and rule: case-insensitive substring match over entity keys and attrs, and fact predicates and values. With kind, or rule (whose applies_to gives the kind, and which must carry a when): the entities of that kind whose key, attrs or current facts' values match text (since 0.20; before, key and attrs only), and with rule only those where the rule evaluates true now, ordered by key; limit caps the page and after (the last key received) continues it. This is the work list of a sweep, a router or an orchestrator.
mode="semantic" ranks subjects by meaning: subjects whose id appears verbatim in the query come first (match: "key", score 1); then a dense leg (the embedded query against the vector index) and, when the index offers text_query, a lexical leg (BM25 over the same documents) are fused by a convex combination of min-max normalized scores, weighted by the cartridge's semantic.lexical_weight (default 0.3; 0 is dense only); then the optional reranker reorders the top candidates. The tenant, the agent's read slice, kind and rule always apply, inside the index when its query takes where; with related_to, where also carries subject (the neighbours' keys; a split document belongs to its subject), which an index MAY apply and Keel checks after retrieval either way. Each entity carries score and similarity (the cosine); min_score (or the cartridge's semantic.min_score) drops results below that cosine, and is model-specific. Default limit 10. Three adapter contracts make it pluggable (keel.semantic): VectorIndex (upsert, delete, query, clear, always by tenant, an index never returns another tenant's keys; optional where on query and an optional text_query), Embedder (embed(texts), optionally role="query"|"document" for asymmetric models) and Reranker (rerank(query, docs); bundled: the Cohere/Jina/Voyage /rerank shape and an LLM reranker over any /chat/completions). Keel ships MemoryIndex, ChromaIndex (extra chroma), WeaviateIndex (extra weaviate), OpenAIEmbedder (any /v1/embeddings API) and HashEmbedder (tests); any other is a pkg.module:factory?param=value spec, checked by tests/test_semantic.py with KEEL_VECTOR_INDEX / KEEL_EMBEDDER. The index is built by keel index, outside ingest (rule 10): one document per subject with its key, attributes, open accepted facts of tenant and global scope and the open relations the cartridge names, as chosen by the cartridge's semantic: section (§5.1); task and agent memory and proposals are never indexed; with semantic.split, each open fact of the listed predicates is also a document of its own (a list value, one per element), a hit on it ranks its subject (which keeps its best document, and says which predicate in matched and which value or element in snippet), and a changed value replaces its document; incremental from a watermark (subjects whose facts changed, and both ends of new links), --full rebuilds. A rule or kind filter never cuts the page: candidates are fetched in widening windows until limit results pass or the index runs out.
7.4 annotate(subject, predicate, value, scope="task", evidence=None, by=None, task=None, key=None, status=None, detail="full") -> Fact¶
Writes earned memory. Applies the agent registry (§6.1) and the scope policy (§6); refused for a forgotten tenant (§4). source is annotate:<agent, or by, or 'agent'> (annotate:human:<id> for a person); corrected_by is by, else the caller. task ties the fact to one job: history(task=) and context(task=) read it, and closing the task (store.close_task, keel expire --task) expires its task-scope memory at once (task on a tenant or agent fact is provenance: the fact keeps its scope's lifetime). key makes the write idempotent, for durable-execution engines that replay steps: the same key on the same subject returns the fact it wrote; the same key with a different predicate, value, task, status or scope raises. status="proposed" writes a proposal: it never touches accepted facts, waits in context.proposals, and needs only some write right (not the right on scope), so an agent can propose a tenant fact a person accepts; the evidence policy of scope still applies. An accepted write (no status) or status="rejected" is a decision: it needs the right on scope and closes the predicate's open proposals of that scope (a private or task note settles no tenant proposal); a rejection is recorded closed at once, in history only. Proposals are kept one open per author per predicate, in their own history beside the accepted facts; the author is the writer and the by it named together, so an agent naming a person with by= never takes that person's slot (the same holds for multi-valued predicates). A value is at most 64 KB serialized: a label and a pointer, never the document. Store close_fact takes status so handlers never close proposals. Returns the stored fact including expires_at, status, trace and cartridge (the cartridge's ident). detail="ack" (the default over MCP and HTTP, §7.7) returns only what was written where: subject, predicate, scope, valid_from, and valid_to, expires_at, task, status when set. detail is "full" or "ack"; an unknown scope raises with the valid ones.
7.5 related(subject, relation=None, as_of=None, depth=1, limit=50) -> [{relation, direction, subject, since}]¶
Open relations at now or at as_of, optionally filtered by type (one, or a list; each must be declared), with direction relative to the subject (out, in, or both for a symmetric relation, which is reported once even when the source recorded it from both ends). depth 2 or 3 keeps walking through the same relations, nearest first and each subject once: every entry then carries hops and via, the subject it was reached from, to which its direction is relative. Entries are ordered by hop, relation, link time (since, oldest first) and key, whatever the store's order. At most limit entries (0 or None: all), keeping the nearest hops and within them the most recent links; when more exist, the first element is {"more": {shown, total, omitted, by_relation, next}}, by_relation counting every entry per relation and next pointing at relation= or a search(related_to=, via=, limit=, after=) page.
7.6 Two time axes¶
as_of is world time: what was true then. as_known_at is transaction time: what Keel knew then, which is what an agent saw when it decided. A fact counts as known at t when recorded_at <= t. It ends where the next fact of its slot (same predicate, status and scope; per author for a multi-valued predicate or a proposal) known by t starts, or at a close learned by t (closed_at <= t, or an explicit close recorded by t), whichever is earlier: closes are rebuilt from what was known then, so a late fact that later moved a close never reopens the fact before it. Memory counts when it had not expired by t. context(as_known_at=t) defaults as_of to t, and also evaluates the rules on that view; relations count when recorded_at <= t, and an unlink only once closed_at <= t. Every context carries watermark, the tenant's latest write (by the ingest clock, so a late event with an old recorded_at is still past it), which is the cursor an agent stores for its next history(changed_since=), passed back exactly as received. Every time argument of the five tools takes a datetime or ISO 8601 text, in every transport. Entities are not bitemporal and are shown as they are now. Facts written before 0.9 have no closed_at; their closes count as always known.
7.7 Transport¶
The tools are plain functions first. keel serve exposes the same five over MCP (stdio, or streamable HTTP at /mcp with --transport http --tenant) and as a JSON API (POST /v1/<tool>, one process for many tenants, tenant and agent from X-Keel-Tenant and X-Keel-Agent behind a shared bearer secret or per-account API keys that prefix the tenant with the account). The names and semantics MUST NOT change per transport: annotate's value is any JSON over MCP, HTTP and in Python alike.
Defaults differ by caller. Over MCP and HTTP the caller is usually an agent, so context defaults to detail="brief", history to "concise" and annotate to "ack"; Python Tools and the SDK clients keep "full". Over MCP, search with detail="entity" (its default) also returns fact hits lean. The list caps of §7.2, §7.3 and §7.5 apply in every transport; the TypeScript, Go and PHP runtimes ask for every entry (limit 0) unless the caller sets one, and drop a leading {"more"} or {"note"} element so a list keeps its declared type.
Over MCP, a result is one text block of compact JSON (no indentation, a list in one block, not one per element), and structuredContent carries the same data (a list under result) for programmatic clients. Tools publish no outputSchema (it would only say "an object" or "a list", in tokens a client may hand the model on every call). Parameters with a closed set of values (detail, mode, scope, status) are declared as enums. Tool descriptions are shared with Tools.spec() (keel.tools.DESCRIPTIONS) and fixed, so a prompt cache holds them. A server bound to one tenant states once per session, in its instructions, what every call would otherwise repeat: the kinds, the relations (from->to) and each rule's applies_to and definition. Experimental, MCP only: detail="text" on context and history renders the brief context or the concise history as lines for the model (one fact per line with its time and provenance), with the JSON in structuredContent.
8. Jobs¶
- Expiry: drop facts past
expires_at, per tenant. Shipped:store.expire(tenant)on both stores andkeel expire <tenant> --db; the schedule is the operator's cron. Reads exclude expired facts regardless. - Consolidation:
keel consolidate(orkeel expire --consolidate, which consolidates before dropping, so nothing is lost unsummarized) runs the cartridge'sconsolidate:entries for a tenant: per subject, the matching episodic facts (predicate, scopes, kind, a window onvalid_from; expired-but-not-dropped ones count; agent memory only whenfrom.scopeslists it, since consolidating it promotes private memory) are reduced into one durable fact when there are at leastmin_count. Reducers are deterministic (modewith ties to the latest,latest,count,min,max,avg,distinct);summaryasks aSummarizeradapter (LLMSummarizerover any/chat/completions, or yours) for one sentence. The fact carriessource=consolidate:<name>, evidence naming the count, the span of theirvalid_fromand the originals' evidence, and an idempotency key over its inputs: the same inputs never write twice, new inputs write a new value that closes the old. Proposals are never consolidated, and the written fact carriescorrected_by=job:consolidate:<name>. The summarizer is chosen with--summarizer llm|join|pkg.module:factory;llmreadsKEEL_SUMMARIZER_BASE_URL,KEEL_SUMMARIZER_MODELandKEEL_SUMMARIZER_API_KEY. The originals are kept until their TTL. A job, never part of ingest. - Every tenant:
keel index,keel consolidateandkeel expiretake--all-tenantsinstead of one tenant and run overstore.tenants()one after another. - Erasure:
keel forget <tenant> --db --yes [--index]erases one tenant (§3.4) and prints what it deleted as JSON; with--indexits semantic documents go too.index_tenantclears a forgotten tenant's documents, andkeel index --all-tenantsdoes so for every tenant forgotten since its last run (its cursor file marks it as not yet cleared).annotateon a forgotten tenant raises.keel forget <tenant> --before <date> --db --yes(or--all-tenants) runs retention instead and prints one JSON line per tenant. - Replay: rebuild a tenant from the external log. Shipped:
Ingestor.replay(tenant, events=adapter)andkeel replay <cartridge> <stream> <tenant> --db.
9. Invariants (what a conformance test checks)¶
- No record spans tenants; every read is partitioned by tenant (global memory is read from its own partition, §6).
open_factnever overwrites: after two writes to the same predicate, both facts exist and the first is closed at the second'svalid_from.- A fact query
at=treturns exactly the facts valid att. - Replay produces a graph equal to the one built incrementally.
annotateto a scope inrequires_evidencewithout evidence raises; whenevidenceis declared, so does evidence that is neither an event id the tenant's log applied (event) nor a declared ref prefix; aneventmust have set a fact on the subject written about.task/agentannotations carryexpires_at;tenant/globalwithneverTTL do not.- The core imports nothing from
cartridges/orexamples/; every example cartridge validates and passes after any core change. - Ingest performs no I/O other than reading the adapter and writing the store.
- Ingesting the same event twice changes nothing, and ingesting the same events in any order yields the same facts.
- Scope reads hold (§6): agent memory reaches only its author, task memory only its task, global memory every tenant.
10. Non-goals¶
- Document or conversation ingestion (RAG memory). Keel can hold a pointer to such a store's output as a fact.
- Agent orchestration, planning or tool routing.
- A query language exposed to agents beyond the five tools.
- Cross-tenant analytics (do that on the product's warehouse).
11. Versioning¶
- The envelope, the cartridge format and the five tool signatures are versioned together as the Keel standard; breaking changes bump the minor version while pre-1.0.
- The public contract is what a caller builds on: the exports of
keel/__init__.py, the envelope, the cartridge schema (docs/cartridge.schema.json), the five tools in Python, over MCP and over HTTP (names, parameters, defaults, enums, read-only annotations), the store protocol (keel/store/base.py) and the Python client.tests/contract.jsonfreezes it andtests/test_contract.pyfails on any difference, telling an addition from a break. - Additions (a new optional parameter, a new enum value, a new field in an answer) ship in any minor version.
- Breaks (a removed or renamed parameter or tool, a changed default, a new required parameter, a removed enum value) need a deprecation first: the old form keeps working for at least one minor version, warns (
DeprecationWarningin Python; a{"note": ...}element or field over MCP and HTTP) and is named in the release notes with its replacement; only then may a release remove it, with--allow-breakingwhen refreshing the snapshot. Pieces marked experimental (MCPdetail="text") may change without a deprecation, still named in the release notes. - A cartridge declares its own
version; the core records which cartridge version built a tenant's graph (planned). - Store implementations are versioned independently; all MUST pass the conformance invariants (§9).