Keel specification¶
Version 0.1-draft · 2026-10-14 · Status: normative for the 0.0.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 AI agents inside a vertical SaaS. 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 |
|---|---|
| SaaS | The product that runs Keel. Has its own database and its own customers. |
| Tenant | One end-customer of the SaaS (the tenant of the tenant). Every graph is partitioned by tenant. |
| Event | A fact that happened in the SaaS, 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 Keel received it. Transaction time. Defaults to now (UTC). |
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:
- Naive datetimes MUST be rejected or coerced to UTC at the envelope; they MUST NOT reach a handler.
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. |
source |
string? | Provenance. |
link is idempotent on an open edge with the same (src, rel, dst). unlink closes the open edge by setting valid_to. 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. |
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. |
Bitemporal rule. open_fact(subject, predicate, value, valid_from) MUST first close any open fact with the same (subject, predicate) by setting its valid_to = valid_from, then append the new fact. Facts are never mutated or deleted by handlers. 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 learned by a transaction time (
recorded_at <= t) — planned; the in-memory store does not filter on it yet.
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) -> Relation
unlink(tenant, src, rel, dst, valid_to) -> None
open_fact(tenant, subject, predicate, value, valid_from, **provenance) -> Fact
close_fact(tenant, subject, predicate, valid_to) -> None
Reads:
entity(tenant, key) -> Entity | None
facts(tenant, subject, at=None, since=None, until=None) -> [Fact]
relations(tenant, key, rel=None, at=None) -> [Relation]
search(tenant, text) -> [Entity | Fact]
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), reset(tenant), dump(tenant) (everything about one tenant, closed facts included; keel export writes it as JSONL).
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) is a development convenience; 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.
replay(tenant, events=None)wipes that tenant's subgraph 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. 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. - 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. At-least-once. |
| Webhook (HTTP) | shipped | build_app: validates the envelope, ingests, returns the report; bearer token. |
| Kafka | shipped | KafkaAdapter (keel.adapters.bus): offset committed after the handler ran. At-least-once; dead_letter for poison messages. |
| SQS | shipped | SqsAdapter (keel.adapters.bus): message deleted after the handler ran. Same semantics. |
| Postgres CDC | planned |
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.6; `| null` = optional
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
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>
writes: [task, agent] # scopes it may annotate; never global
reads: { kinds: [<kind>, ...], relations: [<rel>, ...] } # optional; absent = everything
memory: # optional; defaults in §6
ttl:
global: never | <n>d | <n>h | <n>m
tenant: never | ...
task: 14d
agent: 1d
requires_evidence: [global, tenant]
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;
- every handler's event type is in the catalog;
- every rule's
whennames apredicateand uses only the operators in §5.4; - every agent's
writesare known scopes other thanglobal, and itsreadsname declared kinds and relations.
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.6; | 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 (cartridge rules, platform jobs) | never | yes |
tenant |
that tenant's agents | handlers (from events); agents/humans via annotate |
never | yes |
task |
agents working the same task | agents via annotate |
14 days | no |
agent |
that agent only | 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.
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. - 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. - 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.
7.1 context(subject, as_of=None, depth=0) -> object¶
What is true about a subject now, or at as_of (world time). With depth=1 the answer also carries neighbors: for each open relation (up to 50, then neighbors_truncated: true), the other subject's entity, open facts and rule evaluation, so one call brings a card and the cards it is linked to.
{
"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, since=None, until=None, predicate=None, scope=None) -> [Fact]¶
Facts over time, oldest first, including closed ones. predicate keeps one predicate; scope keeps one memory scope. Each carries valid_from, valid_to, recorded_at, source, evidence, scope, corrected_by.
7.3 search(text) -> [Entity | Fact]¶
Case-insensitive substring match over entity keys and attrs, and fact predicates and values. Planned: semantic search over the same set.
7.4 annotate(subject, predicate, value, scope="task", evidence=None, by=None) -> Fact¶
Writes earned memory. Applies the scope policy (§6). source is annotate:<by or 'agent'>; corrected_by = by. Returns the stored fact including expires_at.
7.5 related(subject, relation=None, as_of=None) -> [{relation, direction, subject, since}]¶
Open relations at now or at as_of, optionally filtered by type, 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).
7.6 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). The names and semantics MUST NOT change per transport.
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: summarize old
task/agentfacts into a durabletenantfact with evidence pointing at the originals. Not designed yet; see CLAUDE.md open decisions. - 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.
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.
- Ingesting the same event twice changes nothing, and ingesting the same events in any order yields the same facts.
annotateto a scope inrequires_evidencewithout evidence raises.task/agentannotations carryexpires_at;tenant/globalwithneverTTL do not.- The core imports nothing from
cartridges/; every reference cartridge validates and passes after any core change. - Ingest performs no I/O other than reading the adapter and writing the store.
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 SaaS'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.
- 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).