daniel-ospina

how-to-use-tortoise

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.

daniel-ospina 7 6 Updated 3w ago
GitHub

Install

npx skillscat add daniel-ospina/tortoise/how-to-use-tortoise

Install via the SkillsCat registry.

SKILL.md

⛔ 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. Configuring it by hand? Use the equivalent project-scoped .mcp.json entry: {"type": "http", "url": "https://api.premiselabs.co/mcp/", "headers": {"Authorization": "Bearer ${TORTOISE_API_KEY}"}} — that file is committable, so it carries the env reference, never the key. A project-scope server stays ⏸ Pending approval until you approve it once — start claude in the project and allow the prompt, or run /mcp. 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)
Compute confidence tortoise_compute_confidence sdk.compute_confidence(anchors=…)
Check structure tortoise_check_structure sdk.check_structure()

Hard Gate

Any graph write (create_point, create_operator, mitigate_operator, supersede_point, delete_point, annotate_point, invalidate_point) 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 reduce claim confidence. Range: 0.10–0.50.

  • 0.10: Minor caveat (claim is mostly true)
  • 0.30: Significant limitation
  • 0.50: Major counter-evidence (claim is substantially weakened)

Never use <0.10 (negligible) or >0.50 (would invert the claim — use NAND instead).

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.

Mitigation semantics (single source: tortoise/weights.py module
docstring, #2315):
strength is the graded DAMPENER of the operator's
effective EP weight — sanctioned band 0.10–0.50, formula
w_eff = w × (1 − strength): a 0.30 mitigation keeps 70% of the operator's
weight; 0.50 keeps 50% — dampened, never refuted (a mitigated NAND stays a
contradiction, only weaker). EP reads mitigation_strength via
compute_operator_weight — it is NOT advisory metadata. It is NOT a
statement of how true the reason is, and it is NOT fused into the
mitigation point's belief (that would invert the meaning).
Decision parts filed through the decide tooling are born LIVE with an
explicit starting belief (#2199): omit credibility= for the system
starting belief medium = Beta(3,1) (provenance system-default), or pass
credibility="high|medium|low" (ladder gold/high/medium/low/unverified)
for a set-by-author belief. The documented decide flow ranks on the first
attempt — no promote/calibrate chores, no CalibrationError.

  • 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)
└─ Option is a BAD FIT for a criterion? → NAND/mitigate the operator between them, never the option or criterion points

Original 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 → Option

Each 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:

  1. Create the new point with updated content
  2. Call supersede_point(old_id, new_id) — this cleans up edges (hosted: tortoise_supersede; self-hosted: sdk.supersede_point)
  3. 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 → lowest

Key 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_search with the evidence claim as query (self-hosted SDK: sdk.tortoise_fts_query)
  • Use create_point with dedup=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/Team pricing options ($29/$49/$79) using criteria (competitor positioning, conversion rate, ARPU) and findings (devtool sweetspot, OSS conversion rates). 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 the tortoise repo's product/pricing.json (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://:falkordb@localhost:6379/tortoise uv run python graph-scripts/decide_licensing.py

graph-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_mean descending
  • ✅ 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 match
  • order_by="confidence": Results sorted by EP confidence_mean descending — highest-confidence claims first
  • min_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 support

Discovery

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.

The committed repo-root .mcp.json defaults to the HOSTED endpoint and carries no DB URI. Its tortoise entry is {"type": "http", "url": "https://api.premiselabs.co/mcp/", "headers": {"Authorization": "Bearer ${TORTOISE_API_KEY}"}} — the DB target is resolved server-side by the hosted API, and the entry has no env block and no TORTOISE_DB_URI setting (its _comment names the variable only to point at the self-hosted path below). The TORTOISE_DB_URI rules below describe the self-hosted / stdio path; to use them, point that entry's url back at your own daemon (http://localhost:8000/mcp — see docs/quickstart-selfhosted.md).

Source URI resolution Behavior when unset
MCP server (self-hosted / stdio) TORTOISE_DB_URI on the daemon's own environment — a stdio .mcp.json entry sets it in its env block, and the compose daemon resolves it from its own env; a repo-root .env fills only the keys that entry does not set (useful for SDK scripts / direct launches — an entry's own env always wins). Canonical local form docker://:falkordb@localhost:6379/tortoise (compose publishes 127.0.0.1:6379; .env.example) — code defaults still fall back to the legacy port 16379, env-overridable 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).

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 name

Or via MCP:

tortoise_summarize_structure  # returns {total, operators, gateN_*, gate_total}; zero total on an empty/wrong graph

Common 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: on a self-hosted setup, point local tooling at the local FalkorDB — set TORTOISE_DB_URI on the daemon / stdio entry, or in the repo-root .env (gitignored — never commit credentials). The committed repo-root .mcp.json does not carry this variable: it defaults to the hosted endpoint (see the note above), so a self-hosted agent must first point that entry's url at its own daemon.

# .env (repo root, gitignored) — LOCAL target only, self-hosted/stdio path
TORTOISE_DB_URI=docker://:falkordb@localhost:6379/tortoise

Restart the MCP server after changing the URI — the connection is resolved once at startup. Do not point local tooling at the hosted (cloud) instance — the committed repo-root .mcp.json is the deliberate hosted default described above, not local tooling re-pointed at the cloud DB.

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 id property — never construct or guess an ID
  • Store IDs, not derive them — if you need to reference a point later, save its id from the response
  • Search by content, not ID — dedup=True and tortoise_search handle resolution; you don't need to know the format

Related Issues

  • #44 (FIXED): traverse used to leak internal FalkorDB node IDs instead of public Point IDs. Fixed — traverse now returns the canonical id property.
  • #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 ELEVEN-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 Direct edge (operator-less) {"direct_edge", "from", "to", "deduped"}
mitigation / reify: true Operator point {"operator_id", "deduped"}
relation Structural edge {"relation", "from", "to", "deduped"}

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 to on plain IMPL/NAND becomes a Phase-1 rejection —
    migrate via reify:true/mitigation or split.
  • Same-pair plain connections differing only in label become Phase-1
    rejections — migrate via reify:true ×2 (the operator route preserves
    both labels).

Warnings — the ELEVEN 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

Query-back verification (J8 exit state)

After ingest, VERIFY what you wrote: recall_subgraph(seed) /
tortoise_query reach the ingested knowledge (promoted/live content), and
list_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).