Behavior Contracts¶
Applies to: CPersona 2.5.x. Statements here are verified against the source of the current release line. Behaviors documented on this page are contracts: callers may rely on them, and a change goes through the pre-release ladder and release notes (see RELEASE_LIFECYCLE_STANDARD) — it will not change silently.
This page collects the behaviors that are easy to assume wrong from the tool names alone. Several of them were surfaced by production operators measuring CPersona from the outside; where a behavior looks surprising, the rationale is stated next to it.
1. Recall return order: last is best¶
recall sorts candidates best-to-worst internally, cuts to limit, then
reverses the slice. The response is ordered by ascending score — the
final element is the strongest match.
This is deliberate. LLMs attend most strongly to the end of their context ("lost in the middle"), so the strongest memory is placed on the near side of the injection point.
Consequences:
- Evaluation: if you measure hit@k against a recall response, index from the tail. Measuring from the head inverts the result.
recall_with_contexthas a different contract: it merges recalled memories with the conversation history you pass in and returns a chronological merge, not a score ordering.
2. Confidence scoring overrides the fusion mode¶
CPERSONA_CONFIDENCE_ENABLED (default false) is not a metadata-only switch.
With it on:
- the result set is re-sorted by the confidence score, and
- the quality gate keys on confidence instead of the fused score.
The fusion mode (CPERSONA_RECALL_MODE=rrf|rsf|cascade) still selects which
candidates enter the result set, but no longer decides the order you get
back. Measured on a 1,545-document corpus with 394 queries: with confidence
on, rsf and rrf returned identical rows in identical order for all 394
queries; with it off, they agreed on fewer than 10%.
The ranking / gate signal priority chain is: confidence > rsf > cosine >
rrf — a scored row's match_reason.signal reports which branch actually keyed
for it. Rows that were never scored omit the key entirely: the injected profile
row, and the FTS / keyword rows a cascade recall fills with. Treat
match_reason as present-or-absent, not as a field on every row.
Note that confidence is not match strength: it blends cosine similarity, time decay, resolved status, and recall count into a separate quantity. An exact-match row can legitimately score below a paraphrase row on this scale.
3. Episode boundary penalty¶
When episodes exist, memories older than the latest episode boundary are multiplied by a decay factor:
factor = max(exp(-RATE × hours_before_boundary), FLOOR)
| Knob | Env var | Default |
|---|---|---|
| Enabled | CPERSONA_EPISODE_PENALTY_ENABLED |
true |
| Rate | CPERSONA_EPISODE_DECAY_RATE |
0.01 |
| Floor | CPERSONA_EPISODE_DECAY_FLOOR |
0.5 |
- The boundary is the latest episode's
created_at, scoped to the same isolation axes (agent / project / channel) as the query — an unrelated bucket's episode does not move your boundary. - Memories at or after the boundary (the current session) are untouched (factor 1.0).
- With the defaults, the factor reaches the floor after ~69 hours
(
ln 2 / 0.01); everything older than ~3 days is uniformly halved. The mechanism is a soft preference for the current session, not a fine-grained recency ranking — ordering decisions within the last few days are outside its resolution. (RATE=0.002stretches the ramp to ~2 weeks if you want a slower curve.)
Bulk-import hazard: the boundary is simply the newest episode row. If you
backfill historical conversations with archive_episode, the import time
becomes the boundary and every pre-existing memory falls into the penalized
region. Either do not backfill episodes, or set
CPERSONA_EPISODE_PENALTY_ENABLED=false for the import.
4. The vector scan window (CPERSONA_MAX_MEMORIES)¶
CPERSONA_MAX_MEMORIES (default 10000) is not a storage cap. It is the
vector retriever's scan window: vector search considers the most recent N
rows (memories and episodes are each scanned under the window). Rows older
than the window are invisible to vector search — but remain reachable through
the FTS and keyword channels, which are not window-limited.
Raising the env var is the supported answer for larger corpora — the constant exists as a knob, not a limit to engineer around. Cost estimate: a 768-dimension float32 embedding is ~3 KB/row, so a 10,000-row window reads up to ~60 MB per recall in the worst case (memories + episodes). No archival or thinning routine is required: the long-term model is no physical deletion — old rows sink via windows and decay.
5. Dedup semantics: skip, not upsert¶
store deduplicates two ways, and the two are scoped differently:
msg_iddedup — astorecarrying amsg_idthat already exists is skipped (result: "skipped", echoing the existing row's id). This probe spans agent and project but notchannel: the samemsg_idwritten to a second channel is skipped against the first channel's row.- Content dedup — an identical content string is likewise skipped, scoped to
agent, project and channel. A unique index backs it, but only within an exact
bucket (
agent_id, project_id, channel, content), while the probe that runs first also sees the global pool. Two writers racing into different project buckets can therefore both land.
The critical consequence: there is no upsert. Re-storing a changed
content under the same msg_id does not update the stored row — it is
skipped. To change a stored memory, use update_memory (re-embeds
automatically), or delete_memory + store.
The flip side is a guarantee you can lean on: re-submitting unchanged content is harmless by construction, which makes naive full re-submission of a corpus safe. See the corpus indexing patterns for how to run a document index on top of these semantics.
A store that reaches the handler carries result: stored (row written),
skipped (dedup hit or persistence paused — nothing wrong), or rejected
(refused, with reason). One layer sits above that and answers in the generic
shape instead: with an ACL configured, a call the client is not permitted to
make returns {ok: false, error: "permission_denied", tool, client_id} and no
result. Branch on ok is false first, then on result.
6. Autocut fires only on similarity-scale signals¶
Autocut (largest-score-gap truncation) assumes score gaps encode relevance breaks. That is only true of similarity-scale signals:
- Fires: under confidence scoring, or on a homogeneous raw-cosine list where every row carries the signal.
- Deliberately inert: under
rsfandrrfordering. Rank-fusion scores decay hyperbolically by construction — their gaps encode retriever overlap, not relevance breaks. Fusion-ordered results rely on the fused quality gate for contamination control instead.
So in the default configuration (confidence off, rrf or rsf), tuning
CPERSONA_AUTOCUT_MIN_RESULTS has no effect on recall size. The knob that
does move the gate under fusion modes is set_recall_precision — see the
tuning runbook.
7. Profile rows carry no score¶
The update_profile row is appended to recall responses as an injection row —
it does not participate in scoring. There is at most one: profiles is unique
on (agent_id, user_id) and every write path binds user_id to '', so a
second update_profile replaces the first rather than accumulating.
- With confidence off (the default), profile rows have no score, sort
last, and are cut by
limitwhen the scored results already fill it. Measured underrsfwithlimit=10on a full corpus: 0 profile rows survived. - With confidence on, profile rows receive a high confidence score and reliably surface near the top.
Do not treat the profile as a guaranteed always-injected channel unless you
run with confidence enabled. For must-always-be-present facts, the correct
mechanism is deterministic injection (your CLAUDE.md / system prompt), not
probabilistic recall — see When not to use recall.
8. gate_fallback responses are low-confidence¶
A recall response carrying gate_fallback: true (absent otherwise) means
every candidate fell below the quality gate, and the below-gate lexical
matches were returned instead of an empty result. Treat these rows as
low-confidence — typical for identifier/hash lookups whose exact match is
semantically distant from the query text.
The rescue path exists only under confidence scoring. The rows it returns
are marked by the same backfill that runs when CPERSONA_CONFIDENCE_ENABLED
is on, so in the default configuration gate_fallback can never appear: an
all-below-gate recall simply returns nothing.
9. lock_memory protects; it does not boost¶
lock_memory protects a row from deletion and editing. It does not
affect ranking — a locked memory can still lose a recall. If the requirement
is "must never be lost", lock it. If the requirement is "must always be in
context", use deterministic injection (and see §7 for the profile caveat).
10. Response shapes: how to tell success from failure¶
Since v2.5.2 the rule is uniform: branch on ok is false, and treat any
response carrying error as a failure whether or not ok is present.
Three things changed in that release, each because the previous shape made a failure readable as a success:
storereports its outcome inresult—stored/skipped/rejected— instead of an always-trueok. A rejected write used to look like a successful one (see §5).check_healthreports the singlestatusverdict, without the formerhealthyboolean.- Every tool-level failure a handler returns now carries
ok: false. Most used to returnerroralone, with nookto branch on. The explanation still travels inerror— except onstore, which puts it inreason.
Two shapes stay outside that rule, and always did:
- The outermost MCP dispatch answers an unknown tool name, or an exception
escaping a handler, with a bare
errorand nook. That layer is vendored from a library shared with the other Cloto servers, so aligning it is an upstream change rather than a local edit. - A successful read (
get_contents,list_memories,list_episodes,get_profile) returns its payload with nookeither.
Both are covered by the rule above, which is why the rule is phrased as "treat
a response carrying error as a failure" rather than "check ok".