Relationship graph semantics
Tracked by pm-4jqm, pm-dwj33e, pm-ju83, pm-8xr8, pm-m2il, pm-jiusod, pm-mfvsng, pm-9gzr4r, pm-xvt7ps, pm-ouyq3n, pm-ayg31c, pm-3dyec2, pm-c90tfh, and pm-ob9z4y.
Decision
pm uses a hybrid relationship model: a versioned registry defines edge semantics, immutable item/history mutations remain the source of truth, and rebuildable indexes serve bounded graph queries. This preserves the simplicity of item-front-matter storage while giving SDK consumers a labeled-property-graph vocabulary without making arbitrary labels semantically ambiguous.
The alternatives were rejected as follows: a closed enum cannot model applications such as a VCS or company; unrestricted labels cannot safely drive algorithms; event-only traversal is too expensive for interactive context assembly; and index-only state is not auditable or replayable.
Contract
Each relationship kind declares direction, inverse, a traversal family,
optional typed-outcome traversal direction, ordering and hierarchy
participation, optional temporal order, incoming and outgoing cardinality,
lifecycle, aliases, payload schema, self-edge policy, and compatibility version.
Built-ins normalize legacy related_to, depends_on, child_of,
parent_child, epic, and task spellings. Unknown custom kinds remain
importable only after their definitions are registered, preventing algorithms
from guessing their meaning. Omitted custom traversal metadata remains
compatible: hierarchy and ordering declarations select their corresponding
families, undirected kinds are associative, and other directed kinds are
semantic.
recurs_from is the canonical recurrence relation: new --recurs_from--> old
means the source is a later event with the same observable failure identity as
the target. Its temporalOrder: "source_after_target" states chronology without
making recurrence an execution-order dependency. The edge is directed,
many-to-many, and persistent, so it remains valid after either endpoint closes.
Local mutation adapters enforce that contract against both endpoint
created_at values before writing the item or immutable history; equal and
reverse timestamps are rejected, including update-many dry runs.
It does not replace the older event (supersedes) and does not assert that two
records describe one event (duplicate_of). Traverse recurrence families with
direction: "both"; impact, paths, dominators, centrality, cut structure, and
community detection then operate on the connected family without special-case
labels or retyping historical replacement edges.
Ordering-cycle validation considers only kinds whose registry definition sets ordering: true. Associative and provenance edges never block execution. Hierarchy cycles remain a separate structural check. Canonical edge identity includes kind and ordered endpoints for directed edges, or sorted endpoints for undirected edges.
SDK queries are deterministic, bounded, cancellation-aware, and return explicit visited-node, inspected-edge, truncation, and continuation metadata. The graph kernel supplies adjacency, incoming and outgoing traversal, closure, shortest path, reverse impact through incoming traversal, and induced subgraphs. The in-memory index is rebuildable directly from item metadata; durable large-workspace indexes remain an interchangeable storage implementation.
Ordering kinds also declare precedence. source_before_target means the source must execute first; target_before_source models dependency-shaped edges such as blocked_by. Custom kinds default to source-first for compatibility, but domain packages should declare the direction explicitly. Analytics consume this field and never infer execution meaning from the label. Built-in canonical spellings, compatibility aliases, and inverse actionability are defined in Dependency-kind contract.
Hierarchy kinds likewise declare which endpoint is the structural parent. source_parent supports domain edges such as company owns asset, while target_parent preserves item-shaped child parent parent storage. Custom hierarchy kinds default to source_parent; packages should declare the orientation explicitly when their persisted edge shape differs. Context explanations use this contract instead of inferring ancestry from a kind name.
Immutable events and snapshots
RelationshipEventLog is the storage-independent reference mutation boundary. An append carries a unique event id, stable logical relationship id, action, author, timestamp, and optional optimistic expectedVersion. Add and supersede events validate endpoints, registered kinds, self-edge policy, duplicate identity, and incoming/outgoing cardinality before they enter the stream. Remove and supersede require an active logical relationship. No event rewrites an earlier event.
snapshot({ atVersion }) and snapshot({ atTimestamp }) replay the immutable stream into an exact RelationshipGraph. Event pages use the shared opaque query-cursor contract and bind continuation to the log version, so a caller cannot silently mix snapshots after concurrent writes.
RelationshipEventStore is the built-in durable filesystem adapter. It stores validated JSONL at .agents/pm/relationships/events.jsonl by default, replays every row through the same registry and cardinality checks on open, and serializes cross-process appends with the tracker lock. Async currentVersion(), snapshot(), and page() reads take the same lock before refreshing, so long-lived readers observe completed appends without torn JSONL tails. Store paths must stay lexically inside a non-symlinked tracker root and cannot traverse symlinked components. Database, replicated-log, and event-bus adapters can persist the same public events and rebuild the same snapshots.
appendBatch(inputs) is the migration/import path. It refreshes under the writer lock, validates every event against one evolving in-memory view, and publishes the complete JSONL replacement with one atomic rename. A validation or write failure leaves the prior stream byte-for-byte unchanged. The ordinary append(input) remains the low-latency single-event path and retains its append-only write. existingEventPolicy: "skip_identical" makes deterministic migrations resumable across processes: an existing event id is skipped only when its relationship id, action, canonical edge, author, timestamp, and reason are identical; a same-id/different-content collision fails closed.
import {
RelationshipEventLog,
RelationshipEventStore,
planRelationshipEventBackfill,
} from "@unbrained/pm-cli/sdk";
const history = new RelationshipEventLog(["design", "build", "ship"]);
history.append({
eventId: "evt-001",
relationshipId: "build-needs-design",
action: "add",
edge: { source: "build", target: "design", kind: "blocked_by" },
author: "planning-agent",
timestamp: new Date().toISOString(),
expectedVersion: 0,
});
const current = history.snapshot();
const historical = history.snapshot({ atVersion: 0 });
const durable = await RelationshipEventStore.open({
pmRoot: ".agents/pm",
nodes: ["design", "build", "ship"],
});
await durable.append({
eventId: "evt-002",
relationshipId: "ship-needs-build",
action: "add",
edge: { source: "ship", target: "build", kind: "blocked_by" },
author: "release-agent",
timestamp: new Date().toISOString(),
});
const legacyItems = [
{ id: "design", title: "Design", status: "closed" as const },
{
id: "build",
title: "Build",
status: "open" as const,
parent: "design",
dependencies: [{ id: "design", kind: "blocked_by" }],
},
];
const migration = planRelationshipEventBackfill(legacyItems, {
migrationId: "legacy-items-v1",
author: "migration-agent",
timestamp: "2026-07-21T20:45:00.000Z",
});
const migrationStore = await RelationshipEventStore.open({
pmRoot: ".agents/pm",
nodes: migration.nodes,
relativePath: "relationships/legacy-items-v1.jsonl",
});
await migrationStore.appendBatch(migration.events, {
existingEventPolicy: "skip_identical",
});
Pluggable graph adapters and federation
RelationshipGraphAdapter is the storage-neutral projection boundary for graph
databases, replicated services, and package-owned indexes. It deliberately
stores a complete RelationshipGraphSnapshot, not authoritative item state or
untyped backend query results. Each snapshot contains the deterministic nodes,
normalized edges, complete relationship-kind ontology, an ISO projection time,
and a SHA-256 semantic fingerprint. parseRelationshipGraphSnapshot rebuilds
the registry and RelationshipGraph, validates every endpoint and kind, and
rejects version drift, semantic corruption, or a fingerprint mismatch before a
backend result can reach an algorithm.
Adapters implement three small operations:
read({ workspace, signal })returns the current portable snapshot ornull.replace({ workspace, snapshot, expected_fingerprint, signal })atomically publishes the complete snapshot and uses the expected fingerprint as a compare-and-swap guard.nullmeans the workspace must still be empty.- Optional
clear(...)applies the same compare-and-swap rule.
syncRelationshipGraphAdapter validates the input, skips an already-current
backend, atomically replaces stale state, and reads it back before reporting
success. This makes a backend that acknowledges a write without exposing it fail
closed. loadRelationshipGraphAdapter always returns the portable snapshot plus
the executable registry and graph; callers never need backend-specific parsing.
federateRelationshipGraphSnapshots unions compatible node/edge sets and rejects
same-name relationship kinds with different semantics, preventing two projects
from silently disagreeing about whether an edge orders or merely relates work.
import {
MemoryRelationshipGraphAdapter,
assertRelationshipGraphAdapterConformance,
createRelationshipGraphSnapshot,
federateRelationshipGraphSnapshots,
syncRelationshipGraphAdapter,
} from "@unbrained/pm-cli/sdk";
const adapter = new MemoryRelationshipGraphAdapter("company-graph");
const portable = createRelationshipGraphSnapshot(current.graph, registry);
await syncRelationshipGraphAdapter(adapter, {
workspace: "acme/product-a",
snapshot: portable,
});
// Package tests run this same contract against Neo4j, SQLite, or a service.
await assertRelationshipGraphAdapterConformance(adapter, {
workspace: "isolated-conformance-workspace",
});
const portfolio = federateRelationshipGraphSnapshots([productA, productB], {
createdAt: new Date().toISOString(),
});
createRelationshipGraphScaleFixture supplies reiterable lazy nodes and
edges for chain, star, disconnected, and sparse layouts. A million-node proof
can therefore declare nodeCount: 1_000_000 and edgeStride: 100 without first
allocating a million item documents or reading a real tracker. Tests choose the
scale explicitly; the fixture never silently downsizes. The returned
node_count and edge_count are exact, so benchmark envelopes can assert what
was actually exercised.
The repository acceptance command builds, snapshots, synchronizes, reads back,
and reuses the default million-node/9,999-edge sparse graph through the packed
public shapes without accessing .agents/pm:
pnpm build
node --max-old-space-size=4096 scripts/benchmarks/graph-adapter-scale.mjs
This adapter protocol complements the immutable event boundary. Events remain the attributable source used for replay and correction; snapshots are replaceable, federatable projections that external query engines can rebuild. Backends must not treat a successful snapshot sync as permission to rewrite pm items or history.
Explainable analytics
analyzeRelationshipExecution runs exact deterministic topological layering and longest-path analysis only over kinds registered with ordering: true. It reports the ready frontier, prerequisite depth, critical path, and genuine strongly connected ordering cycles separately from associative cycles. analyzeGraphImpact returns a bounded affected set with an exact shortest explanation path per returned node. analyzeKnowledgeGraph reports weak and strong components, intentional-or-unreviewed isolates, and unique-neighbor hubs without assigning an opaque authority score. compareRelationshipSnapshots exposes exact temporal edge additions and removals.
Every analytics result identifies its algorithm and edge family. Exact algorithms stay exact when a result is bounded: truncated means additional rows exist, not that returned paths or distances are estimates. Future approximations must add their method, seed, freshness, and confidence or error bounds rather than reuse the exact envelope.
Every result that publishes edge_count also publishes edge_basis.
Workspace analysis and dependency projections use deduplicated_directed;
centrality uses simple_undirected because its adjacency intentionally
collapses reciprocal and parallel edges to unique endpoint pairs; generated
scale fixtures use fixture_generated. Counts with different bases describe
different graph projections and must not be compared as evidence of edge loss.
Bounded agent context
buildRelationshipContext joins caller-owned compact node details with the graph kernel in one request. The packet opens with a counts-first summary (root identity and status, root-incident edge counts per semantic family, discovered/returned/omitted node and edge counts, evidence count, and a continuation marker) followed by the root, shortest-distance related nodes, included edges, root evidence pointers, and the cost envelope.
Every returned node is explainable: role names its semantic family (prerequisite, dependent, ancestor, descendant, provenance, or related), via names the node through which bounded traversal first discovered it, and reasons lists the direct classifications for depth-1 nodes or a "<role> via <node> (depth N)" chain explanation for deeper nodes. When a node matches several families, role picks the deterministic priority order prerequisite > dependent > ancestor > descendant > provenance > related.
meta.completeness reports result quality: the exact in-memory kernel emits complete or truncated, and the contract reserves sampled, approximate, stale_index, and redacted for index-backed or policy-filtered providers so consumers can branch on one field.
Node, edge, depth, kind, direction, and token bounds are independent. The cursor fingerprint covers semantic filters and traversal shape, so it is rejected when reused for a different root or query. Output remains a plain object suitable for TOON, JSON, JSONL, MCP, or a custom UI; adapters own rendering and do not reimplement traversal.
The native adapter is pm deps <id> --format context. It is also available through PmClient.deps, runAction({ action: "deps" }), and the MCP pm_deps tool. --max-depth, --node-limit, --edge-limit, --token-budget, --cursor, --direction, and repeatable or comma-separated --kind map directly to the public SDK context options; --summary keeps only counts. Unknown --kind values fail fast with the registered-kind list instead of silently matching nothing. Tree and graph formats share those first four bounds, use safe defaults when omitted, and report deterministic truncation metadata instead of recursively expanding a cyclic or multiply-linked graph without limit.
The context result also enumerates broken references instead of reporting a bare count: missing_count counts missing nodes reachable within the same bounded traversal that produced the packet (so it agrees with tree/graph semantics for equal traversal parameters and is documented by missing_scope: "traversal"), and missing_references lists each dangling declaration inside the packet with its declaring holder, dangling target, kind, source surface, and legacy_terminal classification so agents can separate repairable typos on active items from ignorable historical debt. --edge-limit caps both returned graph edges and enumerated missing-reference rows; missing_reference_count preserves the untruncated declaration total. The root's linked files, tests, docs, and annotation counts are promoted into evidence as bounded pointers.
pm deps pm-example --format context --max-depth 3 \
--node-limit 20 --edge-limit 40 --token-budget 800 \
--direction both --kind blocked_by,parent
import {
analyzeRelationshipExecution,
buildRelationshipContext,
} from "@unbrained/pm-cli/sdk";
const execution = analyzeRelationshipExecution(current.graph);
const packet = buildRelationshipContext(
current.graph,
"build",
[
{ id: "design", title: "Approve design", status: "closed" },
{
id: "build",
title: "Build release",
status: "open",
evidence: ["src/release.ts", "test:release"],
},
{ id: "ship", title: "Ship release", status: "open" },
],
{ direction: "both", maxDepth: 3, nodeLimit: 20, tokenBudget: 800 },
);
Semantic traversal and governance
The @unbrained/pm-cli/sdk barrel exports registry-aware traversal primitives
for domain packages that need more than generic adjacency:
hierarchyAncestorsandhierarchyDescendantsfollow only hierarchy kinds and honor each kind's declared parent endpoint.orderingPredecessorsandorderingSuccessorsfollow only order-bearing kinds and honor declared precedence, so inverse spellings agree.enumerateRelationshipPathsreturns bounded simple paths with edge evidence, cost metadata, cancellation, direction/kind filters, and explicit truncation.
An explicit kind filter may include traversal: "semantic" kinds on either
hierarchy or ordering walks, giving package-defined lineage edges a bounded
semantic traversal surface without pretending that they are structural or
scheduling edges. Default walks remain family-strict when no kind filter is
provided. Association kinds refuse these walks with an impact --direction both recovery route; selecting the other structural family points to the
matching hierarchy or ordering commands.
All semantic walks are breadth-first and deterministic. limit, maxDepth,
and after provide bounded continuation for hierarchy and ordering walks;
path enumeration separately bounds returned paths and expanded partial paths.
Unknown kinds and cursors fail fast instead of silently degrading context.
assembleWorkspaceRelationshipGraph is the shared normalization seam for
dependency-shaped workspaces. It folds parent links, the legacy scalar
blocked_by, and structured dependency edges into one graph, materializes
missing endpoints as explainable placeholder nodes, and returns active versus
terminal dangling-reference partitions. Domain adapters pass their
RelationshipKindRegistry as the optional third argument so custom VCS,
company, or package-defined edges survive assembly with their registered
semantics. auditWorkspaceRelationshipGraph
consumes that assembly and emits counts-first findings for active/terminal
missing references, retired sentinels, ordering cycles, exact
scalar-versus-structured ordering contradictions, stale lifecycle blocks, and
sparse or isolated active nodes. Findings include stable codes, severity,
bounded deterministic samples, truncation, policy text, and safe remediation;
the audit never invents an edge. Explicit isolate exemptions suppress policy
findings without changing structural coverage metrics.
The audit profile also exposes graph-wide resilience and delivery-lineage
metrics. nodes and edges remain diagnostic totals. Structural ratchets use
recorded_nodes, which excludes synthesized missing/external placeholders,
and informative_edges, which subtracts the union of witnessed redundant edge
identities and structured rows proven to contradict scalar blocker precedence.
redundant_edges and ordering_contradiction_edges remain separate debt
censuses so repair can tighten their ceilings without weakening the
information-bearing floor. articulation_points and bridge_edges reuse the exact cut-structure
algorithm; outcome metrics count explicit Milestone titles beginning with
Outcome milestone: and follow the registry's declared
outcomeTraversal directions toward them. The audit publishes that exact
outcome_reachability_basis direction groups beside the rates, using sorted
comma-separated kind names so consumers never infer lineage meaning from
labels while repeated direction labels stay out of the token surface. Built-in
hierarchy, implementation, verification,
discovery, incident, recurrence, and supersession edges opt in explicitly;
supersedes traverses both directions so an archived predecessor remains
connected to the replacement outcome lineage. Generic related and ordering
edges declare no outcome traversal and cannot satisfy the metric. Active and
terminal populations are reported separately, with integer
basis-point rates and all-status reachable/unreachable totals; the explicit
outcome milestones are roots, not work subjects, and are excluded from those
populations. Rate or
all-status floors are lifecycle-stable; an absolute active-population floor is
invalid because completing reachable work legitimately moves it into the
terminal population. Detailed output's nested SDK profile census,
finding_subjects_by_code, includes every known finding code even when its
population is zero, so assurance selectors never confuse a clean class with a
missing contract field. The top-level audit and persisted-baseline census is
affected_subjects_by_code; both fields count affected subjects, but their
locations and consumers differ and neither is an alias for the other.
import {
assembleWorkspaceRelationshipGraph,
auditWorkspaceRelationshipGraph,
orderingPredecessors,
} from "@unbrained/pm-cli/sdk";
const assembly = assembleWorkspaceRelationshipGraph(items, isTerminalStatus);
const prerequisites = orderingPredecessors(assembly.graph, "deploy", {
limit: 20,
maxDepth: 4,
});
const governance = auditWorkspaceRelationshipGraph(assembly, {
isTerminal: isTerminalStatus,
exemptIsolates: ["company-root"],
maxSampleSize: 25,
});
The three layers are intentionally separate: assembly owns storage-shape normalization, traversal owns semantic graph algorithms, and governance owns policy findings. A VCS, company operating model, digital twin, or other non-project domain can replace the assembly adapter while reusing the same registry, traversal, event, context, and audit contracts.
Relationship assurance sources
Repository assurance can ratchet graph quality without baking one project's
policy into the graph kernel. A dependency_kind measurement may partition a
canonical dependency kind by exact source_kind, by source_kind_prefix, or
by whether provenance is present or missing. These filters are mutually
exclusive and preserve the unfiltered measurement contract. This supports
independent evidence-backed and uncited-edge floors or ceilings while keeping
the dependency vocabulary extensible.
The prose_edge_gap source measures distinct holder-target pairs where item
descriptions, bodies, comments, notes, or learnings mention another canonical
item but no structured relationship exists in either direction. It performs one
bounded pass over the supplied items, resolves the collected mentions after the
canonical id set is complete, reports the exact gap count, partitions the result
into explicit_subject and implicit_subject pairs, and caps contributor
diagnostics with sample_limit. Reasoned exemptions may name a whole holder,
one holder-target pair, or a text fragment within one holder; an exemption
without a non-empty reason is invalid. This makes roadmap ledgers, negative
statements, and analysis subjects explicit policy rather than hidden
false-positive suppression.
const evidenceBlocks = {
kind: "dependency_kind" as const,
dependency_kind: "blocks",
source_kind_prefix: "evidence:",
};
const unlinkedMentions = {
kind: "prose_edge_gap" as const,
sample_limit: 25,
exemptions: [
{
holder_id: "roadmap-ledger",
reason: "The ledger inventories work without asserting pairwise edges.",
},
],
};
Assurance assertions should pin these measurements to observed repository baselines: an evidence partition uses a non-regression floor, while uncited edges and prose gaps use ceilings. Negative controls must prove the observed value passes and a one-unit regression fails before the assertions join the repository's graph-composition gate.
The scale acceptance runs the public SDK over one million items whose 999,999 prose mentions each have a corresponding structured edge. It verifies an exact zero-gap result, bounded empty diagnostics, a 2,999,998-unit cost receipt, and the real item scan count:
pnpm build
node --max-old-space-size=4096 scripts/benchmarks/prose-edge-gap-scale.mjs
The native workspace adapter is pm graph <subcommand>, also available as
PmClient.graph, runAction({ action: "graph" }), and the MCP pm_graph
tool. ancestors/descendants/predecessors/successors expose the
semantic walks, paths exposes bounded simple-path enumeration, impact
exposes reverse-reachability blast radius from analyzeGraphImpact,
analyze combines analyzeRelationshipExecution and analyzeKnowledgeGraph
into one counts-first workspace projection, and audit runs
auditWorkspaceRelationshipGraph with --sample and --exempt-isolate
policy controls. communities exposes detectRelationshipCommunities
(deterministic asynchronous label propagation with lexicographic
tie-breaking), redundancy exposes findRedundantRelationshipEdges
(transitive-reduction scan over ordering and hierarchy families in semantic
orientation, each finding carrying a bounded witness path), and dominators
exposes computeRelationshipDominators (Cooper–Harvey–Kennedy immediate
dominators over the root's reachable subgraph, ranking structural
bottlenecks by gated work), and plan exposes planRelationshipRemediation
(dry-run remediation proposals derived from audit findings and witnessed
redundancy rows, each carrying an exact operation, policy code, evidence,
rationale, and confidence — never auto-applied). Three planning and structural
subcommands complete the analytics surface: slack exposes
analyzeRelationshipSchedule (Critical Path Method float over the order-bearing
DAG — earliest/latest start, total slack, and critical-task classification with
unit task durations, reusing the exact execution forward pass and adding the
backward latest-start pass; genuine cycles are reported separately, never
scheduled), centrality exposes computeRelationshipCentrality (exact Brandes
shortest-path betweenness, Wasserman–Faust closeness, undirected degree, and
precedence-oriented dependency fan-in/fan-out per node over the simple undirected graph), and
articulation exposes findRelationshipCutStructure (iterative Tarjan low-link
search reporting articulation points and bridges — the single points of failure
whose removal fragments the knowledge graph). All three are deterministic and
exact on the bounded workspace, carry explicit cost/truncated metadata, and
honor --kind (centrality/articulation) and --limit/--summary bounds. The audit gates finding
severity on lifecycle: contradictions confined to terminal items report as
informational legacy_ordering_cycle/legacy_duplicate_edge history debt,
while ordering_cycle errors and duplicate_edge findings require at least
one active subject; duplicate_edge covers parallel same-family spellings,
reciprocal inverse pairs included, which transitive-reduction redundancy
deliberately skips. The storage-integrity family duplicate_dependency_row
(warning on active holders, informational legacy_duplicate_dependency_row
on terminal ones) reports raw dependency rows whose exact identity is stored
more than once on one holder — invisible to every assembled-graph projection
because graph construction deduplicates edges by identity, so
collectDuplicateDependencyRows scans the pre-assembly item rows carried on
the assembly. collectOrderingStorageContradictions similarly scans raw rows
before normalization: blocked_by: target plus a same-target source-first
ordering dependency asserts both directions and manufactures a two-node cycle.
The audit reports the exact holder, target, and removable dependency kind under
ordering_storage_contradiction or
legacy_ordering_storage_contradiction; cycle findings attach that evidence
instead of leaving agents to re-derive the storage cause. Mutation advisories
also identify a newly introduced contradiction before reporting its derived
cycle. Coverage policy is type-aware: the audit profile's
coverage_by_type breaks active/isolated/degree≤1 counts down per item type
(untyped items under (untyped)), and isolateExemptTypes
(--exempt-isolate-type) suppresses isolate/sparse findings for types whose
disconnection is policy-valid without changing profile counts.
--save-baseline persists the audit census through
saveGraphAuditBaseline, and later audits attach the signed
diffRelationshipAuditSnapshots drift (baseline block) — the temporal
comparison primitive for census tracking. All subcommands resolve the
workspace assembly through the shared fingerprint-keyed graph cache
(WorkspaceGraphCache): the fingerprint digests every
relationship-relevant item field (item type included, powering the per-type
coverage), so unchanged workspaces in long-lived hosts reuse the assembled
graph and memoized query results, and every envelope reports cache
hit/miss observability next to its explicit truncated and cost metadata.
One-shot processes additionally reuse the durable fingerprint-keyed index at
runtime/graph-cache.json (pm graph index status/--rebuild/--clear;
automatic persistence at ≥500 items, opt-in below via rebuild): atomic
last-write-wins envelopes, corrupt-tolerant decode, never authoritative —
every entry rebuilds from item storage on fingerprint mismatch, and
envelopes report the cache.durable disposition. Ids resolve
case-insensitively; --summary returns envelopes without row collections.
The graph fingerprint consumes the item-metadata-derived index, whose metadata,
body, and collection tiers plus collapsed mutation delta publish one effective
source cursor. Supported item mutations serialize authoritative writes with a
bounded derived-index projection, so a long-lived SDK host or later CLI process
observes the committed relationship fields without paying for a full source
scan or rewriting the whole index. Cursor disagreement, an invalid projection
path, or any refresh failure invalidates the rebuildable base/delta state; the
next graph read source-scans and reconstructs both indexes. Package-owned
storage adapters can preserve this contract with the public
acquireItemMetadataDerivedIndexLock and
refreshItemMetadataDerivedIndex SDK primitives.
Mutation advisories reuse the same cycle semantics incrementally:
collectNewOrderingCycleWarnings builds a lightweight ordering digraph
directly from the before/after item snapshots (no full workspace assembly)
and scopes collectOrderingCycles to the changed item's weakly connected
ordering component — exact for any cycle containing the changed item, and no
longer paying two whole-graph SCC analyses per dependency-bearing mutation.
Compatibility and migration
Aliases normalize at registry boundaries; stored values are not silently rewritten. Imports must carry or select a compatible registry version. Federation merges definitions before edges and rejects identifier or alias collisions. Rollback removes the custom definition and its derived index only after application-owned edges have been exported or superseded; immutable history is retained.
planRelationshipEventBackfill(items, options) is the side-effect-free bridge from legacy item parent, scalar blocked_by, and structured dependencies fields to the event platform. It uses the same workspace assembly and registry semantics as graph queries, so custom kinds, aliases, external targets, case normalization, duplicate-row governance, and explicit missing-target placeholders cannot drift between migration and reads. Input order does not affect the SHA-256 plan fingerprint, deterministic event ids, relationship ids, node universe, or event order. The plan reports total normalized edges, raw duplicate dependency groups, dangling internal references, and ids skipped through existingEventIds before any write occurs.
For a resumable migration, keep migrationId, author, and timestamp stable, inspect the plan evidence, open the store with plan.nodes, and publish with appendBatch(..., { existingEventPolicy: "skip_identical" }). Changing semantic input under the same migration id intentionally produces an event-id collision instead of silently rewriting history. A plan is not an authority transfer: legacy item/history files remain authoritative until the application explicitly switches its read adapter and records that cutover.
Validation rejects missing endpoints, disallowed self-edges, cardinality violations at mutation boundaries, ordering-only cycles, and incompatible aliases or versions. Immutable graph snapshots deduplicate canonical edges deterministically, retaining the last supplied edge; mutation boundaries may reject duplicates before snapshot construction. Evidence freshness and application payload schemas are extension policy: the core preserves payloads but does not invent domain meaning.
Consequences and non-goals
CLI and MCP layers can remain thin consumers of the same SDK semantics, and non-PM applications can register domain relationships without patching core enums. The registry does not infer edges, choose relevance weights, mandate a large persistent index for scratch projects, or make distributed conflict resolution automatic.