Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Relation graph audits**: Added `jumbo relations audit` with selectable dangling, isolated, inactive-only, ambiguous-ID, and summary checks, optional entity-type filtering, deterministic text output, and a stable structured result for relation coverage diagnostics.

## [3.19.0] - 2026-07-24

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Memory and context orchestration for coding agents.
| [Audience Commands](reference/commands/audiences.md) | add, list, update, remove |
| [Audience Pain Commands](reference/commands/audience-pains.md) | add, list, update |
| [Value Proposition Commands](reference/commands/value-propositions.md) | add, list, update, remove |
| [Relation Commands](reference/commands/relations.md) | add, list, traverse, remove |
| [Relation Commands](reference/commands/relations.md) | add, list, traverse, path, audit, remove |
| [Work Commands](reference/commands/work.md) | pause, resume |
| [Maintenance Commands](reference/commands/maintenance.md) | heal, evolve |
| [Worker Commands](reference/commands/worker.md) | view |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Complete reference for all Jumbo CLI commands.
| [Audience Commands](audiences.md) | add, list, update, remove |
| [Audience Pain Commands](audience-pains.md) | add, list, update |
| [Value Proposition Commands](value-propositions.md) | add, list, update, remove |
| [Relation Commands](relations.md) | add, list, traverse, remove |
| [Relation Commands](relations.md) | add, list, traverse, path, audit, remove |
| [Work Commands](work.md) | pause, resume |
| [Maintenance Commands](maintenance.md) | heal, evolve |
| [Telemetry Commands](telemetry.md) | status, enable, disable |
Expand Down
34 changes: 33 additions & 1 deletion docs/reference/commands/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ sidebar:
order: 12
---

Add, list, traverse, find paths through, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities.
Add, list, traverse, find paths through, audit, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities.

---

Expand Down Expand Up @@ -162,6 +162,38 @@ If either ID appears under multiple entity types, specify its matching type opti

---

## jumbo relations audit

Audit relation graph integrity and coverage without modifying relations, projections, or events. By default, the command runs every check.

### Synopsis

```bash
> jumbo relations audit [options]
```

### Options

| Option | Description |
|--------|-------------|
| `-c, --check <checks...>` | Run one or more checks: `dangling`, `isolated`, `inactive-only`, `ambiguous-id`, or `summary` |
| `--entity-type <type>` | Filter findings and summary data by relation endpoint entity type |

The checks report active relations with missing typed endpoints, current entities without an active relation, current entities connected only by inactive relations, IDs shared by multiple entity types, and counts grouped by entity type, relation type, strength, and status. Only explicitly removed projection records are treated as non-current; deprecated, resolved, completed, ended, and deactivated entities remain auditable context.

Text output uses stable check headings and typed entity IDs. `--format json` returns the requested checks, filter, summary, finding collections, and counts as one structured result.

### Examples

```bash
> jumbo relations audit
> jumbo relations audit --check dangling isolated
> jumbo relations audit --check summary --entity-type component
> jumbo relations audit --format json
```

---

## jumbo relation remove

Remove a relation from the knowledge graph.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Complete command documentation.
| [Audience Commands](commands/audiences.md) | add, list, update, remove |
| [Audience Pain Commands](commands/audience-pains.md) | add, list, update |
| [Value Proposition Commands](commands/value-propositions.md) | add, list, update, remove |
| [Relation Commands](commands/relations.md) | add, list, traverse, remove |
| [Relation Commands](commands/relations.md) | add, list, traverse, path, audit, remove |
| [Work Commands](commands/work.md) | pause, resume |
| [Maintenance Commands](commands/maintenance.md) | heal, evolve |
| [Worker Commands](commands/worker.md) | view |
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EntityTypeValue } from "../../../../domain/relations/Constants.js";

