audit
SkillDev toolsaudit
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the audit skill
What this skill tells your AI
The instructions your AI receives, as published by markmhendrickson/neotoma in .claude/skills/audit/SKILL.md and read by ahel’s review.
Purpose
Run a comprehensive read-only audit of the user's Neotoma database, surfacing graph-health issues by category and severity. Each finding includes a structured remediation pointing at an existing Neotoma tool — this skill never mutates state.
Neotoma has strong write-path discipline (idempotency, schema-first, immutability, deterministic IDs) but no holistic read-path "is my graph healthy?" tool. /audit fills that gap.
Scope
In scope: detection of inconsistencies, drift, and quality issues across entities, observations, relationships, schemas, and raw_fragments.
Out of scope: mutation of any kind. Repair is performed by delegating to other tools or skills (merge_entities, correct, split_entity, delete_entity, register_schema, update_schema_incremental). The user opts into each repair individually after reviewing findings.
Modes
/audit— Run all deterministic checks. No LLM cost. Default./audit --deep— Run deterministic + LLM-assisted checks. Requires an LLM API key (read fromNEOTOMA_AUDIT_LLM_KEYenv var or passed via--key=…). Models default to a cheap small model; override with--model=…./audit --scope=<entity_type|relationship|schema|fragments>— Restrict checks to one category./audit --since=<ISO date>— Restrict to entities/observations created or updated since the date./audit --user=<user_id>— Audit a specific user's records (defaults to authenticated user)./audit --dup-min-count=<N>— Minimum entity count for a type to be auto-scanned for potential duplicates beyond the always-checkedcontact/person/organizationset (default 100)./audit --format=<table|json|markdown>— Output shape;tabledefault for interactive,jsonfor piping into repair tooling.
Prerequisites
- Neotoma MCP server connected (prod by default:
mcpsrv_neotoma). - For
--deep: LLM API key configured (NEOTOMA_AUDIT_LLM_KEYor--key=…).
Workflow
Phase 1 — Plan
- Parse mode flags.
- Resolve user scope (
get_session_identityif no--userflag). - Print a one-line summary of what will be checked: "Running 15 deterministic checks against user X (≈N entities)". Add "+8 LLM-assisted checks" if
--deep.
Phase 2 — Deterministic checks
Test-fixture filter (applies to all checks): skip any entity_type matching ^(test_type_|test_activate_|cross_layer_schema_) unless --include-test-fixtures is passed. These are integration-test scaffolding, not real registry entries.
Run all of the following. Each check produces zero or more Finding records. Each Finding has:
{
check_id: "DUP_TYPE_ALIASES",
severity: "high" | "medium" | "low" | "info",
category: "entity_type" | "entity" | "observation" | "relationship" | "schema" | "fragments" | "naming",
entity_ids: ["ent_..."], // affected entities
summary: "Short human-readable summary",
detail: "Longer explanation with counts/examples",
remediation: {
tool: "merge_entities" | "correct" | "register_schema" | …,
args_hint: { ... }, // suggested args; user reviews before applying
description: "Merge ent_A into ent_B as the canonical record"
}
}
Check list
| ID | Category | What it detects | Remediation tool | Severity |
|---|---|---|---|---|
DUP_ENTITIES_BY_RESOLVER | entity | Entities flagged by list_potential_duplicates (server-side resolver matches). | merge_entities | high |
DUP_TYPE_ALIASES | entity_type | Two registered entity types whose aliases arrays overlap or whose canonical names are synonyms (e.g. vendor vs merchant). | register_schema (alias one to the other) | medium |
PLURAL_TYPE_NAMES | entity_type | Registered entity_type is plural and not in the irregular allowlist (per schema_agnostic_design_rules.md). | register_schema (rename to singular + alias) | medium |
ORPHAN_ENTITIES | entity | Entities with no PART_OF, REFERS_TO, or other inbound/outbound relationships, and no observations after the first one. | delete_entity or merge_entities | low |
CYCLE_HIERARCHY | relationship | Cycles in hierarchical relationship types (PART_OF, DEPENDS_ON) — explicitly forbidden by relationships.md. | Manual review + delete_relationship | high |
UNTYPED_OR_INVALID_EDGE | relationship | Relationships whose relationship_type is not in the registered enum. | delete_relationship + recreate with valid type | high |
ZERO_OBSERVATION_ENTITY | entity | Entities with snapshot but observation_count == 0 (resolver edge case — should not happen post-bootstrap). | delete_entity after manual verification | medium |
MISSING_SOURCE_PROVENANCE | observation | Observations with null source_id AND null interpretation_id (provenance broken). | None (immutable) — investigate ingestion path | high |
UNKNOWN_FIELDS_COUNT_HIGH | fragments | Interpretations with unknown_fields_count > 0 for an entity_type that has a registered schema (means schema is missing declared fields). | update_schema_incremental to add fields | medium |
FRAGMENT_DUMP | fragments | Entities where raw_fragments count exceeds the 95th percentile and the entity has fewer than N declared snapshot fields (signals unstructured dumping). | register_schema to declare common fragment_keys | medium |
FRAGMENT_KEY_HIGH_FREQ | fragments | fragment_key values whose frequency_count exceeds threshold T for a given entity_type — strong promotion candidates. | update_schema_incremental to promote field | info |
NON_READABLE_CANONICAL | naming | canonical_name matches a UUID pattern, hex hash, empty string, or pure punctuation/whitespace. | correct (provide identity-bearing field) | medium |
MISSING_REQUIRED_FIELD | schema | Entity snapshot missing a field declared required on its registered schema. | correct to add field | high |
RELATIONSHIP_DANGLING | relationship | Relationship references entity_id that does not exist or is soft-deleted (merged_to_entity_id set). | delete_relationship or update to canonical target | high |
SCHEMA_NO_CANONICAL_FIELDS | schema | Registered schema has empty canonical_name_fields — every store of that type creates a new row. | register_schema to declare canonical fields | high |
INTERPRETATION_HEARTBEAT_STALE | observation | Interpretation rows with started_at set, completed_at null, and heartbeat_at older than threshold. | Investigate — possibly stuck interpretation | medium |
ENV_CONTAMINATION | entity | Entity snapshot fields contain values matching known dev/test/staging environment markers (e.g. localhost, 127.0.0.1, dev., .local, test-, -dev, -staging, Kubernetes cluster DNS). Signals records imported from a non-production environment into a production database, or vice versa. | correct (update field value) or delete_entity | varies |
Implementation notes for each check
DUP_ENTITIES_BY_RESOLVER— Always run for the high-cardinality entity types where duplicates accumulate fastest:contact,person,organization. Calllist_potential_duplicatesonce per type (passingentity_type). When--scope=entityor--scope=entity_typeis not set, also include any other registered type whose entity count exceeds the per-run high-cardinality threshold (default 100; override with--dup-min-count=N). Skip types with fewer than 2 entities. For each returned pair, emit oneFinding:entity_idsis[entity_a.id, entity_b.id];summarynames bothcanonical_namevalues;detailincludesscore,matched_fields, andentity_type; populateargs_hintwith{ source_entity_id: entity_b.id, target_entity_id: entity_a.id, strategy: "prefer_more_recent" | "prefer_more_observations" }(merge the lower-ranked pair member into the higher-ranked one —entity_aranks first by score). Severity ishighwhenscore >= 0.95, otherwisemedium. Detection only — never callmerge_entitiesfrom a check.DUP_TYPE_ALIASES— Calllist_entity_types; build a graph of (canonical_name → aliases). Edges where two distinct canonical names appear in each other's alias sets, OR where alias arrays intersect, become findings. Levenshtein < 2 on short names emits aslowseverity.PLURAL_TYPE_NAMES—list_entity_typesand apply the same singularization rule used byvalidateSchemaDefinition(load fromNEOTOMA_ALLOWED_PLURAL_TYPESenv var or fall back to a hardcoded allowlist:news,data,analytics,status,series,species). Extend the in-skill allowlist with confirmed legacy import artifacts where the plural name is semantically intentional (one entity representing many items imported in bulk):reads(IndieWeb URL export, 2017-2018),sections(website section config blob),songs,talking_points,crypto_transactions. Emit these asinfoseverity with a note that they are legacy import artifacts, not schema violations — do not co-emitDUP_TYPE_ALIASESfor them. Validated against the live registry on 2026-05-16: surfacesplaces,plans,reads,songs,sections,crypto_transactions,talking_points,meeting_notes(before legacy allowlist). After applying legacy allowlist, actionable findings areplaces,plans,meeting_notesonly. Three of those (places/plans/meeting_notes) also have singular siblings already registered — co-emitDUP_TYPE_ALIASESfor those, ranked as the highest-priority repair candidates since rows are split across both types.ORPHAN_ENTITIES—retrieve_entitiesper entity_type; for each entity,list_relationshipswith the entity as source or target. Empty → orphan.CYCLE_HIERARCHY—list_relationshipsfiltered byPART_OF/DEPENDS_ON; DFS cycle detection over the directed graph.UNTYPED_OR_INVALID_EDGE—list_relationships; compare eachrelationship_typeagainst the enum from the relationship schema (PART_OF,CORRECTS,REFERS_TO,SETTLES,DUPLICATE_OF,DEPENDS_ON,SUPERSEDES,EMBEDS, plus domain-specific types from registry).ZERO_OBSERVATION_ENTITY—retrieve_entity_snapshot; checkobservation_count == 0.MISSING_SOURCE_PROVENANCE—list_observationsper entity (or sample); flag rows where bothsource_idandinterpretation_idare null. Note: immutable — finding is informational unless ingestion is still actively producing them.UNKNOWN_FIELDS_COUNT_HIGH— Aggregateunknown_fields_countfrominterpretationstable perentity_type. Anything > 0 for a type with a registered schema is a finding; bucket byfragment_keyfrequency.FRAGMENT_DUMP— Compute per-entity_type fragment counts (raw_fragmentsjoined to observations). Compare each entity to the per-type 95th percentile. High count + low snapshot density = finding.FRAGMENT_KEY_HIGH_FREQ— Group raw_fragments by(entity_type, fragment_key), sumfrequency_count. Threshold defaults: keys appearing on >25% of entities of that type.NON_READABLE_CANONICAL— Regex againstcanonical_name: UUID (^[0-9a-f]{8}-…), hex (^[0-9a-f]{16,}$), empty, whitespace-only, single-character.MISSING_REQUIRED_FIELD— Load schema definitions vialist_entity_types; for each entity, diff snapshot keys againstrequired_fields.RELATIONSHIP_DANGLING—list_relationships; for each row, verifysource_entity_idandtarget_entity_idresolve viaretrieve_entity_snapshotand are not soft-deleted.SCHEMA_NO_CANONICAL_FIELDS—list_entity_types; flag any with emptycanonical_name_fields.INTERPRETATION_HEARTBEAT_STALE— Query interpretation rows; threshold defaults to 15 minutes.ENV_CONTAMINATION— For each entity, callretrieve_entity_snapshotand pass the resulting field map toscanSnapshotForContamination()(implemented insrc/services/env_contamination_audit.ts). The function tests every string-valued field againstENV_CONTAMINATION_INDICATORS(loopback IPs,localhost, Kubernetes cluster-internal DNS,.localmDNS,dev./staging.sub-domains,test_user/test-userpatterns,test-prefixes,-dev/-stagingsuffixes, nil UUID, Docker private IP ranges). Structural metadata fields (entity_id,created_at,updated_at,observation_count,user_id,_migration_run_id) are skipped to avoid false positives. The severity of each finding is the highest severity among matched indicators (high>medium>low). For efficiency, sample up to the 500 most recently updated entities per audit run unless--scope=entityis active. Populateentity_idswith the IDs of flagged entities anddetailwith per-entity summaries of the matched fields and indicators.args_hintfor the remediation:{ entity_id, fields_to_correct: ["<field>", ...] }.
Phase 3 — LLM-assisted checks (only when --deep)
Run these only if the API key is present. Each check batches entities and asks the LLM for structured JSON output. Key never leaves the local process.
| ID | What it detects | Remediation tool |
|---|---|---|
LLM_CANONICAL_READABILITY | canonical_name is technically valid (passes deterministic regex) but not human-readable (e.g., payment-3-2, unnamed-vendor-7). | correct with suggested name |
LLM_ENTITY_TYPE_MISCLASS | Content semantically a transaction but stored as note/message/raw. LLM compares snapshot+observations to type registry. | correct with entity_type change |
LLM_SEMANTICALLY_EQUIVALENT_TYPES | Two entity_types not alias-linked but semantically equivalent (vendor vs merchant vs payee). | register_schema (alias) |
LLM_FRAGMENT_PROMOTION | Raw fragments that are stable, structured, and high-value enough to promote to declared schema fields. | update_schema_incremental with suggested field shape |
LLM_RELATIONSHIP_SUGGESTION | Pairs of entities whose observation text strongly implies a relationship that isn't declared. | create_relationship with suggested type |
LLM_DUPLICATE_BY_CONTENT | Entities that escaped resolver-based duplicate detection but have semantically equivalent content (different spellings, abbreviations). | merge_entities |
Phase 4 — Report
Output sections, in this order:
- Summary line — counts by severity:
Found 47 issues (3 high, 12 medium, 32 low/info). - Potential duplicates — a dedicated section for all
DUP_ENTITIES_BY_RESOLVERfindings, broken out from the generic severity lists because duplicate accumulation is the most common silent graph-health failure. Group byentity_type; within each type list the candidate pairs ranked byscore(truncate to 10 with(+N more)unless--verbose). Each row shows bothcanonical_namevalues, thescore, thematched_fields, and the merge hint. End the section with the always-checked types that returned zero candidates so the operator sees the check ran (contact: no candidates). If no pairs were found across any type, printPotential duplicates: none detected. Remediation ismerge_entitiesonly — emit the hint, never merge. - High-severity findings — list each remaining (non-duplicate) high finding with
check_id, summary, affected entity ids (truncated to 5 with(+N more)), and remediation one-liner. - Medium-severity findings — same format, collapsed by default in interactive mode.
- Low/info findings — collapsed group; show count only unless
--verbose. - Repair menu — numbered list of remediation actions the user can authorize:
1. Merge 7 duplicate pairs (merge_entities),2. Rename 2 plural entity types (register_schema), etc. The user picks individually; no batch auto-apply.
Phase 5 — Repair (delegated, opt-in)
For each remediation the user authorizes:
- Look up the target tool from the finding's
remediation.tool. - Print the exact tool call that will be made, with
args_hintfilled in. - Wait for explicit confirmation per finding (or
--yesto apply all of one category). - Invoke the tool. Record the result alongside the finding.
- After repair, re-run the affected check class to verify the issue is resolved.
Never auto-apply. Repair is always per-finding (or per-category with --yes), never per-audit-run.
Constraints
- MUST NOT mutate any Neotoma state in detection phases.
- MUST emit a
Findingfor every issue surfaced; no silent skips. - MUST include a
remediation.toolpointing at an existing Neotoma tool (ornullwith rationale if no automated repair is possible). - MUST NOT call LLM checks unless
--deepand a key is present. - MUST batch LLM calls and cap total LLM cost; print an estimate before running deep mode and require confirmation if estimate exceeds
$NEOTOMA_AUDIT_COST_CAP(default $1). - MUST respect
--userscope and refuse to audit other users' data without explicit authorization (get_session_identity). - MUST run deterministic checks before LLM checks; LLM checks may consume deterministic findings as input but never vice versa.
- MUST store the audit run itself as a Neotoma entity (
entity_type: audit_run, withfindings_count,started_at,completed_at,mode,scope) for historical comparison.
Forbidden patterns
- Calling
merge_entities,correct,delete_entity, etc., without explicit user confirmation per finding. - Inferring entity types or relationships from deterministic checks (those are LLM-mode only).
- Failing silently on tool errors — surface every error as an
infofinding with the tool response. - Storing LLM API keys in Neotoma or logs.
- Generating findings from heuristics not declared in this skill (no ad-hoc checks).
Output examples
Default /audit (deterministic only)
Auditing Neotoma database for user mark (≈8,420 entities)
Running 15 deterministic checks…
Summary: 47 findings (3 high, 12 medium, 32 low/info)
Potential duplicates (DUP_ENTITIES_BY_RESOLVER) — 7 candidate pairs
contact (5 pairs)
"Jane Doe" ↔ "Jane R. Doe" score 0.97 [canonical_name, email]
→ merge_entities(source=ent_bbb…, target=ent_aaa…, strategy=prefer_more_observations)
"Acme, Inc." ↔ "Acme Inc" score 0.93 [canonical_name]
→ merge_entities(source=ent_ddd…, target=ent_ccc…, strategy=prefer_more_recent)
… (+3 more, run --verbose)
organization (2 pairs)
"Vercel" ↔ "Vercel Inc." score 0.91 [canonical_name]
→ merge_entities(source=ent_fff…, target=ent_eee…, strategy=prefer_more_recent)
person: no candidates
HIGH (2)
[CYCLE_HIERARCHY] 1 cycle detected in PART_OF graph
→ ent_ccc → ent_ddd → ent_eee → ent_ccc
→ Remediation: manual review + delete_relationship
[SCHEMA_NO_CANONICAL_FIELDS] entity_type "note" has no canonical_name_fields
→ Every store creates a new row; deduplication impossible
→ Remediation: register_schema with canonical fields
MEDIUM (12) — collapsed, run with --verbose
LOW/INFO (32) — collapsed, run with --verbose
Repair menu:
1. Merge 7 potential duplicate pairs (merge_entities, review each) [y/n]
2. Resolve cycle in PART_OF graph (manual) [skip]
3. Add canonical_name_fields to "note" schema [y/n]
Run --verbose for medium/low remediations.
/audit --deep --scope=naming
Auditing naming category only (deep mode, model gpt-4o-mini)
Estimated LLM cost: $0.18 — proceed? [y]
Running 1 deterministic check (NON_READABLE_CANONICAL)…
Running 1 LLM check (LLM_CANONICAL_READABILITY)…
Summary: 23 findings (0 high, 18 medium, 5 info)
MEDIUM (18)
[NON_READABLE_CANONICAL] 12 entities with UUID-shaped canonical_name
[LLM_CANONICAL_READABILITY] 6 entities with technically-valid but unreadable names
→ ent_fff "payment-3-2" → suggested "Acme Corp - Invoice #3 - Feb 2026"
→ ent_ggg "vendor-unnamed-7" → suggested "Cloudflare"
→ … (4 more)
→ Remediation: correct (with LLM-suggested names)
Repair menu:
1. Apply 12 deterministic name corrections (correct, manual) [y/n]
2. Apply 6 LLM-suggested name corrections (correct, review each) [y/n]
Related
docs/foundation/schema_agnostic_design_rules.md— singular type names, schema-driven behavior.docs/subsystems/observation_architecture.md— raw_fragments lifecycle.docs/subsystems/relationships.md— typed edges, cycle prohibition.docs/subsystems/schema.md—raw_fragments,interpretationsschema.docs/subsystems/entity_merge.md— merge semantics.docs/foundation/entity_resolution.md— resolver path and identity rules.
Promotion path
This skill is the prototype shape. Once the check set stabilizes (≥2 weeks of use, deterministic checks ratified) promote to a first-class CLI command:
neotoma audit [--deep] [--scope=…] [--since=…] [--format=…]- OpenAPI contract update per
docs/architecture/openapi_contract_flow.md. - MCP tool exposure for cross-harness use.
- Feature unit under
docs/feature_units/in_progress/FU-…audit/.
Signals
- GitHub stars
- 32
- Forks
- 3
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
audit-markmhendrickson- Source
- github.com/markmhendrickson/neotoma