Create points, operators, mitigations, NANDs, supersede, delete, and annotate on the Tortoise graph. Use when asked to write to the Tortoise graph — creating points, operators, mitigations, NAND edges, superseding points, deleting, or annotating.
Instal·la
npx skillscat add daniel-ospina/agent-infra/how-to-use-tortoise Instal·la des del registre de SkillsCat.
⛔ This skill MUST be read in full before any Tortoise graph write. Skipping corrupts the graph.
How to Use Tortoise
Safe graph write operations for the Tortoise probabilistic inference graph.
Which Tortoise Are You On?
The graph-write operations in this skill are identical for every Tortoise deployment — only the invocation differs. Find your setup:
Hosted (cloud)
Your agent reaches the graph through the Tortoise MCP server at https://api.premiselabs.co/mcp/, authenticated with a Bearer API key (TORTOISE_API_KEY). If you installed the skills via the onboarding wizard, the MCP server is already wired — claude mcp add tortoise (or the .mcp.json snippet: {"type": "http", "url": "https://api.premiselabs.co/mcp/", "headers": {"Authorization": "Bearer ${TORTOISE_API_KEY}"}}). Every operation below maps to a tortoise_* MCP tool. No local database, SDK, .env, or graph-scripts.
Self-hosted
You run your own FalkorDB and connect with a local MCP server (same tortoise_* tool names) or the Python SDK (sdk.*). Local-only details — the Docker URI, TORTOISE_DB_URI/.env, graph-scripts, and the "never point local tooling at the hosted instance" rule — live in Self-Hosted Setup below.
Invocation map
The write operations appear once in this skill (by operation name). Invoke them per your setup:
| Operation | Hosted — MCP tool | Self-hosted — SDK |
|---|---|---|
| Create point | tortoise_create_point |
sdk.create_point(kind, content) |
| Create operator (IMPL/NAND edge) | tortoise_create_operator |
sdk.create_operator(op_type, src, [targets]) |
| Mitigate operator | tortoise_mitigate_operator |
sdk.mitigate_operator(id, reason, strength) |
| Annotate operator | tortoise_annotate_operator |
sdk.annotate_operator(id, bias, precision, …) |
| Supersede point | tortoise_supersede |
sdk.supersede_point(old, new) |
| Invalidate point | tortoise_invalidate |
sdk.supersede(old, new, transfer_edges=False) |
| Delete point | tortoise_delete_point |
sdk.delete_point(id) |
| Update point | tortoise_update_point |
sdk.update_point(id, **props) |
| Edit an operator edge (relabel) | tortoise_update |
sdk.update(<operator_id>, label=…) |
| Remove an operator edge | tortoise_delete |
sdk.delete(<operator_id>) |
| Compute confidence | tortoise_compute_confidence |
sdk.compute_confidence(anchors=…) |
| Check structure | tortoise_check_structure |
sdk.check_structure() |
An operator IS a Point. Operators carry
is_operator=trueon a:Pointnode, so they take
the generic verbs above —sdk.update(<operator_id>, label=…)relabels an operator edge in
place andsdk.delete(<operator_id>)removes it.operator_action's refusal ("must be 'mitigate' or 'annotate'") is not the whole operator vocabulary; that action set exists only
for the two operations needing a reason or epistemic dimensions.One asymmetry worth knowing before you choose a verb: relabelling does not validate.
create_operator(label=…)returns a structuredundeclared_relationwarning when the predicate
is neither a core operator label —IMPL/NAND(Edge Types) plus the ONTOLOGY §8 semantics
vocabulary (hasPart,addresses,supports,refutes,causes,dependsOn, …) — nor one an
installed pack declares.update(<operator_id>, label=…)performs no declaredness check and
stores the label silently. Take valid predicates fromlist_relations()for the pack-declared
leg, or from that core set.
Hard Gate
Any graph write (create_point, create_operator, mitigate_operator, supersede_point, delete_point, annotate_point, invalidate_point, and the operator-taking update(<operator_id>, …) / delete(<operator_id>)) MUST go through this skill. Bypassing it risks:
- EP weights nuked by batch-connected mitigations
- Orphaned NAND edges with no cleanup
- Superseded operators with active edges still propagating
- Label-based content instead of structural claims
- Criteria with no provenance — untethered from the customer needs they serve
Edge Types
| Type | Semantics | Use When |
|---|---|---|
| IMPL | A implies/supports B | Evidence supports a claim |
| NAND | A contradicts B | Evidence conflicts with a claim |
Mitigation Ranges
Mitigations dampen the OPERATOR's effective EP weight. Range: 0.10–0.50
(single source of truth: tortoise/weights.py module docstring, #2315):
- 0.10: Minor caveat (claim is mostly true) — keeps 90% of the weight
- 0.30: Significant limitation — keeps 70% of the weight
- 0.50: Major counter-evidence (claim is substantially weakened) — keeps
50% of the weight (strongest sanctioned mitigation)
Never use <0.10 (negligible) or >0.50 (would invert the claim — use NAND instead).
Formula: w_eff = w × (1 − strength) — the mitigation is a graded
DAMPENER, never a refutation (a mitigated NAND stays a contradiction, only
weaker).
Annotation Dimensions — the Other Weight Mechanism
A second, independent lever scales an operator's weight: the four annotation
dimensions on every IMPL/NAND edge. Mitigation only ever damps an operator
(0.10–0.50); annotation bands it — it can make a well-rated operator count for
more as well as less. Use mitigation when the connection is weaker than it
looks, and annotation when the connection's quality is known and should be graded.
| Dimension | Reads as | Direction |
|---|---|---|
bias |
How one-sided the source's stake is (hidden agenda) | Inverted — high bias reads WORSE |
precision |
How sharply the claim is defined | High reads BETTER |
consistency |
How stable this relevance is across contexts | High reads BETTER |
directness |
How directly the evidence bears on the claim | High reads BETTER |
Each is a number in 0.0–1.0. Set all four together:tortoise_annotate_operator (hosted) /sdk.annotate_operator(id, bias, precision, consistency, directness) (self-hosted).
q = mean(1 − bias, precision, consistency, directness)
factor = 2 ** (2q − 1) # 0.5 (worst) … 1.0 (neutral) … 2.0 (best)| Rating | Factor |
|---|---|
| no rating at all, or all four at 0.5 | exactly 1.0 |
| worst possible (bias 1.0, others 0.0) | 0.5 |
| best possible (bias 0.0, others 1.0) | 2.0 |
only precision at 1.0, the rest unrated |
1.189 |
Four rules that decide how to use it:
- Unrated is exactly neutral, and neutral is the default. Leave the
dimensions alone unless you have a reason. Rating is a deliberate act —
never set them as a by-product of writing something else. - Averaged, not multiplied. Rating one dimension well never penalises the
others, and a partial rating moves the factor only as far as that
dimension justifies. A rating of 0.9 across the board reads BETTER than
unrated, not worse. - Symmetric — for every rating that halves a weight there is one that
doubles it. Do not use these to express disagreement; that is NAND's job.
Use them to say "this reasoning is stronger, or weaker, than an unrated
connection". - A rating does not require a source. These dimensions grade how much a
connection contributes, so they apply as much to a reasoning link you built
yourself as to one backed by external evidence. Grade the connection;
provenance belongs on the point, incredibility/sourceKind.
On a NAND the top of the band is partly absorbed: NAND_BASE_WEIGHT is
8.0 and the final clamp is [0.1, 10.0], so 8.0 × 2.0 = 16.0 clamps to10.0. Strengthening is fully effective on IMPL and only partly on a
top-level NAND. It is not effective at all on a NAND whose target is
itself an operator: that NAND's base doubles to 16.0 before the rating, so
even unrated it already clamps to 10.0 and a best rating gains nothing
(weakening still lands — 16.0 × 0.5 = 8.0).
Do not confuse these with source credibility tiers. A Point's Beta
prior — how much we trust the claim itself — comes from its sources' tier
(credibilityTierwhen set, otherwise the tier implied bysourceKind,
T0–T4), not from thecredibilitykwarg. That kwarg is a validated label,
not a strength: it adds one weak evidence body, the same for every ladder
word, and does not select the tier prior. The annotation
dimensions grade an Operator's weight — how much this connection
contributes. They are separate systems: a T0 source on a heavily-biased
connection is still a heavily-biased connection.
The Critical Semantic: Truth vs Weight
When a claim faces challenge, you have two tools. They address different things:
| NAND | mitigatedBy | |
|---|---|---|
| What it says | "This claim is FALSE" | "This claim is TRUE but matters LESS than it seems" |
| Dimension | Correctness | Relevance |
| Effect on EP | Contradiction propagates through graph | Dampens the operator's weight: w_eff = w × (1 − strength) (#2315) |
| Applies to | The argument Point directly | The operator (IMPL connection) between argument and what it supports |
The golden rule: Relevance lives on the OPERATOR, truth lives on the POINT.
- An option IS an option (it's true that it's a candidate); a criterion IS a criterion — never NAND an option or criterion for being a bad fit. A bad fit is a relevance problem, not a truth problem.
- If a FINDING is factually untrue → NAND the finding Point directly (truth attack).
- If a finding is TRUE but IRRELEVANT to the option/criterion → NAND/IMPL the OPERATOR between them, or mitigate the operator (relevance attack, range 0.10–0.50).
- If an OPTION doesn't satisfy a CRITERION well → NAND the IMPL OPERATOR between them, or mitigate it — never NAND the option or criterion points themselves.
Worked Example: Option ↔ Criterion
Setup: "Option:AGPLv3-dual-license" should score well on "Criterion:adoption" (OSI-approved status). Finding says "AGPLv3 is OSI-approved."
| Scenario | What you do | Why |
|---|---|---|
| "Finding:agpl-osi" says AGPLv3 is OSI-approved (TRUE) | Create IMPL from finding → option:agplv3-dual | Truth + relevance both correct |
| "Finding:agpl-enterprise-risk" says enterprises ban AGPL (TRUE) | Create NAND from finding → option:agplv3-dual | True fact that COUNTERS the option — NAND on the OPERATOR (finding opposes option), not on the option point itself |
| "Finding:bsl-osi-gap" says BSL is OSI-approved (FALSE — it's not) | NAND the finding Point directly | The finding is factually wrong — truth attack on the Point |
| Option:agplv3-dual doesn't fit Criterion:stack-compat well (Apache 2.0 compat question) | NAND the operator between criterion and option, or mitigate it at 0.30 | The option and criterion are both true; the RELATIONSHIP is weak — relevance attack on the operator |
Quick Decision Tree
Challenge received:
├─ Claim is FACTUALLY WRONG? → NAND the Point directly (truth attack)
├─ Claim is TRUE but OVERSTATED? → mitigate_operator on the edge (relevance attack, 0.10–0.50)
├─ Claim is TRUE but IRRELEVANT to what it's connected to? → NAND the operator (relevance attack)
├─ Claim is TRUE, the link holds, but its QUALITY is known and should be graded?
│ → annotate_operator on the edge (grades its weight, 0.5–2.0; leave unset = exactly neutral)
└─ Option is a BAD FIT for a criterion? → NAND/mitigate the operator between them, never the option or criterion pointsOriginal example (argument↔option): "A1: Provider cannot read content" supports Option A via IMPL.
- NAND: "Metadata reveals topics, so the provider CAN infer some content." → This says A1 is categorically wrong. NAND the A1 point.
- mitigatedBy (0.20): "Metadata is lossy, like email subjects — visible but not the full content." → This says A1 is true, but the privacy claim is weaker than it sounds. Mitigate the IMPL operator.
Rule of thumb: If you're saying the argument is wrong → NAND. If you're saying it's overstated or irrelevant → mitigatedBy or NAND the operator.
SourceKind Taxonomy
| Tier | Label | Description |
|---|---|---|
| T0 | Direct Observation | First-hand empirical evidence |
| T1 | Primary Source | Original document, raw data |
| T2 | Secondary Source | Analysis, interpretation of primary sources |
| T3 | Tertiary Source | Synthesis, summaries, encyclopedias |
| T4 | Speculative | Hypothetical, unverified claims |
Decision Provenance: Tracing Criteria to Customer Needs
A decision's criteria shouldn't float. They should trace back to who needs them and why. The chain flows from customer-facing needs down to architecture choices:
[domain concepts from expansion packs] → Criterion → Argument → OptionEach link is an IMPL connection. The specific pointKinds in the chain depend on the expansion packs loaded. Load the packs, check their registered kinds and relations, and wire the chain accordingly.
The chain is auditable in both directions:
- Downward: "which customer need does this criterion serve?" → traverse IMPL up
- Upward: "which decisions does this requirement drive?" → traverse IMPL down
An agent auditing an ADR can walk the full chain: understand not just what was decided, but why the criteria exist, who they serve, and whether the decision holds up if those customer needs change.
Supersession
When superseding a point:
- Create the new point with updated content
- Call
supersede_point(old_id, new_id)— this cleans up edges (hosted:tortoise_supersede; self-hosted:sdk.supersede_point) - Verify old point's edges are properly transferred
Annotation Rules
- Annotations describe WHY an edge exists (rationale, not label)
- Use sentence-case, be specific
- Never annotate with just a label (e.g., "evidence" — say what evidence)
Common Mistakes
| Mistake | Consequence | Correct |
|---|---|---|
| Batch-connecting mitigations | EP weights cascade-nuked | Connect mitigations one at a time, verify each |
| NAND without checking existing IMPL | Orphaned contradiction | Check for existing IMPL edges before adding NAND |
| Superseding without edge cleanup | Stale edges from old point still propagate | Always call supersede_point, never manually move edges |
| Label-based annotation | Unreadable graph | Write rationale sentences, not keywords |
Pre-Write Checklist
- Have I read the target point's current edges?
- Am I using the correct edge type (IMPL vs NAND)?
- If mitigation: is the value in 0.10–0.50 range?
- If NAND: am I attacking truth (NAND the Point) or relevance (NAND/mitigate the Operator)?
- Am I about to NAND an option or criterion? STOP — options and criteria are true by definition. Attack the operator instead.
- If superseding: will I call supersede_point (not manually move edges)?
- Are my annotations sentence-case rationales, not labels?
After writing, verify with tortoise-verify-chain to ensure graph integrity.
Decision Comparison Workflow
The most common high-value agent operation: comparing options against weighted criteria using evidence, then ranking them by EP belief propagation.
Pattern
1. Define criteria (evaluation dimensions)
2. Define options (candidates)
3. Gather findings (evidence points — research, observations, data)
4. Wire findings/criteria → options via IMPL (supports) or NAND (opposes)
5. Run compute_confidence
6. Read per-option confidence, ranked highest → lowestKey insight: EP propagates belief from evidence through operators to options. More IMPL → higher confidence. More NAND → lower confidence. Mitigated operators → dampened influence.
Anchors Convention
Decision comparisons use explicit anchor lists to scope EP propagation. Collect all option IDs into a list and pass them to compute_confidence(anchors=...) (hosted: tortoise_compute_confidence; self-hosted: sdk.compute_confidence).
Code Pattern
from tortoise.sdk import TortoiseSDK
sdk = TortoiseSDK()
# 1. Criteria — evaluation dimensions (pointKind: "criterion")
criteria = {
"crit:adoption": "OSI-approved / recognizable open source status",
"crit:enterprise": "Enterprise procurement acceptability",
}
# 2. Options — candidates (pointKind: "option")
options = {
"opt:agpl": "AGPLv3 license",
"opt:mit": "MIT license",
}
# 3. Findings — evidence (pointKind: "evidence")
findings = {
"finding:1": "AGPL is OSI-approved",
"finding:2": "Enterprises ban AGPL internally",
}
# 4. Create all points — no context param needed (namespace is pointKind-based)
all_points = {**criteria, **options, **findings}
point_ids = {}
for pid, content in all_points.items():
kind = "criterion" if pid.startswith("crit:") else (
"option" if pid.startswith("opt:") else "evidence"
)
p = sdk.create_point(kind, content)
point_ids[pid] = p["id"]
# 5. Wire edges (findings/criteria → options)
edges = [
# Criterion supports option
("crit:adoption", "IMPL", "opt:agpl"),
# Finding supports option
("finding:1", "IMPL", "opt:agpl"),
# Finding opposes option
("finding:2", "NAND", "opt:agpl"),
]
for src, op_type, tgt in edges:
sdk.create_operator(op_type, point_ids[src], [point_ids[tgt]])
# 6. Compute confidence via anchors (options are the anchor set)
option_ids = [point_ids[pid] for pid in options]
result = sdk.compute_confidence(anchors=option_ids, max_hops=2, direction="incoming")
confs = result.get("confidences", {})
# 7. Rank options
ranked = sorted(
[(pid, confs.get(point_ids[pid], {}).get("mean", 0))
for pid in options],
key=lambda x: x[1], reverse=True
)
for pid, confidence in ranked:
print(f" {pid}: {confidence:.4f}")Hosted equivalent: the same flow runs on the hosted MCP server with the tortoise_* tools — tortoise_create_point per node, tortoise_create_operator per edge, then tortoise_compute_confidence with the option ids as anchors. No local SDK or database needed.
Link-Before-Create Rule
Before creating a new evidence point, search whether it already exists:
- Use
tortoise_searchwith the evidence claim as query (self-hosted SDK:sdk.tortoise_fts_query) - Use
create_pointwithdedup=True(idempotent — returns existing if content matches) - This prevents duplicate evidence points that fragment EP propagation
Worked Examples (self-hosted)
These run against a local FalkorDB via the SDK — the self-hosted path. Hosted tenants run the identical pattern with the MCP tools (tortoise_create_point → tortoise_create_operator → tortoise_compute_confidence); the scripts are reference implementations of the flow, not a hosted requirement.
graph-scripts/file_pricing_decision.py
Compares Pro ($29/$49/$79) and Team ($99 flat / $199 flat / $99+$20-per-seat) pricing options using criteria (competitor positioning, conversion rate, ARPU, and others) and findings (devtool sweetspot, OSS conversion rates, and others). Wires IMPL to chosen options, NAND to rejected ones. EP computes per-option confidence. ⚠️ Superseded prices — this is a worked example of the flow, not our price list. The decided tiers are free $0 · solo $9 · pro $25 · team $149 in daniel-ospina/tortoise's product/pricing.json ("status": "current", "owner_confirmed": "2026-08-07").
graph-scripts/decide_licensing.py
Compares 3 license options (AGPLv3-dual, BSL+AGPL, SSPL) using 7 criteria and 20+ findings. Full pattern: criteria → options → findings → edges → compute_confidence → ranked output. Run as:
TORTOISE_DB_URI=docker://:@127.0.0.1:16379/tortoise python3 graph-scripts/decide_licensing.pygraph-scripts/decide.py
Generic decision comparison tool — accepts criteria/options/findings as structured input (--input file.json|yaml with the full decision definition, or --options/--criteria/--findings/--edges JSON on the CLI) and automates the create→wire→compute→rank pipeline. This is the self-host variant documented in the tortoise-decide skill; follow the pattern above if you're driving the SDK directly.
Decision Comparison Checklist
- Option IDs collected in an anchors list for
compute_confidence(anchors=...) - Criteria points created with kind=
"criterion" - Option points created with kind=
"option" - Evidence/finding points created with kind=
"evidence" - Each edge wired: finding/criterion → option via IMPL (support) or NAND (oppose)
-
compute_confidence(anchors=[option_ids], max_hops=2, direction="incoming")called after all edges - Options ranked by
confidence_meandescending - ✅ CORRECT: NAND on operator when option opposes criterion
- ❌ WRONG: NAND on option/criterion points themselves — they ARE options/criteria
Searching the Graph
Tortoise supports two search modes for different use cases.
Two Search Modes
| Mode | Tool | What It Does | When to Use |
|---|---|---|---|
| Full-scan | tortoise_query (kind only, no query) |
Returns ALL Points of a given pointKind | Graph review, finding weak spots, integrity checks, duplicate detection |
| Best-match | tortoise_search (with query string) |
Returns top-N Points ranked by RRF fusion | Agent context retrieval, entity resolution, "what does the graph believe about X?" |
Key rule: Full-scan mode never filters by confidence — low-confidence points are exactly what reviewers need to see. Best-match mode annotates confidence but defaults to no filter.
Which Tool to Use
| Tool | Use When |
|---|---|
tortoise_search |
You have a text query and want ranked, relevant results. Returns RRF-fused results from FTS + vector + structural indexes with full EP breakdown. |
tortoise_query |
You want to filter by kind without text search. Use text param for hybrid search. Supports order_by (relevance/confidence) and min_confidence. |
tortoise_suggest_entry_points |
You need to resolve an entity name from natural language (e.g., "what entities relate to pricing?"). Uses hybrid search for semantic matching. |
EP Breakdown Fields
Every search result includes an ep object with:
| Field | Meaning | Range |
|---|---|---|
confidence_mean |
EP Beta posterior mean — how confident the graph is | 0.0–1.0 |
evidence.impl_count |
Number of IMPL (supporting) edges | 0+ |
evidence.nand_count |
Number of NAND (contradicting) edges | 0+ |
evidence.total |
Total edge count (impl + nand) | 0+ |
contention |
Ratio of NAND to total — how disputed the claim is | 0.0–1.0 |
Interpreting confidence:
- 0.50 mean + low total evidence (total < 5) = uncertainty — not enough data yet
- 0.50 mean + high total evidence (total > 10) + high contention (> 0.3) = disagreement — strong opposing views
- 0.85 mean + high total evidence = settled — strong supporting evidence
Ordering and Filtering
order_by="relevance"(default): Results ranked by RRF fusion score — best semantic + keyword matchorder_by="confidence": Results sorted by EP confidence_mean descending — highest-confidence claims firstmin_confidence=0.5: Only return Points with confidence_mean ≥ 0.5 (default: 0.0 = no filter)
When to filter by confidence: Use when you need settled claims for decision-making. Do NOT use when reviewing the graph for weak spots — you need to see low-confidence points to identify what needs verification.
Entity Types
tortoise_search and tortoise_query support cross-entity search via entity_type:
| entity_type | Searches | ID Field | Kind Field | FTS Index |
|---|---|---|---|---|
point (default) |
Point nodes | id |
pointKind |
Point |
event |
Event nodes | eventId |
eventKind |
Event |
subject |
Subject nodes | id |
subjectKind |
Subject |
Non-Point entities skip EP annotation (no confidence breakdown in results).
Pack-Aware Search
Kind values are expanded through pack registries before queries execute. When you search for a kind like WorkItem, the system automatically includes:
- subclassOf: Subclasses registered in packs (e.g.,
dev:issue,pm:task) - equivalentTo: Bidirectional equivalents (e.g.,
dev:issue↔pm:task)
This means tortoise_search(query="auth bug", kind="WorkItem") matches both dev and PM work items automatically. No manual kind expansion needed.
Pack relations are queryable via list_relations(), which returns declared edge schemas with fromKind/toKind/mechanism triplets across all installed packs.
Degradation Behavior
Hybrid search degrades gracefully when indexes are unavailable:
| Strategy | When Available | Degradation |
|---|---|---|
| FTS | FalkorDB fulltext index exists | Skipped silently if index missing |
| Vector | Embedding model + vector index | Falls back to brute-force Euclidean distance; skips if no embeddings |
| Structural | Always available | N/A — uses native property filters |
| TF-IDF | Last resort (in-memory) | Only triggered if all FalkorDB strategies fail |
A 500ms timeout caps all strategies. If all FalkorDB strategies fail, the system falls back to in-memory TF-IDF for Point queries. For Event/Subject queries without FTS indexes, only structural (kind) filtering is available.
Creating Relationships with Semantic Labels
When connecting Points with operators, use the label parameter to add domain context. Direction is an explicit flag, not derived from the label (ONTOLOGY v3.1 §3.1, §8 — #189):
| Semantic Type | Mechanism | Typical Direction | When to Use |
|---|---|---|---|
| hasPart | IMPL | bidirectional (parts↔whole) | Composition — "Epic hasPart Issue", "Product hasPart Feature" |
| addresses | IMPL | unidirectional (A→B) | "Feature addresses Need", "Task implements Feature" |
| opposes | NAND | unidirectional (A→B) | "Feature competesWith Competitor", "Issue blocks Issue" |
Direction is explicit, not label-derived. create_operator(..., direction="bidirectional"|"unidirectional") controls EP back-propagation: bidirectional (default) propagates both ways; unidirectional is source→target only. The label carries domain semantics only — EP reads direction, never the label. NAND defaults to bidirectional but also supports unidirectional. (Hosted: tortoise_create_operator; self-hosted: sdk.create_operator.)
Backward compatibility: existing operators without a direction property default to bidirectional in EP (with a warning log). Self-hosted: run graph-scripts/migrate_direction.py to backfill direction on existing operators per their old semantics (NAND / hasPart / part-of → bidirectional; other IMPL → unidirectional). Hosted tenants do not run graph-scripts — the hosted API does not expose them.
Strength: Set via set_point_baseline(operator_id, alpha, beta) (hosted: tortoise_set_point_baseline; self-hosted: sdk.set_point_baseline). High alpha = strong support. High beta = strong contradiction.
# Create a relationship with semantic context + explicit direction
op = sdk.create_operator("IMPL", feature_id, [need_id], label="addresses", direction="unidirectional")
sdk.set_point_baseline(op["id"], alpha=10, beta=1) # strong supportDiscovery
Points are organized by pointKind (structural type) and source provenance (extractedFrom edges). Use these APIs to discover what's in the graph before searching or filing decisions.
Listing PointKinds
sdk.list_pointkinds()
# or via MCP: tortoise_list_pointkinds()Returns [{pointKind, count}, ...] ordered by count DESC — what structural types actually EXIST in the graph.
Listing Sources
sdk.list_sources()
# or via MCP: tortoise_list_sources()Returns sources grouped by sourceKind with point counts — where data came FROM.
Pack Registry
Packs declare what KINDS and RELATIONS can exist:
# What edge types are registered across all installed packs?
sdk.list_relations() # returns fromKind/toKind/mechanism triplets
# What kinds does a pack declare (including subclass/equivalence expansion)?
sdk.expand_kind("WorkItem") # returns ["dev:issue", "pm:task", ...]Discovery Checklist
- Ran
list_pointkinds()before creating new points — know what structural types exist - Ran
list_sources()if the data has provenance — know where existing data came from - Used pack-registered kinds for new points (check
list_relations()/expand_kind()) - For decision comparisons: collected option IDs in an anchors list for
compute_confidence(anchors=...)
Self-Hosted Setup: DB URI Reality
Hosted tenants: skip this section. Your connection is configured once at MCP registration (Bearer API key) — none of these environment variables or URIs apply to the hosted service.
The DB target is explicit, never accidental, and local stays local. A self-hosted/local instance is intentionally local — local tooling (MCP server, SDK, graph-scripts) targets the local FalkorDB, never a remote cloud DB. The hosted version is a separate product: the hosted API resolves FALKORDB_CLOUD_URI → TORTOISE_DB_URI via its entrypoint and refuses to start without it; clients reach it over HTTP with API keys.
| Source | URI resolution | Behavior when unset |
|---|---|---|
| MCP server (local) | TORTOISE_DB_URI in .mcp.json — defaults to local docker://:@127.0.0.1:16379/tortoise; a repo-root .env only fills keys .mcp.json does not set (useful for SDK scripts / direct launches — .mcp.json env always wins for the MCP server) |
Fails loud on startup (exit 1) — never silently connects to an empty embedded graph (TORTOISE_ALLOW_EMBEDDED=1 is the test-only escape hatch) |
| SDK / graph-scripts | os.environ["TORTOISE_DB_URI"] (or FalkorProjection.from_uri) |
Embedded redislite (dev/test only) |
| Hosted API | FALKORDB_CLOUD_URI secret → TORTOISE_DB_URI via entrypoint |
Refuses to start without it |
Supported URI schemes: docker:// (local), redis:// / rediss:// (accepted so the hosted API can consume cloud connection strings).
❗ Use
127.0.0.1, neverlocalhost, for the local port16379.localhostis not a
synonym for127.0.0.1here, because it resolves IPv6-first (::1before127.0.0.1on
macOS). If a second FalkorDB is ever bound on::1:16379— an ephemeral dev container,
another OrbStack service, a re-created sandbox — thenlocalhost:16379reaches that
near-empty instance instead of the canonical one, and the failure is silent: writes report
success and the node is then "gone", because it landed on the other server. The canonical
local instance is127.0.0.1:16379. Bind to the address, never to the name.A connectivity check cannot tell the two apart — a bare connect succeeds against either
server. Verify by reading back a point that must exist (the objective cascade), never by
connecting. A connectivity test is not a persistence test, and a successful write is not a
durable one. (Root cause and measurement: tortoise#6666.)
Always Verify First
Before any graph operation, confirm you're on the right database:
# Via SDK
sdk.taxonomy() # or tortoise_status() — returns point counts, pointKinds, graph nameOr via MCP:
tortoise_summarize_structure # returns {gateN_*, total}; zero total on an empty/wrong graphCommon Failure Mode
Agent: "Let me search for licensing evidence..."
(TORTOISE_DB_URI unset → MCP server refused to start, or a script fell back
to embedded/empty redislite)
Agent: "The graph has no licensing data. I'll create evidence from scratch."
(files 20+ duplicate points on the wrong graph)Fix: point local tooling at the local FalkorDB. The default in .mcp.json is docker://:@127.0.0.1:16379/tortoise (the designated local container, addressed by IP — see the 127.0.0.1 note above); override in the repo-root .env (gitignored — never commit credentials) if your local target differs.
# .env (repo root, gitignored) — LOCAL target only
TORTOISE_DB_URI=docker://:@127.0.0.1:16379/tortoiseRestart the MCP server after changing the URI — the connection is resolved once at startup. Do not point local tooling at the hosted (cloud) instance.
Self-Hosted: SDK Props Convention
create_point(kind, content, **props) takes flattened kwargs. Direct SDK callers may pass a nested props={"k": v} dict (mirroring the MCP tool signature) — the SDK now flattens it automatically (#218). A dict-valued props property is illegal in FalkorDB ("Property values can only be of primitive types"), so the flatten is the single place handling both conventions.
ID Fragmentation
Point IDs are NOT uniformly ULIDs. Don't assume the ID format.
| Format | Example | Origin |
|---|---|---|
| ULID (canonical) | 01JR8KZ5V7X2M3N4P6Q8R9T0W1 |
create_point with ULID generation |
| 19fc-hash | 19fc8a3b4c5d6e7f8a9b0c1d2e3f4a5b |
Early creation path |
| Numeric | 42, 1337 |
Legacy/bulk-imported points |
| Prefixed | letta-abc123, op-xyz789, ARCH_001, SVBP_002 |
Legacy systems, specific graph-scripts |
What This Means
- Always use the returned
idproperty — never construct or guess an ID - Store IDs, not derive them — if you need to reference a point later, save its
idfrom the response - Search by content, not ID —
dedup=Trueandtortoise_searchhandle resolution; you don't need to know the format
Related Issues
- #44 (FIXED):
traverseused to leak internal FalkorDB node IDs instead of public Point IDs. Fixed — traverse now returns the canonicalidproperty. - #52 (DONE): Audit script validates ID format consistency across the graph and ULID validation in
create_point.
Continue following the workflow as mandated by this skill. Do not skip steps.
Ingest Workflow (the bundle surface)
The Bundle Shape
tortoise_ingest / sdk.ingest takes ONE heterogeneous bundle with four
sections: points (statements — kind defaults to statement; event is
NOT a write kind — use an ENTITY item type:"event" for episodic records),entities (subject/object/event/document), sources (url + sourceKind),
and connections (operator/relation edges between them).
{
"points": [{"ref": "p1", "kind": "statement", "content": "A implies B."}],
"entities": [{"ref": "s1", "type": "subject", "name": "Acme"}],
"sources": [{"ref": "src1", "url": "https://example.com/r", "sourceKind": "report"}],
"connections": [{"ref": "c1", "from": "p1", "to": "s1", "relation": "authoredBy"}]
}granularity: "bulk" (default — aggregated counts) or "granular"
(adds a per-item results array). The response is the SAME key set either
way: {granularity, batch_id, created, deduped, ids, nudges, warnings}
(+ results for granular); created/deduped count{points, entities, sources, connections}; ids carries{points, entities, sources, connections, refs}.
Promotion policy: gated (default) vs auto
promotion_policy is a kwarg-only param, orthogonal to granularity."gated" is the DEFAULT: bundle points stay draft — promotion is
explicit, NEVER automatic. An explicit status:"live" on a point item
under gated is a VIOLATION (row 9 — case variants, nested props:{...},
and terminal statuses too). The sanctioned promotion routes:
- Interim route (pre-#785):
tortoise_update_point(id, props={"status":"live"})
— the guarded draft→live promote (single point). Caveat: it does NOT
resolve ZOMBIE draft operators (an operator whose endpoints are all live
stays inert until IT is explicitly promoted; auto-resolution ships with
the #785 promote tools). promotion_policy="auto"opts into #131 parity (source auto-promotion on
first edge write) — the migration path from the old default.
The response carries warnings (never fatal) — see the TWELVE-key table.
Idempotent resubmission
The identical bundle re-submission is SAFE: it derives the SAME
content-derived batch_id and returns everything in deduped (exactly-once
convergence — no duplicate points/operators/edges). Use it after a transport
death (no response): re-send the identical bundle; the response confirms
presence. Crash-retry semantics: an item that crashed mid-write is absorbed
by the retry (content+kind fallback scan), never duplicated.
Retry table (error → action)
| Error | Meaning | Action |
|---|---|---|
ERR_BUNDLE_INVALID {error, code, violations[]} |
Phase-1 validation failed; zero mutations | Fix the bundle; NEVER re-send unchanged |
ERR_INVALID {error, code} |
Bad param (granularity/promotion_policy) or the row-9 gated guard | Fix the param; the message names valid values |
ERR_QUOTA {error, code} |
Team cap reached (pre-write count-then-act) | Stop; escalate. Resubmit ONCE after headroom |
| Transport death — no response | The call may have committed anything | Re-send the identical bundle (exactly-once) |
Route decision table (connections)
| Connection | Route | Result |
|---|---|---|
Plain operator: IMPL/NAND — any endpoint is a plain :Point |
Operator point | {"operator_id", "deduped"} |
Plain operator: IMPL/NAND — both endpoints non-Point (e.g. Object↔Object) |
Direct edge (operator-less) | {"direct_edge", "from", "to", "deduped"} |
mitigation / reify: true |
Operator point | {"operator_id", "deduped"} |
relation |
Structural edge | {"relation", "from", "to", "deduped"} |
⛔ Point↔Point relations are operator-mediated (owner ruling 2026-10-13, tortoise #8499) — an
operator-less edge carries no operator node, so it can be neither mitigated nor annotated, and it
cannot carry the epistemic weighting a Point's relations claim. (Durability was the ruling's
original third argument — plain direct edges were journaled but never replayed, so rebuild_all
lost them — but #8499 landed the DirectEdgeCreated replay fold, so they survive a rebuild now
too; the ruling does not rest on that argument any more.) The direct shape is reserved forObject↔Object, where no epistemic weighting is claimed. Write reify: true to say the operator
route explicitly; a plain spelling that gets re-routed is reported as the point_mediated_route
warning. Note ingest's connection contract admits only plain :Point endpoints today (sole
carve-out: an Event on the operator route), so in practice every plain IMPL/NAND in a bundle
is operator-mediated and returns the bare operator-id string — the {direct_edge, from, to}
descriptor is what create_direct_edge returns for an Object↔Object pair, while create_edge
writes a relation edge ({relation, from, to}) instead.
Multi-item to on a plain IMPL/NAND connection: use reify:true /
mitigation (the operator route) or split into singular connections.
§6.6 behavior changes (migration lines)
- Default policy flipped auto→gated —
promotion_policy="auto"is the
opt-in parity mode (migration). - Multi-item
toon plain IMPL/NAND becomes a Phase-1 rejection —
migrate viareify:true/mitigation or split. - Same-pair plain connections differing only in
labelbecome Phase-1
rejections — migrate viareify:true×2 (the operator route preserves
both labels).
Warnings — the TWELVE keys (closed set)
warnings entries are key-prefixed strings. A key outside this table is a
divergence — report it:
append_only_items · modified_item_residue · mitigation_orphan_residue
· mitigation_drift_duplicate · nfc_straddle_duplicate ·mitigation_strength_change · partial_operator_residue ·operator_absorb_completed · label_dropped_resubmit ·direction_dropped_resubmit · direction_changed_resubmit ·point_mediated_route
Query-back verification (J8 exit state)
After ingest, VERIFY what you wrote: recall_subgraph(seed) /tortoise_query reach the ingested knowledge (promoted/live content), andlist_batch(batch_id) audits the exact stamped artifact set (points +
direct edges; entities/sources are out of stamp scope). Keep bundles
reasonably sized (large bundles are slower to validate — split when a
bundle exceeds a few hundred items).