export interface AmbiguousRelationNodeFinding {
readonly entityId: string;
readonly entityTypes: readonly EntityTypeValue[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IRelationViewReader } from "../get/IRelationViewReader.js";
import { AuditRelationsRequest } from "./AuditRelationsRequest.js";
import { IRelationNodeCatalog } from "./IRelationNodeCatalog.js";
import { RelationAuditPolicy } from "./RelationAuditPolicy.js";
import { RelationAuditResult } from "./RelationAuditResult.js";

export class AuditRelationsController {
constructor(
private readonly nodeCatalog: IRelationNodeCatalog,
private readonly relationReader: IRelationViewReader,
private readonly policy: RelationAuditPolicy,
) {}

async handle(request: AuditRelationsRequest): Promise<RelationAuditResult> {
const [nodes, relations] = await Promise.all([
this.nodeCatalog.findAll(),
this.relationReader.findAll({ status: "all" }),
]);
return this.policy.audit(nodes, relations, request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EntityTypeValue, RelationAuditCheckValue } from "../../../../domain/relations/Constants.js";

export interface AuditRelationsRequest {
readonly checks?: readonly RelationAuditCheckValue[];
readonly entityType?: EntityTypeValue;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { RelationNodeReference } from "../get/RelationNodeReference.js";

export interface DanglingRelationFinding {
readonly relationId: string;
readonly from: RelationNodeReference;
readonly to: RelationNodeReference;
readonly missingEndpoints: readonly RelationNodeReference[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { RelationNodeCatalogEntry } from "./RelationNodeCatalogEntry.js";

export interface IRelationNodeCatalog {
findAll(): Promise<RelationNodeCatalogEntry[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { EntityTypeValue } from "../../../../domain/relations/Constants.js";

export interface InactiveOnlyRelationNodeFinding {
readonly entityType: EntityTypeValue;
readonly entityId: string;
readonly lifecycleState: string;
readonly relationIds: readonly string[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EntityTypeValue } from "../../../../domain/relations/Constants.js";

export interface IsolatedRelationNodeFinding {
readonly entityType: EntityTypeValue;
readonly entityId: string;
readonly lifecycleState: string;
}
172 changes: 172 additions & 0 deletions src/application/context/relations/audit/RelationAuditPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {
EntityType,
EntityTypeValue,
RelationAuditCheck,
RelationAuditCheckValue,
} from "../../../../domain/relations/Constants.js";
import { RelationView } from "../RelationView.js";
import { AuditRelationsRequest } from "./AuditRelationsRequest.js";
import { RelationAuditResult } from "./RelationAuditResult.js";
import { RelationNodeCatalogEntry } from "./RelationNodeCatalogEntry.js";

const ALL_CHECKS = Object.values(RelationAuditCheck);

export class RelationAuditPolicy {
audit(
catalogEntries: readonly RelationNodeCatalogEntry[],
relations: readonly RelationView[],
request: AuditRelationsRequest,
): RelationAuditResult {
const requestedChecks = this.normalizeChecks(request.checks);
this.validateEntityType(request.entityType);

const currentNodes = catalogEntries
.filter((entry) => entry.isCurrent)
.sort(this.compareNodes);
const currentKeys = new Set(currentNodes.map((entry) => this.nodeKey(entry.entityType, entry.entityId)));
const orderedRelations = [...relations].sort((left, right) => left.relationId.localeCompare(right.relationId));
const activeRelations = orderedRelations.filter((relation) => relation.status === "active");

const danglingItems = activeRelations
.map((relation) => {
const from = { entityType: relation.fromEntityType, entityId: relation.fromEntityId };
const to = { entityType: relation.toEntityType, entityId: relation.toEntityId };
const missingEndpoints = [from, to].filter(
(endpoint) => !currentKeys.has(this.nodeKey(endpoint.entityType, endpoint.entityId)),
);
return { relationId: relation.relationId, from, to, missingEndpoints };
})
.filter((finding) => finding.missingEndpoints.length > 0)
.filter((finding) => this.relationMatchesFilter(finding.from.entityType, finding.to.entityType, request.entityType));

const relationsByNode = new Map<string, RelationView[]>();
for (const relation of orderedRelations) {
this.appendRelation(relationsByNode, relation.fromEntityType, relation.fromEntityId, relation);
this.appendRelation(relationsByNode, relation.toEntityType, relation.toEntityId, relation);
}

const filteredNodes = currentNodes.filter((node) => !request.entityType || node.entityType === request.entityType);
const isolatedItems = filteredNodes
.filter((node) => !(relationsByNode.get(this.nodeKey(node.entityType, node.entityId)) ?? [])
.some((relation) => relation.status === "active"))
.map((node) => ({
entityType: node.entityType,
entityId: node.entityId,
lifecycleState: node.lifecycleState,
}));

const inactiveOnlyItems = filteredNodes
.map((node) => ({ node, relations: relationsByNode.get(this.nodeKey(node.entityType, node.entityId)) ?? [] }))
.filter(({ relations: nodeRelations }) =>
nodeRelations.length > 0 && nodeRelations.every((relation) => relation.status !== "active"),
)
.map(({ node, relations: nodeRelations }) => ({
entityType: node.entityType,
entityId: node.entityId,
lifecycleState: node.lifecycleState,
relationIds: nodeRelations.map((relation) => relation.relationId).sort(),
}));

const nodesById = new Map<string, Set<EntityTypeValue>>();
for (const node of currentNodes) {
const types = nodesById.get(node.entityId) ?? new Set<EntityTypeValue>();
types.add(node.entityType);
nodesById.set(node.entityId, types);
}
const ambiguousIdItems = [...nodesById.entries()]
.map(([entityId, types]) => ({ entityId, entityTypes: [...types].sort() }))
.filter((finding) => finding.entityTypes.length > 1)
.filter((finding) => !request.entityType || finding.entityTypes.includes(request.entityType))
.sort((left, right) => left.entityId.localeCompare(right.entityId));

const summaryNodes = filteredNodes;
const summaryRelations = orderedRelations.filter((relation) =>
this.relationMatchesFilter(relation.fromEntityType, relation.toEntityType, request.entityType),
);
const summary = requestedChecks.includes(RelationAuditCheck.SUMMARY)
? {
nodes: {
count: summaryNodes.length,
byEntityType: this.countBy(summaryNodes.map((node) => node.entityType)),
},
relations: {
count: summaryRelations.length,
byRelationType: this.countBy(summaryRelations.map((relation) => relation.relationType)),
byStrength: this.countBy(summaryRelations.map((relation) => relation.strength ?? "unspecified")),
byStatus: this.countBy(summaryRelations.map((relation) => relation.status)),
},
}
: {
nodes: { count: 0, byEntityType: {} },
relations: { count: 0, byRelationType: {}, byStrength: {}, byStatus: {} },
};

return {
requestedChecks,
entityType: request.entityType ?? null,
summary,
findings: {
dangling: requestedChecks.includes(RelationAuditCheck.DANGLING)
? { count: danglingItems.length, items: danglingItems }
: { count: 0, items: [] },
isolated: requestedChecks.includes(RelationAuditCheck.ISOLATED)
? { count: isolatedItems.length, items: isolatedItems }
: { count: 0, items: [] },
inactiveOnly: requestedChecks.includes(RelationAuditCheck.INACTIVE_ONLY)
? { count: inactiveOnlyItems.length, items: inactiveOnlyItems }
: { count: 0, items: [] },
ambiguousId: requestedChecks.includes(RelationAuditCheck.AMBIGUOUS_ID)
? { count: ambiguousIdItems.length, items: ambiguousIdItems }
: { count: 0, items: [] },
},
};
}

private normalizeChecks(checks: readonly RelationAuditCheckValue[] | undefined): RelationAuditCheckValue[] {
if (!checks || checks.length === 0) return [...ALL_CHECKS];
const invalid = checks.find((check) => !ALL_CHECKS.includes(check));
if (invalid) throw new Error(`Audit check must be one of: ${ALL_CHECKS.join(", ")}.`);
return ALL_CHECKS.filter((check) => checks.includes(check));
}

private validateEntityType(entityType: EntityTypeValue | undefined): void {
if (entityType && !Object.values(EntityType).includes(entityType)) {
throw new Error(`Entity type must be one of: ${Object.values(EntityType).join(", ")}.`);
}
}

private appendRelation(
target: Map<string, RelationView[]>,
entityType: EntityTypeValue,
entityId: string,
relation: RelationView,
): void {
const key = this.nodeKey(entityType, entityId);
const current = target.get(key) ?? [];
if (!current.some((item) => item.relationId === relation.relationId)) current.push(relation);
target.set(key, current);
}

private relationMatchesFilter(
fromEntityType: EntityTypeValue,
toEntityType: EntityTypeValue,
filter: EntityTypeValue | undefined,
): boolean {
return !filter || fromEntityType === filter || toEntityType === filter;
}

private countBy(values: readonly string[]): Record<string, number> {
return [...values].sort().reduce<Record<string, number>>((counts, value) => {
counts[value] = (counts[value] ?? 0) + 1;
return counts;
}, {});
}

private nodeKey(entityType: EntityTypeValue, entityId: string): string {
return `${entityType}\u0000${entityId}`;
}

private compareNodes(left: RelationNodeCatalogEntry, right: RelationNodeCatalogEntry): number {
return left.entityType.localeCompare(right.entityType) || left.entityId.localeCompare(right.entityId);
}
}
18 changes: 18 additions & 0 deletions src/application/context/relations/audit/RelationAuditResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EntityTypeValue, RelationAuditCheckValue } from "../../../../domain/relations/Constants.js";
import { AmbiguousRelationNodeFinding } from "./AmbiguousRelationNodeFinding.js";
import { DanglingRelationFinding } from "./DanglingRelationFinding.js";
import { InactiveOnlyRelationNodeFinding } from "./InactiveOnlyRelationNodeFinding.js";
import { IsolatedRelationNodeFinding } from "./IsolatedRelationNodeFinding.js";
import { RelationAuditSummary } from "./RelationAuditSummary.js";

export interface RelationAuditResult {
readonly requestedChecks: readonly RelationAuditCheckValue[];
readonly entityType: EntityTypeValue | null;
readonly summary: RelationAuditSummary;
readonly findings: {
readonly dangling: { readonly count: number; readonly items: readonly DanglingRelationFinding[] };
readonly isolated: { readonly count: number; readonly items: readonly IsolatedRelationNodeFinding[] };
readonly inactiveOnly: { readonly count: number; readonly items: readonly InactiveOnlyRelationNodeFinding[] };
readonly ambiguousId: { readonly count: number; readonly items: readonly AmbiguousRelationNodeFinding[] };
};
}
12 changes: 12 additions & 0 deletions src/application/context/relations/audit/RelationAuditSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface RelationAuditSummary {
readonly nodes: {
readonly count: number;
readonly byEntityType: Readonly<Record<string, number>>;
};
readonly relations: {
readonly count: number;
readonly byRelationType: Readonly<Record<string, number>>;
readonly byStrength: Readonly<Record<string, number>>;
readonly byStatus: Readonly<Record<string, number>>;
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { EntityTypeValue } from "../../../../domain/relations/Constants.js";

export interface RelationNodeCatalogEntry {
readonly entityType: EntityTypeValue;
readonly entityId: string;
readonly lifecycleState: string;
readonly isCurrent: boolean;
}
4 changes: 4 additions & 0 deletions src/application/host/IApplicationContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ import { IRelationRemovedProjector } from "../context/relations/remove/IRelation
import { IRelationRemovedReader } from "../context/relations/remove/IRelationRemovedReader.js";
import { IRelationReader } from "../context/relations/IRelationReader.js";
import { IRelationViewReader } from "../context/relations/get/IRelationViewReader.js";
import { AuditRelationsController } from "../context/relations/audit/AuditRelationsController.js";
import { IRelationNodeCatalog } from "../context/relations/audit/IRelationNodeCatalog.js";
// Audience Pain Projection Store ports - decomposed by use case
import { IAudiencePainAddedProjector } from "../context/audience-pains/add/IAudiencePainAddedProjector.js";
import { IAudiencePainUpdatedProjector } from "../context/audience-pains/update/IAudiencePainUpdatedProjector.js";
Expand Down Expand Up @@ -643,6 +645,7 @@ export interface IApplicationContainer {
getRelationsController: GetRelationsController;
traverseRelationsController: TraverseRelationsController;
findRelationPathController: FindRelationPathController;
auditRelationsController: AuditRelationsController;

// Relations Category - Event Stores - decomposed by use case
relationAddedEventStore: IRelationAddedEventWriter;
Expand All @@ -652,4 +655,5 @@ export interface IApplicationContainer {
relationAddedProjector: IRelationAddedProjector & IRelationAddedReader;
relationRemovedProjector: IRelationRemovedProjector & IRelationRemovedReader & IRelationReader;
relationViewReader: IRelationViewReader;
relationNodeCatalog: IRelationNodeCatalog;
}
Loading
Loading