diff --git a/decisions/designs/corpus-federation-mechanism.md b/decisions/designs/corpus-federation-mechanism.md
index eca25956..6206e32a 100644
--- a/decisions/designs/corpus-federation-mechanism.md
+++ b/decisions/designs/corpus-federation-mechanism.md
@@ -145,6 +145,19 @@ unsupported transitive declaration, and a stale digest are distinct stable
error findings. No parent artifact enters the effective corpus until all four
checks pass.
+The version-one byte contract is fixed. The hash begins with the ASCII domain
+separator `asdecided-corpus-digest-v1\0`. Every following value is framed as a
+one-byte tag, an unsigned 64-bit big-endian byte length, and the exact value
+bytes. Tag `0x01` carries the UTF-8 parent source, tag `0x02` carries the exact
+governing config bytes, and each Markdown file contributes tag `0x03` with its
+corpus-relative POSIX UTF-8 path followed by tag `0x04` with its exact content
+bytes. Files are ordered by those path bytes. No newline, Unicode, YAML, or
+Markdown normalisation occurs. The operator surface is the read-only command
+`decided corpus digest --root --corpus `; it reads
+the source from the bounded parent config and prints `sha256:` followed by 64
+lowercase hexadecimal characters. The command never edits the manifest or
+parent and never performs network I/O.
+
### One source-aware read model
The engine introduces source-aware equivalents of its path-only concepts:
@@ -221,6 +234,12 @@ An absent, ambiguous, cross-type, retired-rationale, or parent-to-parent
mapping is a validation error. There is no implicit child-wins or parent-wins
rule.
+All three override operands are canonical IDs. `parent` is qualified; `with`
+and `rationale` are canonical local IDs and do not accept aliases. An override
+redirects only the parent's canonical ID in the effective unqualified view;
+it does not turn the parent's legacy or title aliases into aliases of the
+replacement.
+
### Validation semantics
The parent is validated as a source corpus before overlay. A structural or
@@ -261,12 +280,25 @@ They do not copy artifact bodies, excerpts, override mappings, or the full
response provenance. Because ADR-127 pins the current path-only shape, ADR-141
must amend that decision explicitly before these fields ship.
+Public `path` values remain corpus-relative paths within the artifact's owning
+source; they never expose or encode the vendored or submodule checkout path.
+The accompanying `source` disambiguates equal paths across layers. Federated
+CLI and MCP records carry `source`, `layer`, and `pin` in their existing
+provenance object, with `pin` present only for inherited records. Export
+records carry the same facts in their projection-specific metadata. A
+repository without a manifest retains its existing shapes byte for byte.
+
Default reads use the effective combined corpus. Human-facing diagnostic and
export commands may request `--local-only` to inspect the child layer. MCP and
enforcement do not expose a local-only bypass: an agent connected to a
federated repository and `decided gate --code` both receive inherited
governance.
+The first increment exposes `--local-only` on viewer, documents, and graph
+exports. Other human diagnostic reads may adopt the same projection later;
+they are not required for the first implementation and must never weaken MCP,
+routing, or enforcement.
+
### Code scope and enforcement
Inherited live decisions participate in `decisions-for`,
diff --git a/decisions/requirements/corpus-source-identity.md b/decisions/requirements/corpus-source-identity.md
index c0364882..5822c40e 100644
--- a/decisions/requirements/corpus-source-identity.md
+++ b/decisions/requirements/corpus-source-identity.md
@@ -7,7 +7,7 @@ type: requirement
## Status
-Proposed
+Accepted
Classification: `[internal]` — merge N corpora with zero collisions and give
federated artifacts one source identity across every surface. Feature E of the
diff --git a/docs/cli.md b/docs/cli.md
index 7aad7df7..3173d47a 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -34,6 +34,44 @@ These apply across every command.
---
+## corpus digest
+
+Calculate the canonical pin for a parent corpus that is already materialised
+on disk. The command is read-only: it does not clone, fetch, update, write, or
+repin the parent.
+
+```bash
+decided corpus digest --root vendor/standards --corpus decisions
+```
+
+`--root` is the parent repository root and bounds configuration discovery to
+exactly `/.decided/config.yaml`; the command never inherits a config from
+an ancestor. `--corpus` is a relative directory below that root. The config
+must declare an explicit valid `corpus.source`. On success stdout is exactly a
+full lowercase pin followed by a newline:
+
+```text
+sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325
+```
+
+Digest version 1 hashes the fixed domain bytes
+`asdecided-corpus-digest-v1\0`, then length-framed records. Each record is a
+one-byte tag, an unsigned 64-bit big-endian byte length, and the raw payload:
+
+1. tag `0x01`: parent `corpus.source` UTF-8 bytes;
+2. tag `0x02`: exact governing `.decided/config.yaml` bytes; then
+3. for every discovered Markdown file in corpus-relative POSIX UTF-8 path
+ order, tag `0x03` for the path bytes and tag `0x04` for its exact content
+ bytes.
+
+Checkout location, timestamps, filesystem iteration order, hidden paths, and
+non-`.md` files do not enter the digest. Absolute or `..` corpus paths, path
+escape, and traversed symlinks are rejected with stable `parent-corpus-*`
+errors. Exit `0` means the digest was calculated; exit `1` means the bounded
+materialisation could not be safely snapshotted.
+
+---
+
## validate
Validate an artifact — or every artifact in a directory — for structural and
@@ -807,17 +845,25 @@ artifacts — existing output is overwritten.
- **Input:** `decided export [directory]` — scanned recursively for `*.md` (default: current directory).
- **Modes:** *(default)* viewer JSON to stdout · `--html` (self-contained Portal file) · `--okf` (OKF v0.2 Markdown bundle) · `--documents` (JSONL for memory/RAG backends) · `--graph` (typed node+edge JSON for graph backends) · `--schema ` (the packaged JSON Schema, without reading a corpus) · `--agent-rules` (per-client agent-context files; see its own behaviour)
-- **Options:** `--out ` (only `--html`/`--okf`/`--agent-rules`; the stdout modes are pipeable) · `--json` (no-op for the default mode)
+- **Options:** `--out ` (only `--html`/`--okf`/`--agent-rules`; the stdout modes are pipeable) · `--json` (no-op for the default mode) · `--local-only` (viewer/HTML, documents, and graph projections only)
- **Exit codes:** `0` success · `2` not a directory, or `--out` given to a stdout mode
```bash
decided export decisions/ # viewer JSON to stdout
decided export decisions/ --documents # JSONL, one record per artifact
decided export decisions/ --graph # typed node+edge graph
+decided export decisions/ --local-only # writable child records only
decided export --schema documents # Draft 2020-12 record schema
decided export decisions/ --html --out asdecided.html
```
+When `.decided/corpus.md` declares a verified parent, viewer, documents, and
+graph exports include inherited records by default. Records and edges carry
+their own source, layer, and verified-pin provenance; explicit overrides retain
+both the parent history and local replacement. `--local-only` is a human
+diagnostic/export projection of the writable child records. OKF bundles and
+generated agent rules remain local-only and do not accept the flag.
+
The three machine-readable payload contracts, compatibility rules, and direct
schema links are documented on the [Export contracts](export-contracts.md)
page.
diff --git a/docs/export-contracts.md b/docs/export-contracts.md
index eea0f42c..5a4bf100 100644
--- a/docs/export-contracts.md
+++ b/docs/export-contracts.md
@@ -29,8 +29,10 @@ The default `decided export` projection is one JSON object containing:
- `schema_version`
- `corpus`: `name`, `source`, `rac_version`, and `artifact_count`
- `artifacts[]`: `id`, `aliases`, `type`, `status`, `title`, `path`, and
- `body_html`
-- `relationships[]`: `from`, `to`, and the flattened `relates-to` `type`
+ `body_html`; manifest-backed exports add record `provenance`
+- `relationships[]`: `from`, `to`, and the flattened `relates-to` `type`;
+ manifest-backed exports add source-aware `from_identity`, `to_identity`, and
+ edge `provenance`
`rac_version` is a retained v1 machine key. It carries the version of the
AsDecided CLI that produced the payload; it is not a current product or command
@@ -47,7 +49,8 @@ non-empty line is independently validated against the documents schema and
contains:
- `schema_version`, `id`, `type`, `status`, `title`, and Markdown `text`
-- `metadata`: `path`, `aliases`, `tags`, and `source`
+- `metadata`: `path`, `aliases`, `tags`, and the record-owning `source`;
+ manifest-backed exports add `provenance`
The schema describes one line. A consumer should split the UTF-8 stream on line
boundaries and validate each record separately.
@@ -57,7 +60,9 @@ boundaries and validate each record separately.
`decided export --graph` is one JSON object containing `schema_version`,
`source`, `nodes`, and `edges`. Nodes carry `id`, `type`, `status`, and `title`.
Edges carry `source`, `target`, `type`, `directed`, `resolved`, `external`, and
-nullable `provider` provenance.
+nullable `provider` provenance. Manifest-backed nodes and edges add
+`provenance`; edges also add source-aware `source_identity` and nullable
+`target_identity` objects alongside the retained ID fields.
The graph edge `type` is the engine's real relationship kind. It is not the
viewer projection's flattened `relates-to` value.
@@ -85,11 +90,18 @@ For a non-federated export, AsDecided derives the source in this order:
2. the lower-case `repository_key`;
3. the existing corpus-directory basename when neither value is configured.
-The viewer exposes the value as `corpus.source`. Documents records expose it
-as `metadata.source`; the graph exposes it as its top-level `source`. A graph
-edge's own `source` field remains the source *node ID* and is not corpus
-provenance. `corpus.name` remains the existing display value and is not an
-identity.
+The viewer exposes the child value as `corpus.source`. Documents records expose
+their owning value as `metadata.source`; the graph retains the child value as
+its top-level `source`. In a manifest-backed export, each record's `provenance`
+object carries its own `source` and `layer`, plus the full verified `pin` for an
+inherited record. A graph edge's existing `source` field remains the source
+*node ID* and is not corpus provenance. `corpus.name` remains the existing
+display value and is not an identity.
+
+Override provenance is an ordered `provenance.overrides` array. Each entry
+names its `overridden` or `replacement` role and the source-aware `parent`,
+`replacement`, and live local `rationale` identities. The original inherited
+record and its local replacement are both exported.
The repository key continues to namespace newly generated artifact IDs. It is
not globally unique, and different corpora may legitimately use the same key.
@@ -99,11 +111,10 @@ never relies on either fallback.
## Aggregating corpora
Consumers aggregate documents streams by concatenating their records and
-keying each artifact on `(metadata.source, id)`. They aggregate graph exports
-by lifting the graph's top-level source onto every node: a node key is
-`(graph.source, node.id)`, and each edge endpoint is resolved in that same
-namespace before the node and edge sets are unioned. The viewer's
-`corpus.source` provides the equivalent namespace for its artifacts.
+keying each artifact on `(metadata.source, id)`. For a manifest-backed viewer
+or graph export, use `(provenance.source, id)` on every artifact or node and the
+explicit source-aware identity objects on edges. The top-level child source
+remains the fallback namespace for a non-federated payload.
Configure distinct explicit sources whenever repository-key or basename
fallbacks could collide. Source identity alone does not make cross-corpus
@@ -116,6 +127,10 @@ canonical ID, record body, and verified pin all agree. A different body or pin
for the same `(source, id)` is an aggregation conflict, never a
last-writer-wins update.
+Viewer, documents, and graph exports include the inherited layer by default.
+`--local-only` requests the child projection for these modes only. OKF bundles
+and generated agent rules remain local-only in the first federation increment.
+
### Migration from basename sources
Before this contract, documents and graph exports stamped the corpus-directory
@@ -135,8 +150,10 @@ All schema objects allow unknown additional properties. This is intentional:
an additive producer release must remain readable by an existing consumer.
Consumers should ignore fields they do not understand.
-Every field emitted today is nevertheless declared and required. Removing a
-required field, changing its type incompatibly, or changing its meaning is a
-breaking contract change and requires a `schema_version` bump plus a new
-versioned schema file. Adding a field requires updating the current schema and
-its producer drift test in the same change.
+Every unconditional field emitted today is nevertheless declared and required.
+Federation-only properties are declared but optional so a no-manifest payload
+retains its released bytes. Removing a required field, changing its type
+incompatibly, or changing its meaning is a breaking contract change and
+requires a `schema_version` bump plus a new versioned schema file. Adding a
+field requires updating the current schema and its producer drift test in the
+same change.
diff --git a/rac-localview/VIEWER_CONTRACT.md b/rac-localview/VIEWER_CONTRACT.md
index b99ac55c..9df92091 100644
--- a/rac-localview/VIEWER_CONTRACT.md
+++ b/rac-localview/VIEWER_CONTRACT.md
@@ -32,7 +32,11 @@ A single JSON document, as emitted by `decided export --json`.
"status": "Accepted",
"title": "ADR-027: CI test topology",
"path": "decisions/decisions/adr-027-ci-test-topology.md",
- "body_html": "…
"
+ "body_html": "…
",
+ "provenance": {
+ "source": "asdecided/core",
+ "layer": "local"
+ }
}
],
"relationships": [
@@ -68,13 +72,14 @@ Ordered by `path`.
| field | type | meaning |
| ----------- | -------- | ------------------------------------------------------ |
-| `id` | string | Opaque stable artifact ID, unique within the corpus (`RAC-KTQ63DSC8SZW`). |
+| `id` | string | Opaque stable artifact ID, unique within its owning source (`RAC-KTQ63DSC8SZW`). |
| `aliases` | string[] | Human aliases as emitted by Core identity, e.g. `["adr-027", "adr-027-ci-test-topology"]`. May be empty. |
| `type` | string | Artifact family (`decision`, `requirement`, …). Open set; the viewer derives its type filter from the values present. |
| `status` | string | Lifecycle status in its authored casing (`Accepted`, `Proposed`, `Superseded`, …). Open set — see case handling below. |
| `title` | string | Plain text. |
| `path` | string | Source path within the repository. Shown as a muted provenance line on the detail view. |
| `body_html` | string | The artifact body **rendered to HTML at export time** (see trust model). |
+| `provenance` | object | Optional on legacy/no-manifest payloads. Manifest-backed records carry owning `source`, `layer`, inherited `pin`, and any ordered override mappings. |
#### Alias display
@@ -83,7 +88,10 @@ a **display name**: deterministically, the first alias that differs
from the `id`, else the `id` itself. The display name is used on list
rows, the detail heading, and related-artifact links; the opaque `id`
stays visible on the detail view's provenance line (alongside `path`)
-and remains the routing key (`#/artifact/`).
+and remains the legacy routing key (`#/artifact/`). For manifest-backed
+records, the viewer keys and routes on `(provenance.source, id)`, encoded as one
+`::` hash segment. This retains both records in a valid same-ID
+override. A payload without provenance keeps its exact bare-ID routes.
#### Status case handling
@@ -96,11 +104,14 @@ render plain.
### `relationships[]` — edges
-Each edge is `{ "from": ID, "to": ID-or-alias, "type": string }` and
-reads "`from` `type` `to`". Ordered by (from, to). Core emits **only**
-`relates-to`; richer edge typing is a future Core decision. `to` may be
-an unresolved alias preserved verbatim — the viewer renders those as
-"(not in corpus)" rather than dropping them.
+Each edge retains `{ "from": ID, "to": ID-or-alias, "type": string }` and
+reads "`from` `type` `to`". A manifest-backed edge also carries
+`from_identity` and nullable `to_identity` `{source,id}` objects plus the
+declaring artifact's provenance. The viewer uses those identities for graph,
+inbound/outbound, and detail links. Ordered by source-aware endpoint identity.
+Core emits **only** `relates-to`; richer edge typing is a future Core decision.
+`to` may be an unresolved alias preserved verbatim — the viewer renders those
+as "(not in corpus)" rather than dropping them.
The type set stays open for forward compatibility. The viewer keeps
inverse labels for types a future Core might emit (accepted if they
@@ -216,7 +227,7 @@ cited ids and aliases in text nodes are linkified). The viewer performs
## 4. Viewer behaviour summary
- Read-only; no router dependency — state is hash-based
- (`#/` list, `#/artifact/` detail) so deep links work from
+ (`#/` list, `#/artifact/` detail) so deep links work from
`file://`.
- List view: every artifact as a row (display name + title + chips);
filter toggles for type and status derived from the corpus (status
diff --git a/rac-localview/src/viewer/App.tsx b/rac-localview/src/viewer/App.tsx
index 32791e4f..4a57d015 100644
--- a/rac-localview/src/viewer/App.tsx
+++ b/rac-localview/src/viewer/App.tsx
@@ -64,21 +64,24 @@ export function App() {
() => (data ? buildIndex(data) : null),
[data],
);
+ const indexRef = useRef(index);
+ indexRef.current = index;
// Editor-host bridge (v0.21.7): announce readiness and apply the host's
// reveal requests. Inert in a standalone Portal (no host).
useEffect(() => {
const unsubscribe = onRevealArtifact((id) => {
- setActiveId(id);
+ const key = indexRef.current?.citationLookup.get(id.toLowerCase()) ?? id;
+ setActiveId(key);
// In the graph view a reveal just roots/highlights the node; it does not
// navigate away. Elsewhere it opens the detail page, as before.
if (viewRef.current === 'graph') return;
- const target = `#/artifact/${encodeURIComponent(id)}`;
+ const target = `#/artifact/${encodeURIComponent(key)}`;
if (window.location.hash === target) {
revealedRef.current = null; // already here — nothing to suppress
return;
}
- revealedRef.current = id;
+ revealedRef.current = key;
window.location.hash = target;
});
postReady();
diff --git a/rac-localview/src/viewer/DetailView.tsx b/rac-localview/src/viewer/DetailView.tsx
index 7c80e0ca..49a42222 100644
--- a/rac-localview/src/viewer/DetailView.tsx
+++ b/rac-localview/src/viewer/DetailView.tsx
@@ -1,7 +1,12 @@
import { useEffect, useRef } from 'react';
import { KeyboardHint, Panel } from '../components';
import type { CorpusIndex } from './data';
-import { displayName, linkifyCitations } from './data';
+import {
+ displayName,
+ linkifyCitations,
+ relationshipSourceKey,
+ relationshipTargetKey,
+} from './data';
import type { Relationship } from './types';
import { ArtifactChips } from './chips';
@@ -55,10 +60,14 @@ function RelatedGroup({ heading, edges, index, direction }: RelatedGroupProps) {
diff --git a/rac-localview/src/viewer/ListView.tsx b/rac-localview/src/viewer/ListView.tsx
index b84f247c..c23347e4 100644
--- a/rac-localview/src/viewer/ListView.tsx
+++ b/rac-localview/src/viewer/ListView.tsx
@@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react';
import { KeyboardHint } from '../components';
import type { CorpusIndex } from './data';
-import { displayName } from './data';
+import { artifactKey, displayName } from './data';
import type { Artifact } from './types';
import { ArtifactChips } from './chips';
@@ -38,7 +38,7 @@ const Row = memo(function Row({ artifact }: { artifact: Artifact }) {
{displayName(artifact)}
{artifact.title}
@@ -149,7 +149,7 @@ export function ListView({ index, filters, onFilters, searchRef }: ListViewProps
{visible.length > 0 ? (
{visible.map((artifact) => (
-
+
))}
) : (
diff --git a/rac-localview/src/viewer/data.ts b/rac-localview/src/viewer/data.ts
index 082c8f10..9ef43b7c 100644
--- a/rac-localview/src/viewer/data.ts
+++ b/rac-localview/src/viewer/data.ts
@@ -9,7 +9,12 @@
* committed sample corpus as an asset.
*/
-import type { Artifact, AsDecidedExport, Relationship } from './types';
+import type {
+ Artifact,
+ AsDecidedExport,
+ Relationship,
+ SourceIdentity,
+} from './types';
export async function loadExport(): Promise {
const inline = document.getElementById('lore-export');
@@ -37,9 +42,38 @@ export function displayName(artifact: Artifact): string {
return artifact.id;
}
+/**
+ * One unambiguous in-viewer key. Source names cannot contain `:`, so the
+ * qualified form is reversible and remains safe as one encoded hash segment.
+ * Legacy payloads deliberately retain their exact bare-id routes.
+ */
+export function sourceIdentityKey(identity: SourceIdentity): string {
+ return `${identity.source}::${identity.id}`;
+}
+
+export function artifactKey(artifact: Artifact): string {
+ return artifact.provenance
+ ? sourceIdentityKey({ source: artifact.provenance.source, id: artifact.id })
+ : artifact.id;
+}
+
+export function relationshipSourceKey(relationship: Relationship): string {
+ return relationship.from_identity
+ ? sourceIdentityKey(relationship.from_identity)
+ : relationship.from;
+}
+
+export function relationshipTargetKey(relationship: Relationship): string {
+ return relationship.to_identity
+ ? sourceIdentityKey(relationship.to_identity)
+ : relationship.to;
+}
+
/** One artifact plus everything precomputed for list/search/detail. */
export interface IndexedArtifact {
artifact: Artifact;
+ /** Source-aware route/index key, or the exact legacy id. */
+ key: string;
/** Lowercased id + aliases + title + body text, for search. */
haystack: string;
}
@@ -47,6 +81,7 @@ export interface IndexedArtifact {
export interface CorpusIndex {
data: AsDecidedExport;
rows: IndexedArtifact[];
+ /** Source-aware route key -> artifact; legacy payloads remain keyed by id. */
byId: Map;
/** Distinct artifact types, in first-seen order. */
types: string[];
@@ -66,18 +101,47 @@ const WS_RE = /\s+/g;
export function buildIndex(data: AsDecidedExport): CorpusIndex {
const byId = new Map();
const citationLookup = new Map();
+ const ambiguousCitations = new Set();
+ const federated = data.artifacts.some((artifact) => artifact.provenance !== undefined);
const types: string[] = [];
const statuses: string[] = [];
const statusKeys = new Set();
const rows: IndexedArtifact[] = [];
+ const registerCitation = (token: string, target: string) => {
+ const normalized = token.toLowerCase();
+ if (!federated) {
+ if (!citationLookup.has(normalized)) citationLookup.set(normalized, target);
+ return;
+ }
+ if (ambiguousCitations.has(normalized)) return;
+ const existing = citationLookup.get(normalized);
+ if (existing && existing !== target) {
+ citationLookup.delete(normalized);
+ ambiguousCitations.add(normalized);
+ } else if (!existing) {
+ citationLookup.set(normalized, target);
+ }
+ };
+
for (const artifact of data.artifacts) {
const aliases = artifact.aliases ?? [];
- byId.set(artifact.id, artifact);
- citationLookup.set(artifact.id.toLowerCase(), artifact.id);
- for (const alias of aliases) {
- const key = alias.toLowerCase();
- if (!citationLookup.has(key)) citationLookup.set(key, artifact.id);
+ const key = artifactKey(artifact);
+ byId.set(key, artifact);
+ const overridden = artifact.provenance?.overrides?.some(
+ (mapping) => mapping.state === 'overridden',
+ );
+ if (!overridden) {
+ registerCitation(artifact.id, key);
+ for (const alias of aliases) registerCitation(alias, key);
+ for (const mapping of artifact.provenance?.overrides ?? []) {
+ if (mapping.state === 'replacement') {
+ // The composed resolver redirects the overridden parent's canonical
+ // id to its local replacement. Parent aliases deliberately remain
+ // source-qualified history rather than implicit redirects.
+ registerCitation(mapping.parent.id, key);
+ }
+ }
}
if (!types.includes(artifact.type)) types.push(artifact.type);
const statusKey = artifact.status.toLowerCase();
@@ -88,19 +152,22 @@ export function buildIndex(data: AsDecidedExport): CorpusIndex {
const bodyText = artifact.body_html.replace(TAG_RE, ' ').replace(WS_RE, ' ');
rows.push({
artifact,
- haystack: `${artifact.id} ${aliases.join(' ')} ${artifact.title} ${bodyText}`.toLowerCase(),
+ key,
+ haystack: `${artifact.id} ${artifact.provenance?.source ?? ''} ${aliases.join(' ')} ${artifact.title} ${bodyText}`.toLowerCase(),
});
}
const outbound = new Map();
const inbound = new Map();
for (const edge of data.relationships) {
- const out = outbound.get(edge.from);
+ const from = relationshipSourceKey(edge);
+ const to = relationshipTargetKey(edge);
+ const out = outbound.get(from);
if (out) out.push(edge);
- else outbound.set(edge.from, [edge]);
- const inn = inbound.get(edge.to);
+ else outbound.set(from, [edge]);
+ const inn = inbound.get(to);
if (inn) inn.push(edge);
- else inbound.set(edge.to, [edge]);
+ else inbound.set(to, [edge]);
}
return {
diff --git a/rac-localview/src/viewer/graph.ts b/rac-localview/src/viewer/graph.ts
index 724c8f83..92519317 100644
--- a/rac-localview/src/viewer/graph.ts
+++ b/rac-localview/src/viewer/graph.ts
@@ -8,7 +8,12 @@
*/
import type { Artifact, AsDecidedExport } from './types';
-import { displayName } from './data';
+import {
+ artifactKey,
+ displayName,
+ relationshipSourceKey,
+ relationshipTargetKey,
+} from './data';
export interface GraphNode {
id: string;
@@ -89,19 +94,21 @@ export function buildGraph(data: AsDecidedExport): Graph {
return node;
};
- for (const artifact of data.artifacts) ensure(artifact.id, artifact);
+ for (const artifact of data.artifacts) ensure(artifactKey(artifact), artifact);
const edges: GraphEdge[] = [];
for (const rel of data.relationships) {
- const from = byId.get(rel.from);
+ const fromId = relationshipSourceKey(rel);
+ const toId = relationshipTargetKey(rel);
+ const from = byId.get(fromId);
if (!from) continue; // a from-id outside the corpus cannot be placed
- const target = ensure(rel.to); // creates a dangling node when unresolved
- const edge: GraphEdge = { from: rel.from, to: rel.to, unresolved: target.unresolved };
+ const target = ensure(toId); // creates a dangling node when unresolved
+ const edge: GraphEdge = { from: fromId, to: toId, unresolved: target.unresolved };
edges.push(edge);
from.degree += 1;
target.degree += 1;
- adjacency.get(rel.from)!.add(rel.to);
- adjacency.get(rel.to)!.add(rel.from);
+ adjacency.get(fromId)!.add(toId);
+ adjacency.get(toId)!.add(fromId);
}
return { nodes: [...byId.values()], edges, byId, adjacency };
diff --git a/rac-localview/src/viewer/types.ts b/rac-localview/src/viewer/types.ts
index 8100315f..ef1d2176 100644
--- a/rac-localview/src/viewer/types.ts
+++ b/rac-localview/src/viewer/types.ts
@@ -16,8 +16,27 @@ export interface CorpusMeta {
sample?: boolean;
}
+export interface SourceIdentity {
+ source: string;
+ id: string;
+}
+
+export interface OverrideProvenance {
+ state: 'overridden' | 'replacement';
+ parent: SourceIdentity;
+ replacement: SourceIdentity;
+ rationale: SourceIdentity;
+}
+
+export interface ArtifactProvenance {
+ source: string;
+ layer: 'local' | 'inherited';
+ pin?: string;
+ overrides?: OverrideProvenance[];
+}
+
export interface Artifact {
- /** Opaque stable artifact ID, e.g. "RAC-KTQ63DSC8SZW". Unique. */
+ /** Opaque stable artifact ID, unique within its owning source. */
id: string;
/** Human aliases, e.g. ["adr-027", "adr-027-ci-test-topology"]. */
aliases: string[];
@@ -31,6 +50,8 @@ export interface Artifact {
path: string;
/** Body rendered to HTML at export time. Trusted — see contract. */
body_html: string;
+ /** Present for manifest-backed exports; owns global record identity. */
+ provenance?: ArtifactProvenance;
}
export interface Relationship {
@@ -40,6 +61,11 @@ export interface Relationship {
to: string;
/** Edge type. Core emits only "relates-to"; the set stays open. */
type: string;
+ /** Source-aware endpoints on manifest-backed exports. */
+ from_identity?: SourceIdentity;
+ to_identity?: SourceIdentity | null;
+ /** Provenance of the artifact which declared this edge. */
+ provenance?: ArtifactProvenance;
}
export interface AsDecidedExport {
diff --git a/rac-localview/test/App.test.tsx b/rac-localview/test/App.test.tsx
index 9c51d4cb..1885ceaa 100644
--- a/rac-localview/test/App.test.tsx
+++ b/rac-localview/test/App.test.tsx
@@ -3,7 +3,12 @@ import { act } from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { App } from '../src/viewer/App';
-import { fixtureExport, HUB_ID } from './fixtures';
+import {
+ federatedOverrideExport,
+ fixtureExport,
+ HUB_ID,
+ PARENT_SHARED_KEY,
+} from './fixtures';
// Mount the real viewer in jsdom against an injected export, at each route.
// A smoke net: the v0.21.8 graph bug shipped because nothing mounted the viewer.
@@ -60,4 +65,13 @@ describe('viewer App', () => {
expect(container.querySelector('.viewer-detail')).toBeTruthy();
expect(container.textContent).toContain('Hub decision');
});
+
+ it('routes to the inherited side of a same-id override by source', async () => {
+ const seam = document.getElementById('lore-export');
+ seam!.textContent = JSON.stringify(federatedOverrideExport);
+
+ await mountAt(`#/artifact/${encodeURIComponent(PARENT_SHARED_KEY)}`);
+ expect(container.textContent).toContain('Inherited parent policy');
+ expect(container.textContent).not.toContain('Local replacement');
+ });
});
diff --git a/rac-localview/test/federation.test.ts b/rac-localview/test/federation.test.ts
new file mode 100644
index 00000000..583cbac2
--- /dev/null
+++ b/rac-localview/test/federation.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { buildIndex } from '../src/viewer/data';
+import { buildGraph } from '../src/viewer/graph';
+import {
+ DIFFERENT_PARENT_ID,
+ DIFFERENT_REPLACEMENT_KEY,
+ federatedDifferentIdOverrideExport,
+ federatedOverrideExport,
+ fixtureExport,
+ HUB_ID,
+ LOCAL_SHARED_KEY,
+ PARENT_SHARED_KEY,
+ SHARED_ID,
+} from './fixtures';
+
+describe('federated viewer identity', () => {
+ it('retains both sides of a same-id override under source-aware keys', () => {
+ const index = buildIndex(federatedOverrideExport);
+
+ expect(index.rows).toHaveLength(3);
+ expect(index.byId.size).toBe(3);
+ expect(index.byId.get(LOCAL_SHARED_KEY)?.title).toBe('Local replacement');
+ expect(index.byId.get(PARENT_SHARED_KEY)?.title).toBe(
+ 'Inherited parent policy',
+ );
+ expect(index.citationLookup.get(SHARED_ID.toLowerCase())).toBe(
+ LOCAL_SHARED_KEY,
+ );
+ expect(index.outbound.get(LOCAL_SHARED_KEY)).toHaveLength(1);
+ expect(index.outbound.get(PARENT_SHARED_KEY)).toHaveLength(1);
+ });
+
+ it('builds distinct graph nodes and source-aware edges', () => {
+ const graph = buildGraph(federatedOverrideExport);
+
+ expect(graph.nodes).toHaveLength(3);
+ expect(graph.byId.get(LOCAL_SHARED_KEY)?.title).toBe('Local replacement');
+ expect(graph.byId.get(PARENT_SHARED_KEY)?.title).toBe(
+ 'Inherited parent policy',
+ );
+ expect(graph.edges.filter((edge) => edge.from === LOCAL_SHARED_KEY)).toHaveLength(1);
+ expect(graph.edges.filter((edge) => edge.from === PARENT_SHARED_KEY)).toHaveLength(1);
+ });
+
+ it('redirects an overridden parent canonical id to a different-id replacement', () => {
+ const index = buildIndex(federatedDifferentIdOverrideExport);
+
+ expect(index.citationLookup.get(DIFFERENT_PARENT_ID.toLowerCase())).toBe(
+ DIFFERENT_REPLACEMENT_KEY,
+ );
+ expect(index.citationLookup.has('parent-policy-alias')).toBe(false);
+ });
+
+ it('keeps bare-id keys exact for a legacy payload', () => {
+ const index = buildIndex(fixtureExport);
+ expect(index.byId.get(HUB_ID)?.title).toBe('Hub decision');
+
+ const graph = buildGraph(fixtureExport);
+ expect(graph.byId.get(HUB_ID)?.title).toBe('Hub decision');
+ });
+});
diff --git a/rac-localview/test/fixtures.ts b/rac-localview/test/fixtures.ts
index 1c4dd979..8fca4198 100644
--- a/rac-localview/test/fixtures.ts
+++ b/rac-localview/test/fixtures.ts
@@ -27,3 +27,158 @@ export const fixtureExport: AsDecidedExport = {
};
export const HUB_ID = 'RAC-HUB000000001';
+
+export const SHARED_ID = 'RAC-SHARED000001';
+export const LOCAL_SHARED_KEY = `acme/app::${SHARED_ID}`;
+export const PARENT_SHARED_KEY = `acme/standards::${SHARED_ID}`;
+const RATIONALE_ID = 'RAC-RATIONALE001';
+const PIN = 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
+
+const override = {
+ parent: { source: 'acme/standards', id: SHARED_ID },
+ replacement: { source: 'acme/app', id: SHARED_ID },
+ rationale: { source: 'acme/app', id: RATIONALE_ID },
+} as const;
+
+/** Same canonical id in two sources, authorised by an explicit override. */
+export const federatedOverrideExport: AsDecidedExport = {
+ schema_version: '1',
+ corpus: {
+ name: 'federated-fixture',
+ source: 'acme/app',
+ rac_version: '0.0.0-test',
+ artifact_count: 3,
+ },
+ artifacts: [
+ {
+ id: SHARED_ID,
+ aliases: ['local-policy'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Local replacement',
+ path: 'replacement.md',
+ body_html: 'local replacement
',
+ provenance: {
+ source: 'acme/app',
+ layer: 'local',
+ overrides: [{ state: 'replacement', ...override }],
+ },
+ },
+ {
+ id: RATIONALE_ID,
+ aliases: ['override-rationale'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Override rationale',
+ path: 'rationale.md',
+ body_html: `${SHARED_ID}
`,
+ provenance: { source: 'acme/app', layer: 'local' },
+ },
+ {
+ id: SHARED_ID,
+ aliases: ['parent-policy'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Inherited parent policy',
+ path: 'policy.md',
+ body_html: 'parent history
',
+ provenance: {
+ source: 'acme/standards',
+ layer: 'inherited',
+ pin: PIN,
+ overrides: [{ state: 'overridden', ...override }],
+ },
+ },
+ ],
+ relationships: [
+ {
+ from: SHARED_ID,
+ to: RATIONALE_ID,
+ type: 'relates-to',
+ from_identity: { source: 'acme/app', id: SHARED_ID },
+ to_identity: { source: 'acme/app', id: RATIONALE_ID },
+ provenance: {
+ source: 'acme/app',
+ layer: 'local',
+ overrides: [{ state: 'replacement', ...override }],
+ },
+ },
+ {
+ from: SHARED_ID,
+ to: RATIONALE_ID,
+ type: 'relates-to',
+ from_identity: { source: 'acme/standards', id: SHARED_ID },
+ to_identity: { source: 'acme/app', id: RATIONALE_ID },
+ provenance: {
+ source: 'acme/standards',
+ layer: 'inherited',
+ pin: PIN,
+ overrides: [{ state: 'overridden', ...override }],
+ },
+ },
+ ],
+};
+
+export const DIFFERENT_PARENT_ID = 'RAC-PARENT000001';
+export const DIFFERENT_REPLACEMENT_ID = 'RAC-LOCAL0000001';
+export const DIFFERENT_REPLACEMENT_KEY =
+ `acme/app::${DIFFERENT_REPLACEMENT_ID}`;
+
+const differentIdOverride = {
+ parent: { source: 'acme/standards', id: DIFFERENT_PARENT_ID },
+ replacement: { source: 'acme/app', id: DIFFERENT_REPLACEMENT_ID },
+ rationale: { source: 'acme/app', id: RATIONALE_ID },
+} as const;
+
+/** A parent canonical id redirected to a differently-named local decision. */
+export const federatedDifferentIdOverrideExport: AsDecidedExport = {
+ schema_version: '1',
+ corpus: {
+ name: 'federated-different-id-fixture',
+ source: 'acme/app',
+ rac_version: '0.0.0-test',
+ artifact_count: 3,
+ },
+ artifacts: [
+ {
+ id: DIFFERENT_REPLACEMENT_ID,
+ aliases: ['local-exception'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Different-id replacement',
+ path: 'replacement.md',
+ body_html: 'local replacement
',
+ provenance: {
+ source: 'acme/app',
+ layer: 'local',
+ overrides: [{ state: 'replacement', ...differentIdOverride }],
+ },
+ },
+ {
+ id: RATIONALE_ID,
+ aliases: ['override-rationale'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Override rationale',
+ path: 'rationale.md',
+ body_html: `${DIFFERENT_PARENT_ID}
`,
+ provenance: { source: 'acme/app', layer: 'local' },
+ },
+ {
+ id: DIFFERENT_PARENT_ID,
+ aliases: ['parent-policy-alias'],
+ type: 'decision',
+ status: 'Accepted',
+ title: 'Inherited parent policy',
+ path: 'policy.md',
+ body_html: 'parent history
',
+ provenance: {
+ source: 'acme/standards',
+ layer: 'inherited',
+ pin: PIN,
+ overrides: [{ state: 'overridden', ...differentIdOverride }],
+ },
+ },
+ ],
+ relationships: [],
+};
diff --git a/rust/decided-mcp/src/audit.rs b/rust/decided-mcp/src/audit.rs
index 860f8080..c5d44f22 100644
--- a/rust/decided-mcp/src/audit.rs
+++ b/rust/decided-mcp/src/audit.rs
@@ -254,31 +254,37 @@ fn activation_message(recorder: &Recorder) -> String {
)
}
-/// Run `call`, record one audit event, and return the payload unchanged
-/// (ADR-084: audit is observability outside the response contract). With no
-/// recorder this is exactly `call()`. Under `on_write_error: block` a failed
-/// write refuses the call with a structured `audit-unavailable` error.
-pub fn observe(
+/// Result-preserving audit boundary for failures which happen before a tool
+/// can produce its structured payload (for example strict parent verification
+/// or composition). These failures still emit exactly one event with an empty
+/// returned set before the MCP layer serializes the error response.
+pub fn observe_result(
recorder: Option<&mut Recorder>,
request_principal: Option<&str>,
tool: &str,
args: Value,
- call: impl FnOnce() -> String,
-) -> String {
+ call: impl FnOnce() -> Result,
+) -> Result {
let Some(recorder) = recorder else {
return call();
};
let stripped = request_principal.map(str::trim).filter(|s| !s.is_empty());
let asserted = stripped.is_some();
- let principal = stripped.map(str::to_string).unwrap_or_else(|| recorder.principal.clone());
+ let principal = stripped
+ .map(str::to_string)
+ .unwrap_or_else(|| recorder.principal.clone());
let started = Instant::now();
- let payload = call();
+ let result = call();
+ let (returned, result_outcome) = match &result {
+ Ok(payload) => (returned_records(payload), outcome(payload)),
+ Err(_) => (Vec::new(), "error"),
+ };
let event = build_event(
recorder,
tool,
args,
- returned_records(&payload),
- outcome(&payload),
+ returned,
+ result_outcome,
started,
&principal,
asserted,
@@ -288,9 +294,11 @@ pub fn observe(
err.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
err.insert("error".into(), Value::String("audit-unavailable".into()));
err.insert("tool".into(), Value::String(tool.into()));
- return dumps_compact(&Value::Object(err));
+ // Preserve the established block-on-write wire behavior: the audit
+ // refusal is a structured tool payload, not the unrecorded result.
+ return Ok(dumps_compact(&Value::Object(err)));
}
- payload
+ result
}
#[allow(clippy::too_many_arguments)]
@@ -338,6 +346,8 @@ fn outcome(payload: &str) -> &'static str {
/// The audit schema deliberately keeps a small reference rather than copying
/// result records: `id` is the stable identity, `resolved` records the
/// resolution state, and `provenance.path` points back into the served corpus.
+/// Federated records add only the fixed source, layer, and pin identity fields
+/// authorised by ADR-141; response bodies and optional provenance stay out.
/// The extractor covers every result collection emitted by the MCP tools. Raw
/// outgoing relationship text is intentionally excluded because it can be an
/// unresolved declaration rather than a returned artifact.
@@ -365,10 +375,16 @@ fn returned_records(payload: &str) -> Vec {
}
let mut seen = std::collections::HashSet::new();
records.retain(|record| {
- record
- .get("id")
+ let Some(id) = record.get("id").and_then(Value::as_str) else {
+ return false;
+ };
+ let source = record
+ .get("provenance")
+ .and_then(Value::as_object)
+ .and_then(|provenance| provenance.get("source"))
.and_then(Value::as_str)
- .is_some_and(|id| seen.insert(id.to_string()))
+ .unwrap_or("");
+ seen.insert((source.to_string(), id.to_string()))
});
records
}
@@ -379,11 +395,22 @@ fn returned_record(object: &Map) -> Option {
.get("resolved")
.and_then(Value::as_bool)
.unwrap_or(true);
- let provenance = object
- .get("path")
- .and_then(Value::as_str)
- .map(|path| json!({ "path": path }))
- .unwrap_or(Value::Null);
+ let mut provenance = Map::new();
+ if let Some(path) = object.get("path").and_then(Value::as_str) {
+ provenance.insert("path".to_string(), json!(path));
+ }
+ if let Some(response_provenance) = object.get("provenance").and_then(Value::as_object) {
+ for key in ["source", "layer", "pin"] {
+ if let Some(value) = response_provenance.get(key) {
+ provenance.insert(key.to_string(), value.clone());
+ }
+ }
+ }
+ let provenance = if provenance.is_empty() {
+ Value::Null
+ } else {
+ Value::Object(provenance)
+ };
Some(json!({
"id": id,
"resolved": resolved,
@@ -503,6 +530,59 @@ mod tests {
assert!(returned_records(r#"{"error":{"code":-1},"id":"A"}"#).is_empty());
}
+ #[test]
+ fn returned_records_keep_bounded_federation_identity_and_dedupe_by_source() {
+ let payload = json!({
+ "matches": [
+ {
+ "id": "ADR-001",
+ "path": "decisions/adr-001.md",
+ "provenance": {
+ "source": "acme/app",
+ "layer": "local",
+ "status": "Accepted",
+ "status_history": ["must not enter audit"]
+ }
+ },
+ {
+ "id": "ADR-001",
+ "path": "decisions/adr-001.md",
+ "provenance": {
+ "source": "acme/standards",
+ "layer": "inherited",
+ "pin": "sha256:0123",
+ "evidence": "must not enter audit"
+ }
+ }
+ ]
+ })
+ .to_string();
+ assert_eq!(
+ returned_records(&payload),
+ vec![
+ json!({
+ "id": "ADR-001",
+ "resolved": true,
+ "provenance": {
+ "path": "decisions/adr-001.md",
+ "source": "acme/app",
+ "layer": "local"
+ }
+ }),
+ json!({
+ "id": "ADR-001",
+ "resolved": true,
+ "provenance": {
+ "path": "decisions/adr-001.md",
+ "source": "acme/standards",
+ "layer": "inherited",
+ "pin": "sha256:0123"
+ }
+ })
+ ]
+ );
+ }
+
#[test]
fn activation_message_declares_path_scope_and_failure_mode() {
let recorder = Recorder {
diff --git a/rust/decided-mcp/src/graph.rs b/rust/decided-mcp/src/graph.rs
index f3e2354c..879d46ab 100644
--- a/rust/decided-mcp/src/graph.rs
+++ b/rust/decided-mcp/src/graph.rs
@@ -2,6 +2,7 @@
//! and adjacency indexes are built once per freshness generation, then reused
//! by every graph call until the corpus changes.
+use rac_engine::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, Layer};
use rac_engine::freshness::TrackerModel;
use rac_engine::relationships::{corpus_items, relationships_from_corpus, Relationship};
use rac_engine::resolve::{index_from_items, IndexEntry, ResolutionResult, ResolvedArtifact};
@@ -26,6 +27,22 @@ fn relationship_order(section: &str) -> usize {
RELATIONSHIP_SECTIONS.len()
}
+fn stable_entry_order(left: &IndexEntry, right: &IndexEntry) -> std::cmp::Ordering {
+ left.artifact_path
+ .cmp(&right.artifact_path)
+ .then_with(|| left.path.cmp(&right.path))
+ .then_with(|| left.id.cmp(&right.id))
+}
+
+fn public_path(entry: &IndexEntry, federated: bool) -> String {
+ if federated {
+ if let Some(path) = &entry.artifact_path {
+ return path.relative_path.clone();
+ }
+ }
+ entry.path.clone()
+}
+
pub struct OutgoingReferences {
/// Section (snake_case) → raw stored targets, first-seen section order.
pub by_section: Vec<(String, Vec)>,
@@ -47,6 +64,8 @@ impl OutgoingReferences {
}
pub struct IncomingReference {
+ pub key: Option,
+ pub origin: Option,
pub id: String,
pub artifact_type: String,
pub title: Option,
@@ -61,6 +80,8 @@ pub struct IncomingReferences {
}
pub struct NeighborhoodNode {
+ pub key: Option,
+ pub origin: Option,
pub id: String,
pub artifact_type: String,
pub title: Option,
@@ -73,17 +94,23 @@ pub struct Neighborhood {
pub truncated: bool,
}
-/// Immutable graph projection for one logical corpus generation.
-pub struct GraphView {
- entries: Vec,
+struct RelationshipProjection {
relationships: Vec,
- aliases: HashMap>,
- entry_by_path: HashMap,
outgoing_by_source: Vec>,
incoming_by_target: Vec>,
adjacency: Vec>,
}
+/// Immutable graph projection for one logical corpus generation.
+pub struct GraphView {
+ entries: Vec,
+ entry_by_path: HashMap,
+ entry_by_artifact_path: HashMap,
+ effective_graph: RelationshipProjection,
+ historical_graph: Option,
+ federated: bool,
+}
+
impl GraphView {
pub fn from_model(model: &TrackerModel) -> Self {
match model {
@@ -111,37 +138,99 @@ impl GraphView {
Self::new(index_from_items(&corpus), relationships_from_corpus(&corpus))
}
+ pub fn from_composed(corpus: &rac_engine::composition::ComposedCorpus) -> Self {
+ Self::new_with_history(
+ corpus.identity_index(),
+ corpus.relationships(),
+ Some(corpus.catalog_relationships()),
+ )
+ }
+
pub fn new(entries: Vec, relationships: Vec) -> Self {
- let mut aliases: HashMap> = HashMap::new();
+ Self::new_with_history(entries, relationships, None)
+ }
+
+ fn new_with_history(
+ entries: Vec,
+ relationships: Vec,
+ historical_relationships: Option>,
+ ) -> Self {
let mut entry_by_path = HashMap::with_capacity(entries.len());
+ let mut entry_by_artifact_path = HashMap::with_capacity(entries.len());
+ let federated = entries.iter().any(|entry| {
+ entry
+ .origin
+ .as_ref()
+ .is_some_and(|origin| origin.layer == Layer::Inherited)
+ });
for (index, entry) in entries.iter().enumerate() {
entry_by_path.insert(entry.path.clone(), index);
- for alias in &entry.aliases {
- let targets = aliases
- .entry(rac_engine::pycompat::py_casefold(alias))
- .or_default();
- if !targets.contains(&index) {
- targets.push(index);
- }
+ if let Some(path) = &entry.artifact_path {
+ entry_by_artifact_path.insert(path.clone(), index);
}
}
- let mut outgoing_by_source = vec![Vec::new(); entries.len()];
- let mut incoming_by_target = vec![Vec::new(); entries.len()];
- let mut adjacency = vec![Vec::new(); entries.len()];
+ let effective_graph = Self::relationship_projection(
+ entries.len(),
+ &entry_by_path,
+ &entry_by_artifact_path,
+ relationships,
+ );
+ let historical_graph = historical_relationships.map(|relationships| {
+ Self::relationship_projection(
+ entries.len(),
+ &entry_by_path,
+ &entry_by_artifact_path,
+ relationships,
+ )
+ });
+
+ Self {
+ entries,
+ entry_by_path,
+ entry_by_artifact_path,
+ effective_graph,
+ historical_graph,
+ federated,
+ }
+ }
+
+ fn relationship_projection(
+ entry_count: usize,
+ entry_by_path: &HashMap,
+ entry_by_artifact_path: &HashMap,
+ relationships: Vec,
+ ) -> RelationshipProjection {
+ let mut outgoing_by_source = vec![Vec::new(); entry_count];
+ let mut incoming_by_target = vec![Vec::new(); entry_count];
+ let mut adjacency = vec![Vec::new(); entry_count];
for (index, relationship) in relationships.iter().enumerate() {
- let Some(&source_index) = entry_by_path.get(&relationship.source_path) else {
+ let source_index = relationship
+ .source_artifact
+ .as_ref()
+ .and_then(|path| entry_by_artifact_path.get(path))
+ .or_else(|| entry_by_path.get(&relationship.source_path))
+ .copied();
+ let Some(source_index) = source_index else {
continue;
};
outgoing_by_source[source_index].push(index);
- let Some(target) = relationship.resolved_path.as_deref() else {
- continue;
- };
- let Some(&target_index) = entry_by_path.get(target) else {
+ let target_index = relationship
+ .resolved_artifact
+ .as_ref()
+ .and_then(|path| entry_by_artifact_path.get(path))
+ .copied()
+ .or_else(|| {
+ relationship
+ .resolved_path
+ .as_deref()
+ .and_then(|path| entry_by_path.get(path).copied())
+ });
+ let Some(target_index) = target_index else {
continue;
};
incoming_by_target[target_index].push(index);
- if relationship.source_path == target {
+ if source_index == target_index {
continue;
}
let rank = relationship_order(&relationship.relationship);
@@ -149,71 +238,50 @@ impl GraphView {
adjacency[target_index].push((source_index, rank));
}
- Self {
- entries,
+ RelationshipProjection {
relationships,
- aliases,
- entry_by_path,
outgoing_by_source,
incoming_by_target,
adjacency,
}
}
- pub fn resolve(&self, artifact_id: &str) -> ResolutionResult {
- use rac_engine::resolve::{OUTCOME_DUPLICATE, OUTCOME_NOT_FOUND, OUTCOME_RESOLVED};
-
- let wanted = rac_engine::pycompat::py_casefold(rac_engine::pycompat::py_strip(artifact_id));
- let matches = self.aliases.get(&wanted).map(Vec::as_slice).unwrap_or(&[]);
- if matches.is_empty() {
- return ResolutionResult {
- artifact_id: artifact_id.to_string(),
- outcome: OUTCOME_NOT_FOUND,
- artifact: None,
- duplicate_paths: Vec::new(),
- };
- }
- if matches.len() > 1 {
- let mut paths: Vec = matches
- .iter()
- .map(|index| self.entries[*index].path.clone())
- .collect();
- paths.sort();
- return ResolutionResult {
- artifact_id: artifact_id.to_string(),
- outcome: OUTCOME_DUPLICATE,
- artifact: None,
- duplicate_paths: paths,
- };
- }
- let entry = &self.entries[matches[0]];
- ResolutionResult {
- artifact_id: artifact_id.to_string(),
- outcome: OUTCOME_RESOLVED,
- artifact: Some(ResolvedArtifact {
- id: entry.id.clone(),
- artifact_type: entry.artifact_type.clone(),
- title: entry.title.clone(),
- path: entry.path.clone(),
- section: None,
- snippet: None,
- evidence: None,
- recency: None,
- tags: entry.tags.clone(),
- }),
- duplicate_paths: Vec::new(),
+ fn graph(&self, historical: bool) -> &RelationshipProjection {
+ if historical {
+ self.historical_graph
+ .as_ref()
+ .unwrap_or(&self.effective_graph)
+ } else {
+ &self.effective_graph
}
}
- pub fn outgoing(&self, source_path: &str) -> OutgoingReferences {
+ fn entry_index(&self, artifact: &ResolvedArtifact) -> Option {
+ artifact
+ .artifact_path
+ .as_ref()
+ .and_then(|path| self.entry_by_artifact_path.get(path))
+ .copied()
+ .or_else(|| self.entry_by_path.get(&artifact.path).copied())
+ }
+
+ pub fn resolve(&self, artifact_id: &str) -> ResolutionResult {
+ rac_engine::resolve::resolve_in_index(&self.entries, artifact_id)
+ }
+
+ pub fn outgoing(
+ &self,
+ artifact: &ResolvedArtifact,
+ historical: bool,
+ ) -> OutgoingReferences {
+ let graph = self.graph(historical);
let indexes = self
- .entry_by_path
- .get(source_path)
- .map(|index| self.outgoing_by_source[*index].as_slice())
+ .entry_index(artifact)
+ .map(|index| graph.outgoing_by_source[index].as_slice())
.unwrap_or(&[]);
let mut by_section: Vec<(String, Vec)> = Vec::new();
for index in indexes.iter().take(MAX_RELATED_EDGES) {
- let relationship = &self.relationships[*index];
+ let relationship = &graph.relationships[*index];
match by_section
.iter_mut()
.find(|(section, _)| *section == relationship.relationship)
@@ -231,30 +299,42 @@ impl GraphView {
}
}
- pub fn incoming(&self, target_path: &str) -> IncomingReferences {
- let indexes = self
- .entry_by_path
- .get(target_path)
- .map(|index| self.incoming_by_target[*index].as_slice())
+ pub fn incoming(
+ &self,
+ artifact: &ResolvedArtifact,
+ historical: bool,
+ ) -> IncomingReferences {
+ let graph = self.graph(historical);
+ let target_index = self.entry_index(artifact);
+ let indexes = target_index
+ .map(|index| graph.incoming_by_target[index].as_slice())
.unwrap_or(&[]);
let mut incoming = Vec::new();
let mut total = 0usize;
for index in indexes {
- let relationship = &self.relationships[*index];
- if relationship.source_path == target_path {
- continue;
- }
- let Some(entry_index) = self.entry_by_path.get(&relationship.source_path) else {
+ let relationship = &graph.relationships[*index];
+ let entry_index = relationship
+ .source_artifact
+ .as_ref()
+ .and_then(|path| self.entry_by_artifact_path.get(path))
+ .or_else(|| self.entry_by_path.get(&relationship.source_path))
+ .copied();
+ let Some(entry_index) = entry_index else {
continue;
};
+ if Some(entry_index) == target_index {
+ continue;
+ }
total += 1;
if incoming.len() < MAX_RELATED_EDGES {
- let entry = &self.entries[*entry_index];
+ let entry = &self.entries[entry_index];
incoming.push(IncomingReference {
+ key: entry.key.clone(),
+ origin: entry.origin.clone(),
id: entry.id.clone(),
artifact_type: entry.artifact_type.clone(),
title: entry.title.clone(),
- path: relationship.source_path.clone(),
+ path: public_path(entry, self.federated),
section: relationship.relationship.clone(),
target: relationship.target.clone(),
});
@@ -273,9 +353,15 @@ impl GraphView {
}
}
- pub fn neighborhood(&self, origin_path: &str, depth: i64) -> Neighborhood {
+ pub fn neighborhood(
+ &self,
+ artifact: &ResolvedArtifact,
+ depth: i64,
+ historical: bool,
+ ) -> Neighborhood {
+ let graph = self.graph(historical);
let depth = depth.clamp(0, MAX_TRAVERSAL_DEPTH);
- let Some(&origin_index) = self.entry_by_path.get(origin_path) else {
+ let Some(origin_index) = self.entry_index(artifact) else {
return Neighborhood {
nodes: Vec::new(),
truncated: false,
@@ -292,11 +378,14 @@ impl GraphView {
for current_depth in 1..=depth {
let mut next_frontier = Vec::new();
let mut sorted_frontier = frontier.clone();
- sorted_frontier.sort_by(|a, b| self.entries[*a].path.cmp(&self.entries[*b].path));
+ sorted_frontier.sort_by(|a, b| {
+ stable_entry_order(&self.entries[*a], &self.entries[*b])
+ });
for entry_index in &sorted_frontier {
- let mut neighbors = self.adjacency[*entry_index].clone();
+ let mut neighbors = graph.adjacency[*entry_index].clone();
neighbors.sort_by(|a, b| {
- (&self.entries[a.0].path, a.1).cmp(&(&self.entries[b.0].path, b.1))
+ stable_entry_order(&self.entries[a.0], &self.entries[b.0])
+ .then_with(|| a.1.cmp(&b.1))
});
neighbors.dedup();
for (neighbor_index, rank) in neighbors {
@@ -328,18 +417,21 @@ impl GraphView {
}
discovered.sort_by(|a, b| {
- (a.0, a.1, &a.2, &self.entries[a.3].path)
- .cmp(&(b.0, b.1, &b.2, &self.entries[b.3].path))
+ (a.0, a.1, &a.2)
+ .cmp(&(b.0, b.1, &b.2))
+ .then_with(|| stable_entry_order(&self.entries[a.3], &self.entries[b.3]))
});
let mut nodes: Vec = discovered
.into_iter()
.map(|(hops, _rank, _id, entry_index)| {
let entry = &self.entries[entry_index];
NeighborhoodNode {
+ key: entry.key.clone(),
+ origin: entry.origin.clone(),
id: entry.id.clone(),
artifact_type: entry.artifact_type.clone(),
title: entry.title.clone(),
- path: entry.path.clone(),
+ path: public_path(entry, self.federated),
hops,
}
})
@@ -355,7 +447,11 @@ impl GraphView {
}
pub fn relationship_count(&self) -> usize {
- self.relationships.len()
+ self.effective_graph.relationships.len()
+ }
+
+ pub fn is_federated(&self) -> bool {
+ self.federated
}
/// Approximate owned heap payload, excluding hash-table control bytes.
@@ -370,10 +466,19 @@ impl GraphView {
+ entry.path.len()
+ entry.aliases.iter().map(String::len).sum::()
+ entry.tags.iter().map(String::len).sum::()
+ + entry
+ .artifact_path
+ .as_ref()
+ .map_or(0, |path| path.source.len() + path.relative_path.len())
+ + entry.origin.as_ref().map_or(0, |origin| {
+ origin.source.len()
+ + origin.pin.as_ref().map_or(0, String::len)
+ + origin.alias.as_ref().map_or(0, String::len)
+ })
})
.sum();
- let relationship_bytes: usize = self
- .relationships
+ let relationship_bytes = |relationships: &[Relationship]| -> usize {
+ relationships
.iter()
.map(|relationship| {
relationship.source_path.len()
@@ -381,31 +486,148 @@ impl GraphView {
+ relationship.target.len()
+ relationship.resolved_path.as_ref().map_or(0, String::len)
+ relationship.issue.as_ref().map_or(0, String::len)
+ + relationship
+ .source_artifact
+ .as_ref()
+ .map_or(0, |path| path.source.len() + path.relative_path.len())
+ + relationship
+ .resolved_artifact
+ .as_ref()
+ .map_or(0, |path| path.source.len() + path.relative_path.len())
})
- .sum();
- let map_key_bytes = self.aliases.keys().map(String::len).sum::()
- + self.entry_by_path.keys().map(String::len).sum::();
- let vector_payload_bytes = self
+ .sum()
+ };
+ let relationship_bytes = relationship_bytes(&self.effective_graph.relationships)
+ + self.historical_graph.as_ref().map_or(0, |graph| {
+ relationship_bytes(&graph.relationships)
+ });
+ let map_key_bytes = self.entry_by_path.keys().map(String::len).sum::()
+ + self
+ .entry_by_artifact_path
+ .keys()
+ .map(|path| path.source.len() + path.relative_path.len())
+ .sum::();
+ let projection_payload = |graph: &RelationshipProjection| {
+ graph
.outgoing_by_source
.iter()
.map(|indexes| indexes.len() * std::mem::size_of::())
.sum::()
- + self
+ + graph
.incoming_by_target
.iter()
.map(|indexes| indexes.len() * std::mem::size_of::())
.sum::()
- + self
+ + graph
.adjacency
.iter()
.map(|neighbors| neighbors.len() * std::mem::size_of::<(usize, usize)>())
- .sum::();
+ .sum::()
+ };
+ let vector_payload_bytes = projection_payload(&self.effective_graph)
+ + self
+ .historical_graph
+ .as_ref()
+ .map_or(0, projection_payload);
entry_bytes + relationship_bytes + map_key_bytes + vector_payload_bytes
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rac_engine::corpus::{ArtifactKey, CorpusLayer};
+
+ fn entry(
+ layer: CorpusLayer,
+ id: &str,
+ relative_path: &str,
+ physical_path: &str,
+ ) -> IndexEntry {
+ let origin = layer.origin();
+ IndexEntry {
+ key: Some(ArtifactKey::new(&origin.source, id)),
+ artifact_path: Some(origin.path(relative_path)),
+ origin: Some(origin),
+ id: id.to_string(),
+ artifact_type: "Decision".to_string(),
+ title: None,
+ path: physical_path.to_string(),
+ aliases: vec![id.to_string()],
+ search_sections: Vec::new(),
+ inbound_count: 0,
+ tags: Vec::new(),
+ }
+ }
+
+ fn resolved(entry: &IndexEntry) -> ResolvedArtifact {
+ ResolvedArtifact {
+ key: entry.key.clone(),
+ artifact_path: entry.artifact_path.clone(),
+ origin: entry.origin.clone(),
+ id: entry.id.clone(),
+ artifact_type: entry.artifact_type.clone(),
+ title: entry.title.clone(),
+ path: entry.path.clone(),
+ section: None,
+ snippet: None,
+ evidence: None,
+ recency: None,
+ tags: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn source_aware_endpoints_do_not_alias_physical_or_display_paths() {
+ let parent = entry(
+ CorpusLayer::inherited("acme/standards", "standards", "sha256:0123"),
+ "ADR-PARENT",
+ "decisions/shared.md",
+ "/checkout/vendor/decisions/shared.md",
+ );
+ let local = entry(
+ CorpusLayer::local("acme/app"),
+ "ADR-LOCAL",
+ "decisions/shared.md",
+ "/checkout/decisions/shared.md",
+ );
+ let relationship = Relationship {
+ source_artifact: parent.artifact_path.clone(),
+ source_path: parent.path.clone(),
+ relationship: "depends_on".to_string(),
+ target: "ADR-LOCAL".to_string(),
+ resolved_artifact: local.artifact_path.clone(),
+ resolved_path: Some(local.path.clone()),
+ issue: None,
+ };
+ let parent_artifact = resolved(&parent);
+ let local_artifact = resolved(&local);
+ let view = GraphView::new(vec![parent, local], vec![relationship]);
+
+ assert!(view.is_federated());
+ assert_eq!(view.outgoing(&parent_artifact, false).total, 1);
+ assert_eq!(view.outgoing(&local_artifact, false).total, 0);
+
+ let incoming = view.incoming(&local_artifact, false);
+ assert_eq!(incoming.total, 1);
+ assert_eq!(incoming.items[0].id, "ADR-PARENT");
+ assert_eq!(incoming.items[0].path, "decisions/shared.md");
+ assert_eq!(
+ incoming.items[0]
+ .origin
+ .as_ref()
+ .map(|origin| origin.source.as_str()),
+ Some("acme/standards")
+ );
+ }
+
+}
+
fn identity_projection(entry: &IndexEntry) -> IndexEntry {
IndexEntry {
+ key: entry.key.clone(),
+ artifact_path: entry.artifact_path.clone(),
+ origin: entry.origin.clone(),
id: entry.id.clone(),
artifact_type: entry.artifact_type.clone(),
title: entry.title.clone(),
@@ -423,13 +645,17 @@ fn identity_projection(entry: &IndexEntry) -> IndexEntry {
#[derive(Default)]
pub struct GraphCache {
generation: Option,
+ federated_generation: Option,
view: Option,
builds: u64,
}
impl GraphCache {
pub fn view_for(&mut self, generation: u64, model: &TrackerModel) -> &GraphView {
- if self.generation != Some(generation) || self.view.is_none() {
+ if self.generation != Some(generation)
+ || self.federated_generation.is_some()
+ || self.view.is_none()
+ {
let started = rac_engine::timing::start();
let replacement = GraphView::from_model(model);
rac_engine::timing::emit_since(
@@ -443,11 +669,40 @@ impl GraphCache {
);
self.view = Some(replacement);
self.generation = Some(generation);
+ self.federated_generation = None;
self.builds += 1;
}
self.view.as_ref().expect("graph view built")
}
+ pub fn view_for_composed(
+ &mut self,
+ generation: &str,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ ) -> &GraphView {
+ if self.federated_generation.as_deref() != Some(generation)
+ || self.generation.is_some()
+ || self.view.is_none()
+ {
+ let started = rac_engine::timing::start();
+ let replacement = GraphView::from_composed(corpus);
+ rac_engine::timing::emit_since(
+ "graph.view_build",
+ started,
+ &[
+ ("entries", replacement.entry_count() as u64),
+ ("relationships", replacement.relationship_count() as u64),
+ ("payload_bytes", replacement.estimated_payload_bytes() as u64),
+ ],
+ );
+ self.view = Some(replacement);
+ self.generation = None;
+ self.federated_generation = Some(generation.to_string());
+ self.builds += 1;
+ }
+ self.view.as_ref().expect("federated graph view built")
+ }
+
#[cfg(test)]
pub fn builds(&self) -> u64 {
self.builds
diff --git a/rust/decided-mcp/src/main.rs b/rust/decided-mcp/src/main.rs
index 21a9d28e..eb344390 100644
--- a/rust/decided-mcp/src/main.rs
+++ b/rust/decided-mcp/src/main.rs
@@ -20,6 +20,7 @@ use args::{Arg, Kind, Param};
use rac_engine::budget;
use serde_json::{json, Map, Value};
use std::io::{BufRead, Write};
+use std::path::{Path, PathBuf};
/// The pinned `tools/list` result — the captured ORACLE-NEXT bytes, embedded
/// verbatim (schemas, descriptions, pydantic-shaped titles incl. the
@@ -28,10 +29,66 @@ use std::io::{BufRead, Write};
const TOOLS_LIST_RESULT: &str = include_str!("tools_list_result.json");
pub(crate) struct ServerState {
+ repository_root: PathBuf,
+ federation_seen: bool,
tracker: Option,
+ federated_tracker: Option<
+ rac_engine::derived_cache::FederatedCacheTracker<
+ rac_engine::composition::ComposedCorpus,
+ >,
+ >,
graph_cache: graph::GraphCache,
}
+enum RequestRead<'a> {
+ Legacy {
+ generation: Option,
+ model: Option<&'a rac_engine::freshness::TrackerModel>,
+ },
+ FederatedCached(
+ rac_engine::derived_cache::FederatedCacheRead<
+ 'a,
+ rac_engine::composition::ComposedCorpus,
+ >,
+ ),
+ FederatedFresh {
+ generation: rac_engine::derived_cache::LogicalGeneration,
+ composed: Box,
+ },
+}
+
+impl RequestRead<'_> {
+ fn legacy(&self) -> (Option, Option<&rac_engine::freshness::TrackerModel>) {
+ match self {
+ Self::Legacy { generation, model } => (*generation, *model),
+ _ => (None, None),
+ }
+ }
+
+ fn composed(&self) -> Option<&rac_engine::composition::ComposedCorpus> {
+ match self {
+ Self::FederatedCached(read) => Some(read.composed),
+ Self::FederatedFresh { composed, .. } => Some(composed),
+ Self::Legacy { .. } => None,
+ }
+ }
+
+ fn cached_model(&self) -> Option<&rac_engine::derived_cache::ReadModel> {
+ match self {
+ Self::FederatedCached(read) => Some(read.model),
+ _ => None,
+ }
+ }
+
+ fn logical_generation(&self) -> Option<&rac_engine::derived_cache::LogicalGeneration> {
+ match self {
+ Self::FederatedCached(read) => Some(read.generation),
+ Self::FederatedFresh { generation, .. } => Some(generation),
+ Self::Legacy { .. } => None,
+ }
+ }
+}
+
/// The SDK's logging notification for an unparseable input line (§1) —
/// note the field order: method, params, jsonrpc.
const PARSE_ERROR_NOTIFICATION: &str = "{\"method\":\"notifications/message\",\"params\":{\"level\":\"error\",\"logger\":\"mcp.server.exception_handler\",\"data\":\"Internal Server Error\"},\"jsonrpc\":\"2.0\"}";
@@ -133,7 +190,10 @@ fn main() {
if !std::path::Path::new(&root).is_dir() {
usage_error(&format!("not a directory: {root}"));
}
- check_corpus(&root);
+ let mut federation_seen = false;
+ let topology = repository_topology(&root, None, &mut federation_seen)
+ .unwrap_or_else(|error| usage_error(&error));
+ check_corpus(&root, &topology);
// Server-lifetime freshness (ADR-105/118): one tracker per server keeps
// the derived read-model current through Linux inotify-clean detection or
// the authoritative stat fallback, re-deriving only where files changed.
@@ -146,8 +206,18 @@ fn main() {
} else {
None
};
+ let federated_tracker = if rac_engine::derived_cache::cache_enabled(cache) {
+ Some(rac_engine::derived_cache::FederatedCacheTracker::new(
+ rac_engine::derived_cache::default_cache_dir(),
+ ))
+ } else {
+ None
+ };
let mut state = ServerState {
+ repository_root: topology.repository_root,
+ federation_seen,
tracker,
+ federated_tracker,
graph_cache: graph::GraphCache::default(),
};
// Audit recorder (ADR-084): built from the `.decided/config.yaml` audit stanza,
@@ -184,9 +254,26 @@ fn main() {
}
/// Startup diagnostic (stderr only; declared-normalized in parity, §0).
-fn check_corpus(root: &str) {
- let entries = rac_engine::resolve::build_index(root, true);
- if !entries.iter().any(|e| e.artifact_type != "unknown") {
+fn check_corpus(root: &str, topology: &RepositoryTopology) {
+ let has_artifacts = if topology.federated {
+ let generation = rac_engine::derived_cache::capture_logical_generation(
+ &topology.repository_root,
+ root,
+ true,
+ )
+ .unwrap_or_else(|error| usage_error(&error.to_string()));
+ let composed = rac_engine::derived_cache::compose_logical_generation(root, &generation)
+ .unwrap_or_else(|error| usage_error(&error.to_string()));
+ let has_artifacts = composed
+ .effective()
+ .any(|item| item.spec.is_some());
+ has_artifacts
+ } else {
+ rac_engine::resolve::build_index(root, true)
+ .iter()
+ .any(|entry| entry.artifact_type != "unknown")
+ };
+ if !has_artifacts {
eprintln!(
"decided-mcp: no AsDecided artifacts found under '{root}'. Point --root at a \
directory containing RAC Markdown artifacts, or run 'decided init' to initialize \
@@ -195,6 +282,96 @@ a new repository. The server is running; get_summary will report the empty state
}
}
+struct RepositoryTopology {
+ repository_root: PathBuf,
+ federated: bool,
+}
+
+fn marker_present(path: &Path) -> Result {
+ match std::fs::symlink_metadata(path) {
+ Ok(_) => Ok(true),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(format!(
+ "parent-corpus-malformed-manifest: cannot inspect repository topology {}: {error}",
+ path.display()
+ )),
+ }
+}
+
+/// Discover and then pin the repository topology used by one server.
+///
+/// Startup searches for either governing config or federation manifest. Each
+/// request supplies the pinned root, so removing config cannot make discovery
+/// jump to another ancestor and silently return to the single-corpus model.
+/// Manifest presence is inspected with `symlink_metadata`, then parsed by the
+/// strict loader; directories, symlinks (including dangling ones), and races
+/// therefore fail closed instead of masquerading as absence.
+fn repository_topology(
+ root: &str,
+ pinned_root: Option<&Path>,
+ federation_seen: &mut bool,
+) -> Result {
+ let repository_root = if let Some(pinned) = pinned_root {
+ pinned.to_path_buf()
+ } else {
+ let resolved = std::fs::canonicalize(root).map_err(|error| {
+ format!("cannot resolve MCP corpus root {}: {error}", Path::new(root).display())
+ })?;
+ let mut selected = None;
+ for ancestor in resolved.ancestors() {
+ let config = ancestor.join(rac_engine::federation::CONFIG_RELATIVE_PATH);
+ let manifest = ancestor.join(rac_engine::federation::MANIFEST_RELATIVE_PATH);
+ if marker_present(&config)? || marker_present(&manifest)? {
+ selected = Some(ancestor.to_path_buf());
+ break;
+ }
+ }
+ selected.unwrap_or(resolved)
+ };
+
+ let manifest_path = repository_root.join(rac_engine::federation::MANIFEST_RELATIVE_PATH);
+ let present = marker_present(&manifest_path)?;
+ if present {
+ // Presence itself is sticky. A malformed addition cannot be removed
+ // to make the next request fall back to a legacy corpus.
+ *federation_seen = true;
+ }
+ let manifest = rac_engine::federation::load_manifest(&repository_root)
+ .map_err(|error| error.to_string())?;
+ let federated = manifest.is_some();
+ if *federation_seen && !federated {
+ return Err(format!(
+ "parent-corpus-malformed-manifest: federation manifest disappeared after this server observed federation: {}",
+ manifest_path.display()
+ ));
+ }
+ if *federation_seen {
+ let config_path = repository_root.join(rac_engine::federation::CONFIG_RELATIVE_PATH);
+ let metadata = std::fs::symlink_metadata(&config_path).map_err(|error| {
+ format!(
+ "parent-corpus-child-config-missing: child config is unavailable after this server observed federation: {}: {error}",
+ config_path.display()
+ )
+ })?;
+ if metadata.file_type().is_symlink() {
+ return Err(format!(
+ "parent-corpus-symlink-traversal: child config must not be a symlink: {}",
+ config_path.display()
+ ));
+ }
+ if !metadata.is_file() {
+ return Err(format!(
+ "parent-corpus-child-config-missing: child config is not a regular file: {}",
+ config_path.display()
+ ));
+ }
+ }
+ Ok(RepositoryTopology {
+ repository_root,
+ federated,
+ })
+}
+
fn serve(
root: &str,
state: &mut ServerState,
@@ -437,6 +614,64 @@ fn a_bool(args: &[Arg], i: usize, default: bool) -> bool {
}
}
+fn read_request<'a>(
+ root: &str,
+ repository_root: &Path,
+ federation_seen: &mut bool,
+ tracker: &'a mut Option,
+ federated_tracker: &'a mut Option<
+ rac_engine::derived_cache::FederatedCacheTracker<
+ rac_engine::composition::ComposedCorpus,
+ >,
+ >,
+) -> Result, String> {
+ // Every recognized tool enters the same strict topology boundary after
+ // its allowlisted arguments have been normalized for audit. Cache-off
+ // skips persistence only; it never skips topology, parent verification,
+ // or exact-byte composition.
+ let topology = repository_topology(root, Some(repository_root), federation_seen)?;
+ if topology.federated {
+ match federated_tracker.as_mut() {
+ Some(tracker) => Ok(RequestRead::FederatedCached(
+ tracker
+ .read_composed(&topology.repository_root, root, true)
+ .map_err(|error| error.to_string())?,
+ )),
+ None => {
+ let generation = rac_engine::derived_cache::capture_logical_generation(
+ &topology.repository_root,
+ root,
+ true,
+ )
+ .map_err(|error| error.to_string())?;
+ let composed = rac_engine::derived_cache::compose_logical_generation(
+ root,
+ &generation,
+ )
+ .map_err(|error| error.to_string())?;
+ Ok(RequestRead::FederatedFresh {
+ generation,
+ composed: Box::new(composed),
+ })
+ }
+ }
+ } else {
+ Ok(match tracker.as_mut() {
+ Some(tracker) => {
+ let (generation, model) = tracker.read_model_with_generation(false);
+ RequestRead::Legacy {
+ generation: Some(generation),
+ model: Some(model),
+ }
+ }
+ None => RequestRead::Legacy {
+ generation: None,
+ model: None,
+ },
+ })
+ }
+}
+
fn dispatch(
root: &str,
state: &mut ServerState,
@@ -457,15 +692,13 @@ fn dispatch(
) {
return Err(format!("Unknown tool: {name}"));
}
- // Freshen the read-model once per call (the corpus-change check every
- // tool answer rides, ADR-105); without the tracker every arm re-walks.
- let (generation, model) = match state.tracker.as_mut() {
- Some(tracker) => {
- let (generation, model) = tracker.read_model_with_generation(false);
- (Some(generation), Some(model))
- }
- None => (None, None),
- };
+ let ServerState {
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ graph_cache,
+ } = state;
// Audit args mirror server.py's per-tool `observed(...)` shapes exactly
// (insertion order = recorded key order): non-default arguments ride the
// record only when supplied. `sidecar::observe` keeps the telemetry seam
@@ -481,11 +714,28 @@ fn dispatch(
let effective = tools::effective_budget(server_budget, a_int(&a, 1, 0));
budget::validate_call_budget(effective)?;
let audit_args = json!({ "id": a_str(&a, 0, "") });
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::get_artifact(root, model, &a_str(&a, 0, ""), effective)
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
+ root,
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (_, model) = request.legacy();
+ Ok(if let Some(corpus) = request.composed() {
+ tools::get_artifact_composed(
+ root,
+ corpus,
+ &a_str(&a, 0, ""),
+ effective,
+ )
+ } else {
+ tools::get_artifact(root, model, &a_str(&a, 0, ""), effective)
+ })
})
- }))
+ })
}
"search_artifacts" => {
let params = [
@@ -509,19 +759,40 @@ fn dispatch(
m.insert("live_only".into(), Value::Bool(true));
}
let audit_args = Value::Object(m);
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::search_artifacts(
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
root,
- model,
- &query,
- artifact_type.as_deref(),
- &tags,
- live_only,
- server_budget,
- )
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (_, model) = request.legacy();
+ Ok(if let Some(corpus) = request.composed() {
+ tools::search_artifacts_composed(
+ root,
+ request.cached_model(),
+ corpus,
+ &query,
+ artifact_type.as_deref(),
+ &tags,
+ live_only,
+ server_budget,
+ )
+ } else {
+ tools::search_artifacts(
+ root,
+ model,
+ &query,
+ artifact_type.as_deref(),
+ &tags,
+ live_only,
+ server_budget,
+ )
+ })
})
- }))
+ })
}
"retrieve_grounding" => {
let params = [
@@ -554,13 +825,27 @@ fn dispatch(
m.insert("live_only".into(), Value::Bool(false));
}
let audit_args = Value::Object(m);
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::retrieve_grounding(
- root, model, &task, &scope, top_k, effective, live_only,
- )
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
+ root,
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (_, model) = request.legacy();
+ Ok(if let Some(corpus) = request.composed() {
+ tools::retrieve_grounding_composed(
+ root, corpus, &task, &scope, top_k, effective, live_only,
+ )
+ } else {
+ tools::retrieve_grounding(
+ root, model, &task, &scope, top_k, effective, live_only,
+ )
+ })
})
- }))
+ })
}
"find_decisions" => {
let params = [
@@ -576,11 +861,35 @@ fn dispatch(
m.insert("path".into(), Value::String(p.clone()));
}
let audit_args = Value::Object(m);
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::find_decisions_tool(root, model, &topic, path.as_deref(), server_budget)
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
+ root,
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (_, model) = request.legacy();
+ Ok(if let Some(corpus) = request.composed() {
+ tools::find_decisions_tool_composed(
+ root,
+ corpus,
+ &topic,
+ path.as_deref(),
+ server_budget,
+ )
+ } else {
+ tools::find_decisions_tool(
+ root,
+ model,
+ &topic,
+ path.as_deref(),
+ server_budget,
+ )
+ })
})
- }))
+ })
}
"get_related" => {
let params = [
@@ -591,29 +900,74 @@ fn dispatch(
let id = a_str(&a, 0, "");
let depth = a_int(&a, 1, 1);
let audit_args = json!({ "id": id.clone(), "depth": depth });
- let fresh_graph;
- let graph_view = match (generation, model) {
- (Some(generation), Some(model)) => state.graph_cache.view_for(generation, model),
- _ => {
- fresh_graph = graph::GraphView::fresh(root);
- &fresh_graph
- }
- };
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::get_related(graph_view, &id, depth, server_budget)
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
+ root,
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (generation, model) = request.legacy();
+ let fresh_graph;
+ let graph_view = if let (Some(corpus), Some(logical)) =
+ (request.composed(), request.logical_generation())
+ {
+ graph_cache.view_for_composed(logical.cache_key(), corpus)
+ } else {
+ match (generation, model) {
+ (Some(generation), Some(model)) => {
+ graph_cache.view_for(generation, model)
+ }
+ _ => {
+ fresh_graph = graph::GraphView::fresh(root);
+ &fresh_graph
+ }
+ }
+ };
+ Ok(if let Some(corpus) = request.composed() {
+ tools::get_related_composed(
+ graph_view,
+ corpus,
+ &id,
+ depth,
+ server_budget,
+ )
+ } else {
+ tools::get_related(graph_view, &id, depth, server_budget)
+ })
})
- }))
+ })
}
"get_summary" => {
let params: [Param; 0] = [];
args::validate(name, "get_summaryArguments", ¶ms, arguments)?;
let audit_args = json!({});
- Ok(sidecar::observe(name, || {
- audit::observe(recorder, principal, name, audit_args, || {
- tools::get_summary(root, model, server_budget)
+ sidecar::observe(name, || {
+ audit::observe_result(recorder, principal, name, audit_args, || {
+ let request = read_request(
+ root,
+ repository_root,
+ federation_seen,
+ tracker,
+ federated_tracker,
+ )?;
+ let (_, model) = request.legacy();
+ Ok(if let (Some(corpus), Some(generation)) =
+ (request.composed(), request.logical_generation())
+ {
+ tools::get_summary_composed(
+ root,
+ generation,
+ corpus,
+ server_budget,
+ )
+ } else {
+ tools::get_summary(root, model, server_budget)
+ })
})
- }))
+ })
}
_ => unreachable!("known tool guard and dispatch arms must stay aligned"),
}
@@ -664,11 +1018,14 @@ mod tests {
#[test]
fn unknown_tool_does_not_freshen_tracker() {
let mut state = ServerState {
+ repository_root: PathBuf::from("/definitely-not-a-decided-corpus"),
+ federation_seen: false,
tracker: Some(rac_engine::freshness::FreshnessTracker::new(
std::path::PathBuf::from("/definitely-not-a-decided-cache"),
"/definitely-not-a-decided-corpus",
None,
)),
+ federated_tracker: None,
graph_cache: graph::GraphCache::default(),
};
let result = dispatch(
@@ -692,11 +1049,14 @@ mod tests {
std::fs::write(corpus.join("requirement-1.md"), requirement("FIX-0REQ1GRAPH00")).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut state = ServerState {
+ repository_root: corpus.clone(),
+ federation_seen: false,
tracker: Some(rac_engine::freshness::FreshnessTracker::new(
cache.clone(),
&root,
Some(10),
)),
+ federated_tracker: None,
graph_cache: graph::GraphCache::default(),
};
let arguments = json!({"id": "FIX-0DEC1GRAPH00", "depth": 2});
diff --git a/rust/decided-mcp/src/sidecar.rs b/rust/decided-mcp/src/sidecar.rs
index 6cb65a93..a619980f 100644
--- a/rust/decided-mcp/src/sidecar.rs
+++ b/rust/decided-mcp/src/sidecar.rs
@@ -11,6 +11,6 @@
//! remains a no-op around the read-only protocol.
/// The no-op observation seam: time-and-record hooks would wrap `call` here.
-pub fn observe String>(_tool: &str, call: F) -> String {
+pub fn observe T>(_tool: &str, call: F) -> T {
call()
}
diff --git a/rust/decided-mcp/src/tools.rs b/rust/decided-mcp/src/tools.rs
index 4b31030d..4df7fda0 100644
--- a/rust/decided-mcp/src/tools.rs
+++ b/rust/decided-mcp/src/tools.rs
@@ -18,6 +18,56 @@ use rac_engine::resolve::{
};
use serde_json::{json, Map, Value};
+fn fixed_origin(
+ origin: Option<&rac_engine::corpus::ArtifactOrigin>,
+ enabled: bool,
+) -> Option> {
+ let origin = origin.filter(|_| enabled)?;
+ let mut provenance = Map::new();
+ provenance.insert("source".to_string(), json!(origin.source));
+ provenance.insert("layer".to_string(), json!(origin.layer.as_str()));
+ if let Some(pin) = &origin.pin {
+ provenance.insert("pin".to_string(), json!(pin));
+ }
+ Some(provenance)
+}
+
+fn attach_origin(
+ value: &mut Value,
+ origin: Option<&rac_engine::corpus::ArtifactOrigin>,
+ enabled: bool,
+) {
+ let Some(provenance) = fixed_origin(origin, enabled) else {
+ return;
+ };
+ if let Some(record) = value.as_object_mut() {
+ record.insert("provenance".to_string(), Value::Object(provenance));
+ }
+}
+
+fn composed_provenance(
+ corpus: &rac_engine::composition::ComposedCorpus,
+ key: Option<&rac_engine::corpus::ArtifactKey>,
+) -> Option> {
+ let provenance = key.and_then(|key| corpus.provenance_for(key))?;
+ rac_engine::output::composed_provenance_value(&provenance)
+ .as_object()
+ .cloned()
+}
+
+fn attach_composed_provenance(
+ value: &mut Value,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ key: Option<&rac_engine::corpus::ArtifactKey>,
+) {
+ let Some(provenance) = composed_provenance(corpus, key) else {
+ return;
+ };
+ if let Some(record) = value.as_object_mut() {
+ record.insert("provenance".to_string(), Value::Object(provenance));
+ }
+}
+
/// The additive empty-corpus guidance the server layers over the summary.
const EMPTY_GUIDANCE: &str = "This repository has no AsDecided artifacts yet. The user can create the \
first one with `decided quickstart`, or with `decided init` then \
@@ -50,8 +100,35 @@ fn artifact_value(m: &ResolvedArtifact) -> Map {
}
}
-fn search_result_payload(result: &SearchResult) -> Value {
- output::search_result_value(result, true)
+fn search_result_payload(result: &SearchResult, include_origin: bool) -> Value {
+ let mut payload = output::search_result_value(result, true);
+ if let Some(matches) = payload
+ .as_object_mut()
+ .and_then(|object| object.get_mut("matches"))
+ .and_then(Value::as_array_mut)
+ {
+ for (record, artifact) in matches.iter_mut().zip(&result.matches) {
+ attach_origin(record, artifact.origin.as_ref(), include_origin);
+ }
+ }
+ payload
+}
+
+fn composed_search_result_payload(
+ result: &SearchResult,
+ corpus: &rac_engine::composition::ComposedCorpus,
+) -> Value {
+ let mut payload = output::search_result_value(result, true);
+ if let Some(matches) = payload
+ .as_object_mut()
+ .and_then(|object| object.get_mut("matches"))
+ .and_then(Value::as_array_mut)
+ {
+ for (record, artifact) in matches.iter_mut().zip(&result.matches) {
+ attach_composed_provenance(record, corpus, artifact.key.as_ref());
+ }
+ }
+ payload
}
/// The per-call budget clamp (ADR-113): a call may only *lower* the server
@@ -113,10 +190,16 @@ pub fn get_artifact(
payload.insert(k, v);
}
let status = artifact_status(&rac_engine::parse::parse_text(&content, &artifact.path));
- let mut prov = Map::new();
+ let mut prov = fixed_origin(artifact.origin.as_ref(), false).unwrap_or_default();
prov.insert("status".to_string(), json!(status));
- for (k, v) in provenance::artifact_provenance(root, &artifact.path) {
- prov.insert(k, v);
+ if artifact
+ .origin
+ .as_ref()
+ .is_none_or(|origin| origin.layer != rac_engine::corpus::Layer::Inherited)
+ {
+ for (k, v) in provenance::artifact_provenance(root, &artifact.path) {
+ prov.insert(k, v);
+ }
}
// Pinned key order: {schema_version, **artifact, content, provenance}.
payload.insert("content".to_string(), json!(content));
@@ -124,6 +207,56 @@ pub fn get_artifact(
serialize(&Value::Object(payload), budget)
}
+pub fn get_artifact_composed(
+ root: &str,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ artifact_id: &str,
+ budget: i64,
+) -> String {
+ let result = corpus.resolve_identity(artifact_id);
+ let Some(artifact) = result
+ .artifact
+ .as_ref()
+ .filter(|_| result.outcome == OUTCOME_RESOLVED)
+ else {
+ return serialize(&output::resolution_error_value(&result), budget);
+ };
+ let Some(key) = artifact.key.as_ref() else {
+ return serialize(&unreadable_payload(&artifact.id, &artifact.path), budget);
+ };
+ let Some(content) = corpus
+ .content(key)
+ .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok())
+ .map(|text| text.replace("\r\n", "\n").replace('\r', "\n"))
+ else {
+ return serialize(&unreadable_payload(&artifact.id, &artifact.path), budget);
+ };
+ let mut payload = Map::new();
+ payload.insert("schema_version".to_string(), json!("1"));
+ for (key, value) in artifact_value(artifact) {
+ payload.insert(key, value);
+ }
+ payload.insert("content".to_string(), json!(content));
+
+ let mut provenance = composed_provenance(corpus, Some(key)).unwrap_or_default();
+ let status = corpus
+ .item(key)
+ .map(|item| artifact_status(&item.artifact))
+ .unwrap_or_default();
+ provenance.insert("status".to_string(), json!(status));
+ if let Some(item) = corpus
+ .item(key)
+ .filter(|item| item.origin.layer == rac_engine::corpus::Layer::Local)
+ {
+ let physical = item.locator.path.to_string_lossy();
+ for (name, value) in provenance::artifact_provenance(root, &physical) {
+ provenance.insert(name, value);
+ }
+ }
+ payload.insert("provenance".to_string(), Value::Object(provenance));
+ serialize(&Value::Object(payload), budget)
+}
+
pub fn search_artifacts(
root: &str,
model: Option<&TrackerModel>,
@@ -153,7 +286,52 @@ pub fn search_artifacts(
}
};
rac_engine::commands::annotate_search_recency(&mut result.matches, root);
- serialize(&search_result_payload(&result), budget)
+ serialize(
+ &search_result_payload(&result, false),
+ budget,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+pub fn search_artifacts_composed(
+ root: &str,
+ cached: Option<&rac_engine::derived_cache::ReadModel>,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ query: &str,
+ artifact_type: Option<&str>,
+ tags: &[String],
+ live_only: bool,
+ budget: i64,
+) -> String {
+ let mut result = match cached {
+ Some(rac_engine::derived_cache::ReadModel::View(reader)) => {
+ rac_engine::read_model::store_search(
+ reader,
+ query,
+ artifact_type,
+ tags,
+ live_only,
+ )
+ }
+ Some(rac_engine::derived_cache::ReadModel::Fresh(derived)) => {
+ search_index_filtered(
+ &derived.index_entries,
+ query,
+ artifact_type,
+ tags,
+ live_only,
+ )
+ }
+ None => search_index_filtered(
+ &corpus.effective_index(),
+ query,
+ artifact_type,
+ tags,
+ live_only,
+ ),
+ };
+ rac_engine::commands::annotate_composed_search_recency(&mut result.matches, root, corpus);
+ serialize(&composed_search_result_payload(&result, corpus), budget)
}
pub fn find_decisions_tool(
@@ -165,32 +343,41 @@ pub fn find_decisions_tool(
) -> String {
// Python truthiness: a non-empty `path` selects path mode.
if let Some(p) = path.filter(|p| !p.is_empty()) {
+ let include_origin = false;
// Path mode builds through the same read-model as every other tool
// (ADR-103), served from precomputed scope rows.
let payload = match model {
Some(TrackerModel::View(reader)) => {
let rows = reader.scope_rows().unwrap_or_default();
- rac_engine::retrieve::scope_lookup_value(
+ rac_engine::retrieve::scope_lookup_value_with_origin(
&rac_engine::retrieve::decisions_for_path_with_rows(&rows, root, p),
+ include_origin,
+ )
+ }
+ Some(TrackerModel::Snapshot(derived)) => {
+ rac_engine::retrieve::scope_lookup_value_with_origin(
+ &rac_engine::retrieve::decisions_for_path_with_rows(
+ &derived.scope_rows,
+ root,
+ p,
+ ),
+ include_origin,
)
}
- Some(TrackerModel::Snapshot(derived)) => rac_engine::retrieve::scope_lookup_value(
- &rac_engine::retrieve::decisions_for_path_with_rows(
- &derived.scope_rows,
- root,
- p,
- ),
- ),
Some(TrackerModel::Delta(generation)) => {
- rac_engine::retrieve::scope_lookup_value(
+ rac_engine::retrieve::scope_lookup_value_with_origin(
&rac_engine::retrieve::decisions_for_path_with_rows(
&generation.scope.rows(),
root,
p,
),
+ include_origin,
)
}
- None => rac_engine::retrieve::find_decisions_path_payload(root, p),
+ None => rac_engine::retrieve::scope_lookup_value_with_origin(
+ &rac_engine::retrieve::decisions_for_path(root, p, true),
+ include_origin,
+ ),
};
return serialize(&payload, budget);
}
@@ -210,7 +397,45 @@ pub fn find_decisions_tool(
),
None => find_decisions(root, topic, true),
};
- let mut payload = search_result_payload(&result);
+ let mut payload = search_result_payload(&result, false);
+ payload
+ .as_object_mut()
+ .expect("object")
+ .insert("filter".to_string(), json!("live-decisions"));
+ serialize(&payload, budget)
+}
+
+pub fn find_decisions_tool_composed(
+ root: &str,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ topic: &str,
+ path: Option<&str>,
+ budget: i64,
+) -> String {
+ if let Some(path) = path.filter(|path| !path.is_empty()) {
+ let items: Vec<_> = corpus.effective().cloned().collect();
+ let rows = rac_engine::retrieve::scope_rows_from_items(&items);
+ let result = rac_engine::retrieve::decisions_for_path_with_rows(&rows, root, path);
+ return serialize(
+ &rac_engine::retrieve::scope_lookup_value_with_composed(&result, corpus),
+ budget,
+ );
+ }
+
+ let entries = corpus.effective_index();
+ let live: std::collections::HashSet<_> = corpus
+ .effective()
+ .filter(|item| {
+ item.spec.map(|spec| spec.name.as_str()) == Some("decision")
+ && rac_engine::resolve::is_live_decision(&item.artifact)
+ })
+ .map(|item| item.key.clone())
+ .collect();
+ let mut result = search_index_filtered(&entries, topic, Some("decision"), &[], false);
+ result
+ .matches
+ .retain(|artifact| artifact.key.as_ref().is_some_and(|key| live.contains(key)));
+ let mut payload = composed_search_result_payload(&result, corpus);
payload
.as_object_mut()
.expect("object")
@@ -223,9 +448,32 @@ pub fn get_related(
artifact_id: &str,
depth: i64,
budget: i64,
+) -> String {
+ get_related_inner(graph_view, None, artifact_id, depth, budget)
+}
+
+pub fn get_related_composed(
+ graph_view: &graph::GraphView,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ artifact_id: &str,
+ depth: i64,
+ budget: i64,
+) -> String {
+ get_related_inner(graph_view, Some(corpus), artifact_id, depth, budget)
+}
+
+fn get_related_inner(
+ graph_view: &graph::GraphView,
+ corpus: Option<&rac_engine::composition::ComposedCorpus>,
+ artifact_id: &str,
+ depth: i64,
+ budget: i64,
) -> String {
let graph_started = rac_engine::timing::start();
- let result = graph_view.resolve(artifact_id);
+ let result = corpus.map_or_else(
+ || graph_view.resolve(artifact_id),
+ |corpus| corpus.resolve_identity(artifact_id),
+ );
let Some(artifact) = result
.artifact
.as_ref()
@@ -233,8 +481,15 @@ pub fn get_related(
else {
return serialize(&output::resolution_error_value(&result), budget);
};
- let outgoing = graph_view.outgoing(&artifact.path);
- let incoming_result = graph_view.incoming(&artifact.path);
+ let include_origin = graph_view.is_federated();
+ let historical = corpus.is_some_and(|corpus| {
+ artifact
+ .key
+ .as_ref()
+ .is_some_and(|key| corpus.is_overridden(key))
+ });
+ let outgoing = graph_view.outgoing(artifact, historical);
+ let incoming_result = graph_view.incoming(artifact, historical);
let incoming: Vec = incoming_result
.items
.iter()
@@ -250,7 +505,13 @@ pub fn get_related(
ev.insert("relationship".to_string(), json!(r.section));
ev.insert("target".to_string(), json!(r.target));
m.insert("evidence".to_string(), Value::Object(ev));
- Value::Object(m)
+ let mut value = Value::Object(m);
+ if let Some(corpus) = corpus {
+ attach_composed_provenance(&mut value, corpus, r.key.as_ref());
+ } else {
+ attach_origin(&mut value, r.origin.as_ref(), include_origin);
+ }
+ value
})
.collect();
let mut payload = Map::new();
@@ -258,11 +519,17 @@ pub fn get_related(
for (k, v) in artifact_value(artifact) {
payload.insert(k, v);
}
+ if let Some(provenance) = corpus
+ .and_then(|corpus| composed_provenance(corpus, artifact.key.as_ref()))
+ .or_else(|| fixed_origin(artifact.origin.as_ref(), include_origin))
+ {
+ payload.insert("provenance".to_string(), Value::Object(provenance));
+ }
payload.insert("outgoing".to_string(), outgoing.to_value());
payload.insert("incoming".to_string(), Value::Array(incoming));
let mut neighborhood_truncated = false;
if depth > 1 {
- let hood = graph_view.neighborhood(&artifact.path, depth);
+ let hood = graph_view.neighborhood(artifact, depth, historical);
let nodes: Vec = hood
.nodes
.iter()
@@ -274,7 +541,13 @@ pub fn get_related(
m.insert("title".to_string(), opt_str(&n.title));
m.insert("path".to_string(), json!(n.path));
m.insert("hops".to_string(), json!(n.hops));
- Value::Object(m)
+ let mut value = Value::Object(m);
+ if let Some(corpus) = corpus {
+ attach_composed_provenance(&mut value, corpus, n.key.as_ref());
+ } else {
+ attach_origin(&mut value, n.origin.as_ref(), include_origin);
+ }
+ value
})
.collect();
payload.insert("neighborhood".to_string(), Value::Array(nodes));
@@ -395,6 +668,42 @@ pub fn get_summary(root: &str, model: Option<&TrackerModel>, budget: i64) -> Str
serialize(&Value::Object(payload), budget)
}
+pub fn get_summary_composed(
+ root: &str,
+ generation: &rac_engine::derived_cache::LogicalGeneration,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ budget: i64,
+) -> String {
+ let identity = generation
+ .identity()
+ .expect("federated request has generation identity");
+ let parent = generation
+ .verified_parent()
+ .expect("federated request has verified parent");
+ let items: Vec<_> = corpus.effective().cloned().collect();
+ let overrides = rac_engine::validate::overrides_from_config_bytes(&parent.child_config_bytes);
+ let summary = rac_engine::portfolio::portfolio_from_corpus_with_analysis(
+ &identity.child_corpus_path,
+ &items,
+ identity.recursive,
+ &overrides,
+ corpus.relationship_summary(),
+ corpus.validate_relationships(root, identity.recursive).ok(),
+ );
+ let mut payload = rac_engine::output::portfolio_summary_value(&summary);
+ if payload
+ .get("empty")
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+ {
+ payload
+ .as_object_mut()
+ .expect("portfolio payload is an object")
+ .insert("guidance".to_string(), json!(EMPTY_GUIDANCE));
+ }
+ serialize(&payload, budget)
+}
+
pub fn retrieve_grounding(
root: &str,
model: Option<&TrackerModel>,
@@ -434,6 +743,28 @@ pub fn retrieve_grounding(
serialize(&payload, effective)
}
+pub fn retrieve_grounding_composed(
+ root: &str,
+ corpus: &rac_engine::composition::ComposedCorpus,
+ task: &str,
+ scope: &str,
+ top_k: i64,
+ effective: i64,
+ live_only: bool,
+) -> String {
+ let scope = if scope.is_empty() { None } else { Some(scope) };
+ let payload = rac_engine::retrieve::retrieve_grounding_from_composed(
+ root,
+ task,
+ scope,
+ top_k,
+ effective,
+ live_only,
+ corpus,
+ );
+ serialize(&payload, effective)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -446,6 +777,31 @@ mod tests {
)
}
+ #[test]
+ fn fixed_origin_is_additive_only_in_a_federated_context() {
+ let local = rac_engine::corpus::CorpusLayer::local("acme/app").origin();
+ assert!(fixed_origin(Some(&local), false).is_none());
+ assert_eq!(
+ Value::Object(fixed_origin(Some(&local), true).unwrap()),
+ json!({"source": "acme/app", "layer": "local"})
+ );
+
+ let inherited = rac_engine::corpus::CorpusLayer::inherited(
+ "acme/standards",
+ "standards",
+ "sha256:0123",
+ )
+ .origin();
+ assert_eq!(
+ Value::Object(fixed_origin(Some(&inherited), true).unwrap()),
+ json!({
+ "source": "acme/standards",
+ "layer": "inherited",
+ "pin": "sha256:0123"
+ })
+ );
+ }
+
#[test]
fn delta_point_and_search_routes_use_incremental_generations() {
let root =
diff --git a/rust/decided-mcp/tests/docs_contract.rs b/rust/decided-mcp/tests/docs_contract.rs
index 1f80b58e..55d373b4 100644
--- a/rust/decided-mcp/tests/docs_contract.rs
+++ b/rust/decided-mcp/tests/docs_contract.rs
@@ -54,6 +54,7 @@ const SUPPORTED_DECIDED_COMMANDS: &[&str] = &[
"retrieve",
"sentry",
"herald",
+ "corpus",
];
fn documented_decided_command(line: &str) -> Option<&str> {
diff --git a/rust/decided-mcp/tests/federation.rs b/rust/decided-mcp/tests/federation.rs
new file mode 100644
index 00000000..96b3b99e
--- /dev/null
+++ b/rust/decided-mcp/tests/federation.rs
@@ -0,0 +1,622 @@
+use serde_json::{json, Value};
+use std::fs;
+use std::io::{BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+static COUNTER: AtomicUsize = AtomicUsize::new(0);
+
+fn scratch(tag: &str) -> PathBuf {
+ let sequence = COUNTER.fetch_add(1, Ordering::SeqCst);
+ let root = std::env::temp_dir().join(format!(
+ "decided-mcp-federation-{tag}-{}-{sequence}",
+ std::process::id()
+ ));
+ let _ = fs::remove_dir_all(&root);
+ fs::create_dir_all(&root).expect("create federation scratch directory");
+ root
+}
+
+fn copy_tree(source: &Path, target: &Path) {
+ fs::create_dir_all(target).expect("create copied directory");
+ for entry in fs::read_dir(source).expect("read fixture directory") {
+ let entry = entry.expect("read fixture entry");
+ let destination = target.join(entry.file_name());
+ if entry.file_type().expect("fixture file type").is_dir() {
+ copy_tree(&entry.path(), &destination);
+ } else {
+ fs::copy(entry.path(), destination).expect("copy fixture file");
+ }
+ }
+}
+
+fn eval_fixture(tag: &str) -> PathBuf {
+ let target = scratch(tag);
+ let source = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../fixtures/eval/federation/child");
+ copy_tree(&source, &target);
+ target
+}
+
+fn request(id: usize, name: &str, arguments: Value) -> String {
+ json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": "tools/call",
+ "params": {"name": name, "arguments": arguments}
+ })
+ .to_string()
+}
+
+fn run(root: &Path, extra_args: &[&str], requests: &[String]) -> Vec {
+ let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp"))
+ .arg("--root")
+ .arg(root)
+ .args(extra_args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn decided-mcp");
+ {
+ let stdin = child.stdin.as_mut().expect("server stdin");
+ for request in requests {
+ writeln!(stdin, "{request}").expect("write MCP request");
+ }
+ }
+ drop(child.stdin.take());
+ let output = child.wait_with_output().expect("wait for decided-mcp");
+ assert!(
+ output.status.success(),
+ "server failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ String::from_utf8(output.stdout)
+ .expect("UTF-8 MCP output")
+ .lines()
+ .map(|line| serde_json::from_str(line).expect("JSON-RPC response"))
+ .collect()
+}
+
+fn tool_text(frame: &Value) -> &str {
+ frame
+ .pointer("/result/content/0/text")
+ .and_then(Value::as_str)
+ .expect("tool text")
+}
+
+fn tool_value(frame: &Value) -> Value {
+ serde_json::from_str(tool_text(frame)).expect("tool payload JSON")
+}
+
+fn spawn_live(root: &Path, extra_args: &[&str]) -> (Child, ChildStdin, BufReader) {
+ let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp"))
+ .arg("--root")
+ .arg(root)
+ .args(extra_args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn long-lived decided-mcp");
+ let stdin = child.stdin.take().expect("server stdin");
+ let stdout = BufReader::new(child.stdout.take().expect("server stdout"));
+ (child, stdin, stdout)
+}
+
+fn live_call(
+ stdin: &mut ChildStdin,
+ stdout: &mut BufReader,
+ call: &str,
+) -> Value {
+ writeln!(stdin, "{call}").expect("write MCP request");
+ stdin.flush().expect("flush MCP request");
+ let mut line = String::new();
+ stdout.read_line(&mut line).expect("read MCP response");
+ serde_json::from_str(line.trim()).expect("MCP response JSON")
+}
+
+fn finish_live(child: Child, stdin: ChildStdin, stdout: BufReader) {
+ drop(stdin);
+ drop(stdout);
+ let output = child.wait_with_output().expect("wait for long-lived server");
+ assert!(
+ output.status.success(),
+ "server failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+#[test]
+fn all_six_tools_share_one_verified_composition_with_or_without_cache() {
+ let repository = eval_fixture("six-tools");
+ let corpus = repository.join("decisions");
+ let requests = vec![
+ request(
+ 1,
+ "get_artifact",
+ json!({"id": "standards::FEDEVAL-000000000001"}),
+ ),
+ request(
+ 2,
+ "search_artifacts",
+ json!({"query": "quantum ledger compaction"}),
+ ),
+ request(
+ 3,
+ "retrieve_grounding",
+ json!({"task": "quantum ledger compaction", "top_k": 3}),
+ ),
+ request(
+ 4,
+ "find_decisions",
+ json!({"topic": "quantum ledger compaction"}),
+ ),
+ request(
+ 5,
+ "get_related",
+ json!({"id": "standards::FEDEVAL-000000000002", "depth": 2}),
+ ),
+ request(6, "get_summary", json!({})),
+ ];
+
+ let uncached = run(&corpus, &["--no-cache"], &requests);
+ let cached = run(&corpus, &[], &requests);
+ assert_eq!(
+ uncached.iter().map(tool_text).collect::>(),
+ cached.iter().map(tool_text).collect::>()
+ );
+
+ let artifact = tool_value(&cached[0]);
+ assert_eq!(artifact["provenance"]["source"], json!("eval/standards"));
+ assert_eq!(artifact["provenance"]["layer"], json!("inherited"));
+ assert!(artifact["provenance"]["pin"]
+ .as_str()
+ .is_some_and(|pin| pin.starts_with("sha256:")));
+
+ let search = tool_value(&cached[1]);
+ assert_eq!(search["matches"][0]["id"], json!("FEDEVAL-000000000001"));
+ assert_eq!(
+ search["matches"][0]["provenance"]["source"],
+ json!("eval/standards")
+ );
+ assert!(search["matches"].as_array().unwrap().iter().any(|record| {
+ record["provenance"]["source"] == json!("eval/child")
+ && record.get("recency").is_some()
+ }));
+ assert!(search["matches"].as_array().unwrap().iter().all(|record| {
+ record["provenance"]["source"] != json!("eval/standards")
+ || record.get("recency").is_none()
+ }));
+ let grounding = tool_value(&cached[2]);
+ assert_eq!(
+ grounding["items"][0]["provenance"]["layer"],
+ json!("inherited")
+ );
+ assert_eq!(tool_value(&cached[5])["artifacts"]["total"], json!(41));
+ fs::remove_dir_all(repository).expect("remove six-tool fixture");
+}
+
+fn decision(id: &str, title: &str) -> String {
+ format!(
+ "---\nschema_version: 1\nid: {id}\ntype: decision\n---\n# {title}\n\n## Status\n\nAccepted\n\n## Context\n\nA reviewed context.\n\n## Decision\n\nKeep the reviewed rule.\n\n## Consequences\n\nThe rule is deterministic.\n"
+ )
+}
+
+fn decision_with_relationships(id: &str, title: &str, relationships: &str) -> String {
+ format!("{}\n{relationships}\n", decision(id, title))
+}
+
+fn override_fixture() -> PathBuf {
+ let child = scratch("override");
+ let parent = child.join("vendor/standards");
+ fs::create_dir_all(parent.join(".decided")).expect("parent config directory");
+ fs::create_dir_all(parent.join("decisions")).expect("parent corpus directory");
+ fs::create_dir_all(child.join(".decided")).expect("child config directory");
+ fs::create_dir_all(child.join("decisions")).expect("child corpus directory");
+ fs::write(
+ parent.join(".decided/config.yaml"),
+ "repository_key: STD\ncorpus:\n source: acme/standards\n",
+ )
+ .expect("parent config");
+ fs::write(
+ parent.join("decisions/parent.md"),
+ decision_with_relationships(
+ "STD-01JY4M8X2QZ7",
+ "Parent Policy",
+ "## Related Decisions\n\n- STD-01JY4M8X2QZA",
+ ),
+ )
+ .expect("parent decision");
+ fs::write(
+ parent.join("decisions/target.md"),
+ decision("STD-01JY4M8X2QZA", "Retained Target"),
+ )
+ .expect("parent target decision");
+ fs::write(
+ child.join(".decided/config.yaml"),
+ "repository_key: APP\ncorpus:\n source: acme/app\n",
+ )
+ .expect("child config");
+ fs::write(
+ child.join("decisions/replacement.md"),
+ decision("APP-01JY4M8X2QZ8", "Local Replacement"),
+ )
+ .expect("replacement decision");
+ fs::write(
+ child.join("decisions/rationale.md"),
+ decision("APP-01JY4M8X2QZ9", "Override Rationale"),
+ )
+ .expect("rationale decision");
+ let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions")
+ .expect("calculate parent digest")
+ .digest;
+ fs::write(
+ child.join(".decided/corpus.md"),
+ format!(
+ "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n\n## overrides\n\n```yaml\nversion: 1\nitems:\n - parent: standards::STD-01JY4M8X2QZ7\n with: APP-01JY4M8X2QZ8\n rationale: APP-01JY4M8X2QZ9\n```\n"
+ ),
+ )
+ .expect("child manifest");
+ child
+}
+
+fn alias_collision_fixture() -> PathBuf {
+ let child = scratch("alias-collision");
+ let parent = child.join("vendor/standards");
+ fs::create_dir_all(parent.join(".decided")).expect("parent config directory");
+ fs::create_dir_all(parent.join("decisions")).expect("parent corpus directory");
+ fs::create_dir_all(child.join(".decided")).expect("child config directory");
+ fs::create_dir_all(child.join("decisions")).expect("child corpus directory");
+ fs::write(
+ parent.join(".decided/config.yaml"),
+ "repository_key: STD\ncorpus:\n source: acme/standards\n",
+ )
+ .expect("parent config");
+ fs::write(
+ parent.join("decisions/shared.md"),
+ decision("STD-01JY4M8X2QZ7", "Parent Shared Policy"),
+ )
+ .expect("parent shared decision");
+ fs::write(
+ child.join(".decided/config.yaml"),
+ "repository_key: APP\ncorpus:\n source: acme/app\n",
+ )
+ .expect("child config");
+ fs::write(
+ child.join("decisions/shared.md"),
+ decision("APP-01JY4M8X2QZ8", "Local Shared Policy"),
+ )
+ .expect("local shared decision");
+ let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions")
+ .expect("calculate parent digest")
+ .digest;
+ fs::write(
+ child.join(".decided/corpus.md"),
+ format!(
+ "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n"
+ ),
+ )
+ .expect("child manifest");
+ child
+}
+
+#[test]
+fn composed_exact_tools_share_source_aware_ambiguity_and_qualification() {
+ let repository = alias_collision_fixture();
+ let corpus = repository.join("decisions");
+ let frames = run(
+ &corpus,
+ &["--no-cache"],
+ &[
+ request(1, "get_artifact", json!({"id": "shared"})),
+ request(2, "get_related", json!({"id": "shared"})),
+ request(3, "get_artifact", json!({"id": "standards::shared"})),
+ request(
+ 4,
+ "get_artifact",
+ json!({"id": "other::STD-01JY4M8X2QZ7"}),
+ ),
+ request(
+ 5,
+ "get_artifact",
+ json!({"id": "standards::STD-01JY4M8X2QZ7"}),
+ ),
+ ],
+ );
+ let artifact_duplicate = tool_value(&frames[0]);
+ let related_duplicate = tool_value(&frames[1]);
+ let expected_paths = json!([
+ "acme/app::shared.md",
+ "acme/standards::shared.md"
+ ]);
+ assert_eq!(artifact_duplicate["error"], json!("duplicate"));
+ assert_eq!(related_duplicate["error"], json!("duplicate"));
+ assert_eq!(artifact_duplicate["paths"], expected_paths);
+ assert_eq!(related_duplicate["paths"], expected_paths);
+ assert_eq!(tool_value(&frames[2])["error"], json!("not-found"));
+ assert_eq!(tool_value(&frames[3])["error"], json!("not-found"));
+ assert_eq!(tool_value(&frames[4])["id"], json!("STD-01JY4M8X2QZ7"));
+ fs::remove_dir_all(repository).expect("remove alias collision fixture");
+}
+
+#[test]
+fn qualified_history_and_canonical_redirect_keep_complete_override_provenance() {
+ let repository = override_fixture();
+ let corpus = repository.join("decisions");
+ let frames = run(
+ &corpus,
+ &["--no-cache"],
+ &[
+ request(
+ 1,
+ "get_artifact",
+ json!({"id": "standards::STD-01JY4M8X2QZ7"}),
+ ),
+ request(2, "get_artifact", json!({"id": "STD-01JY4M8X2QZ7"})),
+ request(3, "get_related", json!({"id": "STD-01JY4M8X2QZ7", "depth": 2})),
+ request(4, "get_related", json!({"id": "STD-01JY4M8X2QZA", "depth": 2})),
+ request(
+ 5,
+ "get_related",
+ json!({"id": "standards::STD-01JY4M8X2QZ7", "depth": 2}),
+ ),
+ ],
+ );
+ let parent = tool_value(&frames[0]);
+ let replacement = tool_value(&frames[1]);
+ assert_eq!(parent["id"], json!("STD-01JY4M8X2QZ7"));
+ assert_eq!(parent["provenance"]["overrides"][0]["state"], json!("overridden"));
+ assert_eq!(replacement["id"], json!("APP-01JY4M8X2QZ8"));
+ let mapping = &replacement["provenance"]["overrides"][0];
+ assert_eq!(mapping["state"], json!("replacement"));
+ assert_eq!(mapping["parent"]["source"], json!("acme/standards"));
+ assert_eq!(mapping["replacement"]["source"], json!("acme/app"));
+ assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9"));
+
+ let redirected_graph = tool_value(&frames[2]);
+ assert_eq!(redirected_graph["id"], json!("APP-01JY4M8X2QZ8"));
+ assert!(redirected_graph["outgoing"]
+ .get("related_decisions")
+ .is_none());
+
+ let target_graph = tool_value(&frames[3]);
+ assert!(target_graph["incoming"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .all(|entry| entry["id"] != json!("STD-01JY4M8X2QZ7")));
+
+ let parent_history = tool_value(&frames[4]);
+ assert_eq!(parent_history["id"], json!("STD-01JY4M8X2QZ7"));
+ assert_eq!(
+ parent_history["outgoing"]["related_decisions"],
+ json!(["STD-01JY4M8X2QZA"])
+ );
+ fs::remove_dir_all(repository).expect("remove override fixture");
+}
+
+#[test]
+fn tight_budgets_keep_parent_and_replacement_override_provenance_atomic() {
+ let repository = override_fixture();
+ let corpus = repository.join("decisions");
+ let mut requests = Vec::new();
+ let mut expectations = Vec::new();
+ for budget in [384, 512, 768] {
+ requests.push(request(
+ requests.len() + 1,
+ "get_artifact",
+ json!({"id": "standards::STD-01JY4M8X2QZ7", "budget": budget}),
+ ));
+ expectations.push((budget, "overridden"));
+ requests.push(request(
+ requests.len() + 1,
+ "get_artifact",
+ json!({"id": "STD-01JY4M8X2QZ7", "budget": budget}),
+ ));
+ expectations.push((budget, "replacement"));
+ }
+
+ let frames = run(&corpus, &["--no-cache"], &requests);
+ for (frame, (budget, state)) in frames.iter().zip(expectations) {
+ let text = tool_text(frame);
+ assert!(
+ text.chars().count() <= budget,
+ "{} characters exceeded budget {budget}",
+ text.chars().count()
+ );
+ let value = tool_value(frame);
+ if value["error"] == json!(rac_engine::budget::BUDGET_ERROR) {
+ continue;
+ }
+ let mapping = &value["provenance"]["overrides"][0];
+ assert_eq!(mapping["state"], json!(state));
+ assert_eq!(mapping["parent"]["source"], json!("acme/standards"));
+ assert_eq!(mapping["parent"]["id"], json!("STD-01JY4M8X2QZ7"));
+ assert_eq!(mapping["replacement"]["source"], json!("acme/app"));
+ assert_eq!(mapping["replacement"]["id"], json!("APP-01JY4M8X2QZ8"));
+ assert_eq!(mapping["rationale"]["source"], json!("acme/app"));
+ assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9"));
+ }
+ fs::remove_dir_all(repository).expect("remove tight-budget override fixture");
+}
+
+#[test]
+fn stale_parent_blocks_the_next_request_instead_of_serving_the_old_generation() {
+ let repository = eval_fixture("stale-parent");
+ let corpus = repository.join("decisions");
+ let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp"))
+ .arg("--root")
+ .arg(&corpus)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn long-lived decided-mcp");
+ let mut stdin = child.stdin.take().expect("server stdin");
+ let mut stdout = BufReader::new(child.stdout.take().expect("server stdout"));
+ let query = request(
+ 1,
+ "search_artifacts",
+ json!({"query": "quantum ledger compaction"}),
+ );
+ writeln!(stdin, "{query}").expect("write first request");
+ stdin.flush().expect("flush first request");
+ let mut first_line = String::new();
+ stdout.read_line(&mut first_line).expect("read first response");
+ let first: Value = serde_json::from_str(first_line.trim()).expect("first response JSON");
+ assert_eq!(first["result"]["isError"], json!(false));
+
+ let parent_file = repository
+ .join("vendor/standards/decisions/quantum-ledger-compaction-anchor.md");
+ fs::write(&parent_file, "changed after verification\n").expect("mutate parent bytes");
+ let second = request(
+ 2,
+ "search_artifacts",
+ json!({"query": "quantum ledger compaction"}),
+ );
+ writeln!(stdin, "{second}").expect("write second request");
+ stdin.flush().expect("flush second request");
+ let mut second_line = String::new();
+ stdout
+ .read_line(&mut second_line)
+ .expect("read second response");
+ let second: Value = serde_json::from_str(second_line.trim()).expect("second response JSON");
+ assert_eq!(second["result"]["isError"], json!(true));
+ assert!(tool_text(&second).contains("parent-corpus-digest-mismatch"));
+ assert!(!tool_text(&second).contains("FEDEVAL-000000000001"));
+
+ drop(stdin);
+ let output = child.wait_with_output().expect("wait for long-lived server");
+ assert!(
+ output.status.success(),
+ "server failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ fs::remove_dir_all(repository).expect("remove stale-parent fixture");
+}
+
+#[test]
+fn deleting_child_config_after_federation_is_seen_fails_closed() {
+ let repository = eval_fixture("deleted-config");
+ let corpus = repository.join("decisions");
+ let (child, mut stdin, mut stdout) = spawn_live(&corpus, &[]);
+
+ let first = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(1, "get_summary", json!({})),
+ );
+ assert_eq!(first["result"]["isError"], json!(false));
+
+ fs::remove_file(repository.join(".decided/config.yaml")).expect("remove child config");
+ let second = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(2, "get_summary", json!({})),
+ );
+ assert_eq!(second["result"]["isError"], json!(true));
+ assert!(tool_text(&second).contains("parent-corpus-child-config-missing"));
+ assert!(!tool_text(&second).contains("\"total\":41"));
+
+ finish_live(child, stdin, stdout);
+ fs::remove_dir_all(repository).expect("remove deleted-config fixture");
+}
+
+#[test]
+fn non_regular_or_dangling_manifest_never_falls_back_to_legacy() {
+ let repository = eval_fixture("non-file-manifest");
+ let corpus = repository.join("decisions");
+ let manifest = repository.join(".decided/corpus.md");
+ let (child, mut stdin, mut stdout) = spawn_live(&corpus, &["--no-cache"]);
+
+ let first = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(1, "get_summary", json!({})),
+ );
+ assert_eq!(first["result"]["isError"], json!(false));
+
+ fs::remove_file(&manifest).expect("remove manifest");
+ fs::create_dir(&manifest).expect("replace manifest with directory");
+ let directory_response = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(2, "get_summary", json!({})),
+ );
+ assert_eq!(directory_response["result"]["isError"], json!(true));
+ assert!(tool_text(&directory_response).contains("parent-corpus-symlink-traversal"));
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::symlink;
+ fs::remove_dir(&manifest).expect("remove manifest directory");
+ symlink("missing-corpus-manifest.md", &manifest).expect("create dangling manifest");
+ let dangling_response = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(3, "get_summary", json!({})),
+ );
+ assert_eq!(dangling_response["result"]["isError"], json!(true));
+ assert!(tool_text(&dangling_response).contains("parent-corpus-symlink-traversal"));
+ }
+
+ finish_live(child, stdin, stdout);
+ fs::remove_dir_all(repository).expect("remove non-file-manifest fixture");
+}
+
+#[test]
+fn a_manifest_added_to_a_live_server_activates_and_cannot_be_removed() {
+ let repository = eval_fixture("topology-add-remove");
+ let corpus = repository.join("decisions");
+ let manifest_path = repository.join(".decided/corpus.md");
+ let manifest = fs::read(&manifest_path).expect("read manifest before startup");
+ fs::remove_file(&manifest_path).expect("start without manifest");
+ let (child, mut stdin, mut stdout) = spawn_live(&corpus, &[]);
+
+ let legacy = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(
+ 1,
+ "search_artifacts",
+ json!({"query": "quantum ledger compaction"}),
+ ),
+ );
+ assert_eq!(legacy["result"]["isError"], json!(false));
+ assert!(tool_value(&legacy)["matches"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .all(|item| item["id"] != json!("FEDEVAL-000000000001")));
+
+ fs::write(&manifest_path, &manifest).expect("add manifest");
+ let federated = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(
+ 2,
+ "search_artifacts",
+ json!({"query": "quantum ledger compaction"}),
+ ),
+ );
+ assert_eq!(federated["result"]["isError"], json!(false));
+ assert_eq!(
+ tool_value(&federated)["matches"][0]["id"],
+ json!("FEDEVAL-000000000001")
+ );
+
+ fs::remove_file(&manifest_path).expect("remove observed manifest");
+ let removed = live_call(
+ &mut stdin,
+ &mut stdout,
+ &request(3, "get_summary", json!({})),
+ );
+ assert_eq!(removed["result"]["isError"], json!(true));
+ assert!(tool_text(&removed).contains("federation manifest disappeared"));
+ assert!(!tool_text(&removed).contains("FEDEVAL-000000000001"));
+
+ finish_live(child, stdin, stdout);
+ fs::remove_dir_all(repository).expect("remove topology fixture");
+}
diff --git a/rust/decided-mcp/tests/http_transport.rs b/rust/decided-mcp/tests/http_transport.rs
index 3b7764f5..138d90da 100644
--- a/rust/decided-mcp/tests/http_transport.rs
+++ b/rust/decided-mcp/tests/http_transport.rs
@@ -109,6 +109,80 @@ impl Server {
panic!("HTTP server did not start");
}
+ fn start_federated(tag: &str) -> Self {
+ let corpus = scratch(tag);
+ let parent = corpus.join("vendor/standards");
+ let audit_path = corpus.join("audit.jsonl");
+ std::fs::create_dir_all(corpus.join(".decided")).unwrap();
+ std::fs::create_dir_all(corpus.join("decisions")).unwrap();
+ std::fs::create_dir_all(parent.join(".decided")).unwrap();
+ std::fs::create_dir_all(parent.join("decisions")).unwrap();
+ std::fs::write(
+ corpus.join(".decided/config.yaml"),
+ format!(
+ "repository_key: APP\ncorpus:\n source: acme/app\naudit:\n enabled: true\n path: {}\n",
+ audit_path.display()
+ ),
+ )
+ .unwrap();
+ std::fs::write(
+ parent.join(".decided/config.yaml"),
+ "repository_key: STD\ncorpus:\n source: acme/standards\n",
+ )
+ .unwrap();
+ std::fs::write(
+ parent.join("decisions/parent.md"),
+ "---\nschema_version: 1\nid: STD-01JY4M8X2QZ7\ntype: decision\n---\n# Parent Audit Policy\n\n## Status\n\nAccepted\n\n## Context\n\nAudit parent context.\n\n## Decision\n\nKeep federation auditable.\n\n## Consequences\n\nFailures are recorded.\n",
+ )
+ .unwrap();
+ let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions")
+ .unwrap()
+ .digest;
+ std::fs::write(
+ corpus.join(".decided/corpus.md"),
+ format!(
+ "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n"
+ ),
+ )
+ .unwrap();
+
+ let port = TcpListener::bind(("127.0.0.1", 0))
+ .unwrap()
+ .local_addr()
+ .unwrap()
+ .port();
+ let root = corpus.join("decisions").to_string_lossy().into_owned();
+ let port_text = port.to_string();
+ let child = Command::new(env!("CARGO_BIN_EXE_decided-mcp"))
+ .args([
+ "--root",
+ &root,
+ "--no-cache",
+ "--transport",
+ "http",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ &port_text,
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn federated HTTP server");
+ let server = Self {
+ child,
+ corpus,
+ port,
+ };
+ for _ in 0..100 {
+ if TcpStream::connect(("127.0.0.1", port)).is_ok() {
+ return server;
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ panic!("federated HTTP server did not start");
+ }
+
fn post(&self, body: &Value, method_header: &str, name: Option<&str>) -> (String, Value) {
self.post_with_version_and_origin(body, method_header, name, CURRENT_VERSION, None)
}
@@ -552,6 +626,53 @@ fn http_audit_records_every_result_collection() {
assert!(returned[6].is_empty());
}
+#[test]
+fn stale_parent_failure_is_audited_once_before_http_error_response() {
+ let _guard = serial_http_test();
+ let server = Server::start_federated("http-audit-stale-parent");
+ let arguments = json!({"query": "parent audit policy"});
+
+ let (first_status, first) = server.post_with_principal_headers(
+ &tool_call(1, "search_artifacts", arguments.clone()),
+ "tools/call",
+ Some("search_artifacts"),
+ &[("X-AsDecided-Principal", "alice@example.com")],
+ );
+ assert_eq!(first_status, "HTTP/1.1 200 OK");
+ assert_eq!(first["result"]["isError"], json!(false));
+
+ std::fs::write(
+ server.corpus.join("vendor/standards/decisions/parent.md"),
+ "changed after the verified generation\n",
+ )
+ .expect("mutate verified parent");
+ let before = server.audit_events().len();
+ let (second_status, second) = server.post_with_principal_headers(
+ &tool_call(2, "search_artifacts", arguments),
+ "tools/call",
+ Some("search_artifacts"),
+ &[("X-AsDecided-Principal", "alice@example.com")],
+ );
+ assert_eq!(second_status, "HTTP/1.1 200 OK");
+ assert_eq!(second["result"]["isError"], json!(true));
+ assert!(second["result"]["content"][0]["text"]
+ .as_str()
+ .unwrap()
+ .contains("parent-corpus-digest-mismatch"));
+
+ let events = server.audit_events();
+ assert_eq!(events.len(), before + 1, "failure records exactly one event");
+ let failure = events.last().unwrap();
+ assert_eq!(failure["tool"], json!("search_artifacts"));
+ assert_eq!(failure["query"], json!({
+ "query": "parent audit policy",
+ "type": null
+ }));
+ assert_eq!(failure["outcome"], json!("error"));
+ assert_eq!(failure["returned"], json!([]));
+ assert_eq!(failure["principal"], json!("alice@example.com"));
+}
+
#[test]
fn http_principal_is_explicit_and_response_independent() {
let _guard = serial_http_test();
diff --git a/rust/decided/tests/cli.rs b/rust/decided/tests/cli.rs
index 833c8b43..9fc0657d 100644
--- a/rust/decided/tests/cli.rs
+++ b/rust/decided/tests/cli.rs
@@ -149,6 +149,53 @@ fn export_schema_rejects_an_unknown_projection() {
);
}
+#[test]
+fn export_local_only_is_additive_for_the_three_read_projections() {
+ let root = scratch_root();
+ let root_text = root.to_string_lossy().into_owned();
+ for mode in [None, Some("--documents"), Some("--graph")] {
+ let mut baseline_args = vec!["export", &root_text];
+ if let Some(mode) = mode {
+ baseline_args.push(mode);
+ }
+ let baseline = run(&baseline_args);
+ assert!(baseline.status.success());
+
+ let mut local_args = baseline_args;
+ local_args.push("--local-only");
+ let local = run(&local_args);
+ assert!(
+ local.status.success(),
+ "{mode:?} local projection failed: {}",
+ String::from_utf8_lossy(&local.stderr)
+ );
+ assert_eq!(local.stdout, baseline.stdout);
+ assert_eq!(local.stderr, baseline.stderr);
+ }
+ fs::remove_dir_all(root).expect("remove local-only export corpus");
+}
+
+#[test]
+fn export_local_only_rejects_non_composed_modes() {
+ let root = scratch_root();
+ let root_text = root.to_string_lossy().into_owned();
+ for args in [
+ vec!["export", &root_text, "--okf", "--local-only"],
+ vec!["export", &root_text, "--agent-rules", "--local-only"],
+ vec!["export", "--schema", "viewer", "--local-only"],
+ ] {
+ let output = run(&args);
+ assert_eq!(output.status.code(), Some(2));
+ assert!(
+ String::from_utf8_lossy(&output.stderr)
+ .contains("--local-only is available only for viewer, documents, and graph exports"),
+ "stderr={}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+ fs::remove_dir_all(root).expect("remove local-only refusal corpus");
+}
+
#[test]
fn export_rejects_an_invalid_configured_corpus_source() {
let root = scratch_root();
@@ -171,3 +218,87 @@ fn export_rejects_an_invalid_configured_corpus_source() {
fs::remove_dir_all(root).expect("remove CLI smoke corpus");
}
+
+#[test]
+fn corpus_digest_prints_the_canonical_read_only_pin() {
+ let root = scratch_root();
+ fs::create_dir_all(root.join(".decided")).unwrap();
+ fs::create_dir_all(root.join("decisions/sub")).unwrap();
+ fs::write(
+ root.join(".decided/config.yaml"),
+ b"repository_key: STD\ncorpus:\n source: acme/standards\n",
+ )
+ .unwrap();
+ fs::write(root.join("decisions/a.md"), b"alpha\n").unwrap();
+ fs::write(root.join("decisions/sub/b.md"), b"beta\r\n").unwrap();
+ fs::write(root.join("decisions/ignored.MD"), b"ignored\n").unwrap();
+ let before_config = fs::read(root.join(".decided/config.yaml")).unwrap();
+ let before_a = fs::read(root.join("decisions/a.md")).unwrap();
+ let root_text = root.to_string_lossy().into_owned();
+
+ let output = run(&[
+ "corpus",
+ "digest",
+ "--root",
+ &root_text,
+ "--corpus",
+ "decisions",
+ ]);
+ assert!(
+ output.status.success(),
+ "stdout={}, stderr={}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert_eq!(
+ output.stdout,
+ b"sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325\n"
+ );
+ assert!(output.stderr.is_empty());
+ assert_eq!(fs::read(root.join(".decided/config.yaml")).unwrap(), before_config);
+ assert_eq!(fs::read(root.join("decisions/a.md")).unwrap(), before_a);
+
+ fs::remove_dir_all(root).expect("remove CLI digest corpus");
+}
+
+#[test]
+fn corpus_digest_bounds_config_and_rejects_escaping_corpus_paths() {
+ let root = scratch_root();
+ fs::create_dir_all(root.join("parent/decisions")).unwrap();
+ fs::write(root.join("parent/decisions/a.md"), b"alpha\n").unwrap();
+ fs::create_dir_all(root.join(".decided")).unwrap();
+ fs::write(
+ root.join(".decided/config.yaml"),
+ b"repository_key: CHILD\ncorpus:\n source: acme/child\n",
+ )
+ .unwrap();
+ let parent = root.join("parent").to_string_lossy().into_owned();
+
+ let missing = run(&[
+ "corpus",
+ "digest",
+ "--root",
+ &parent,
+ "--corpus",
+ "decisions",
+ ]);
+ assert_eq!(missing.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&missing.stderr).contains("parent-corpus-config-missing")
+ );
+
+ let escaping = run(&[
+ "corpus",
+ "digest",
+ "--root",
+ &parent,
+ "--corpus",
+ "../decisions",
+ ]);
+ assert_eq!(escaping.status.code(), Some(1));
+ assert!(
+ String::from_utf8_lossy(&escaping.stderr).contains("parent-corpus-path-escape")
+ );
+
+ fs::remove_dir_all(root).expect("remove CLI digest corpus");
+}
diff --git a/rust/fixtures/eval/README.md b/rust/fixtures/eval/README.md
index 519cf7ed..f27559f8 100644
--- a/rust/fixtures/eval/README.md
+++ b/rust/fixtures/eval/README.md
@@ -1,8 +1,8 @@
# Grounding retrieval benchmark fixture (v0.23.0, WS1)
This directory is the versioned fixture the `decided eval` grounding benchmark
-scores. It is a dev/CI surface, not a RAC artifact corpus — nothing here is part
-of the product knowledge under `rac/`.
+scores. It is a dev/CI surface, not an AsDecided artifact corpus — nothing here
+is part of the repository's product-knowledge corpus.
## Layout
@@ -27,14 +27,26 @@ of the product knowledge under `rac/`.
category's `p_at_1` / `r_at_5`. Per-tool figures are diagnostic.
- `baseline.json` — the committed `metrics` baseline, written by
`decided eval --update-baseline` (human-only; CI never rebaselines).
+- `federation/` — the ADR-139 DecisionGrounding track. Its child inherits a
+ 40-artifact standards parent containing a precise inherited match, six
+ lexical near-matches, and a 32-inbound-edge hard negative. The hard negative
+ has graph rank 1 but remains outside the top-five window because the v0.28
+ lexical floor clamps its graph contribution.
## Running
```sh
-rac eval # human-readable scorecard
-rac eval --json # full scorecard JSON
-rac eval --check # CI gate: exit 0 pass / 1 regression / 2 usage error
-rac eval --update-baseline # human-only re-baseline
+decided eval # human-readable scorecard
+decided eval --json # full scorecard JSON
+decided eval --check # CI gate: exit 0 pass / 1 regression / 2 usage error
+decided eval --update-baseline # human-only re-baseline
+
+# ADR-139 large-parent/hard-negative track
+decided eval --check \
+ --root rust/fixtures/eval/federation/child/decisions \
+ --queries rust/fixtures/eval/federation/queries.json \
+ --baseline rust/fixtures/eval/federation/baseline.json \
+ --config rust/fixtures/eval/federation/eval-config.json
```
## Calibration
diff --git a/rust/fixtures/eval/federation/baseline.json b/rust/fixtures/eval/federation/baseline.json
new file mode 100644
index 00000000..70b07341
--- /dev/null
+++ b/rust/fixtures/eval/federation/baseline.json
@@ -0,0 +1,23 @@
+{
+ "overall": {
+ "p_at_1": 1.0,
+ "p_at_3": 0.333333,
+ "p_at_5": 0.2,
+ "r_at_1": 1.0,
+ "r_at_3": 1.0,
+ "r_at_5": 1.0,
+ "negative_violations": 0
+ },
+ "by_category": {
+ "federated_large_parent": {
+ "p_at_1": 1.0,
+ "r_at_5": 1.0
+ }
+ },
+ "by_tool": {
+ "search_artifacts": {
+ "p_at_1": 1.0,
+ "r_at_5": 1.0
+ }
+ }
+}
diff --git a/rust/fixtures/eval/federation/child/.decided/config.yaml b/rust/fixtures/eval/federation/child/.decided/config.yaml
new file mode 100644
index 00000000..55289303
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/.decided/config.yaml
@@ -0,0 +1,4 @@
+repository_key: CHILD
+corpus:
+ source: eval/child
+
diff --git a/rust/fixtures/eval/federation/child/.decided/corpus.md b/rust/fixtures/eval/federation/child/.decided/corpus.md
new file mode 100644
index 00000000..d3994654
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/.decided/corpus.md
@@ -0,0 +1,19 @@
+# DecisionGrounding federation fixture
+
+## inherits
+
+```yaml
+version: 1
+alias: standards
+source: eval/standards
+root: vendor/standards
+corpus: decisions
+digest: sha256:4657f93e3c7480636cc3d52907c45bfb3b1edf573e8975c2cda46a271672b624
+```
+
+## overrides
+
+```yaml
+version: 1
+items: []
+```
diff --git a/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md b/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md
new file mode 100644
index 00000000..c8bc8089
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md
@@ -0,0 +1,26 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000003
+type: decision
+---
+# Local Quantum Ledger Notes
+
+## Status
+
+Accepted
+
+## Context
+
+The child application keeps operational notes for its quantum ledger client.
+
+## Decision
+
+Use the organisation standard whenever ledger compaction is configured.
+
+## Consequences
+
+The local repository does not redefine the parent compaction policy.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml b/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml
new file mode 100644
index 00000000..0a35072c
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml
@@ -0,0 +1,4 @@
+repository_key: STANDARDS
+corpus:
+ source: eval/standards
+
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md
new file mode 100644
index 00000000..2e27dcee
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000101
+type: decision
+---
+# Quantum Ledger Compaction Planning
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction planning procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md
new file mode 100644
index 00000000..92146e2c
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000102
+type: decision
+---
+# Quantum Ledger Compaction Operations
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction operations procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md
new file mode 100644
index 00000000..255d22ad
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000103
+type: decision
+---
+# Quantum Ledger Compaction Observability
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction observability procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md
new file mode 100644
index 00000000..1f168940
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000104
+type: decision
+---
+# Quantum Ledger Compaction Capacity
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction capacity procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md
new file mode 100644
index 00000000..a62421b0
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000105
+type: decision
+---
+# Quantum Ledger Compaction Scheduling
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction scheduling procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md
new file mode 100644
index 00000000..0bc4b847
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000106
+type: decision
+---
+# Quantum Ledger Compaction Recovery
+
+## Status
+
+Accepted
+
+## Context
+
+This standard covers a neighbouring operational concern and uses anchor
+vocabulary, but does not select the signed checkpoint.
+
+## Decision
+
+Teams document their quantum ledger compaction recovery procedure separately.
+
+## Consequences
+
+The document is a deliberate lexical near-match in the grounding benchmark.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md
new file mode 100644
index 00000000..7f9dc654
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md
@@ -0,0 +1,27 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000002
+type: decision
+---
+# Ledger Reference Hub
+
+## Status
+
+Accepted
+
+## Context
+
+Many general standards link to this ledger reference index. Its glossary also
+mentions quantum compaction anchor terminology without setting that policy.
+
+## Decision
+
+Keep a stable ledger reference for broad portfolio navigation.
+
+## Consequences
+
+Relationship popularity alone does not make this the compaction policy.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md
new file mode 100644
index 00000000..a71cb51a
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md
@@ -0,0 +1,26 @@
+---
+schema_version: 1
+id: FEDEVAL-000000000001
+type: decision
+---
+# Quantum Ledger Compaction Anchor
+
+## Status
+
+Accepted
+
+## Context
+
+Every child service needs one precise standard for quantum ledger compaction.
+
+## Decision
+
+Quantum ledger compaction MUST use the signed anchor checkpoint before pruning.
+
+## Consequences
+
+The exact inherited standard remains the strongest lexical grounding match.
+
+## Category
+
+Technical
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md
new file mode 100644
index 00000000..9e6d8b33
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001001
+type: decision
+---
+# Service Reliability Standard 001
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 001 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 001 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md
new file mode 100644
index 00000000..15695daf
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001002
+type: decision
+---
+# Service Reliability Standard 002
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 002 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 002 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md
new file mode 100644
index 00000000..b3d8d917
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001003
+type: decision
+---
+# Service Reliability Standard 003
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 003 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 003 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md
new file mode 100644
index 00000000..83b60ac1
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001004
+type: decision
+---
+# Service Reliability Standard 004
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 004 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 004 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md
new file mode 100644
index 00000000..88934aaa
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001005
+type: decision
+---
+# Service Reliability Standard 005
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 005 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 005 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md
new file mode 100644
index 00000000..59c8b356
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001006
+type: decision
+---
+# Service Reliability Standard 006
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 006 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 006 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md
new file mode 100644
index 00000000..14405139
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001007
+type: decision
+---
+# Service Reliability Standard 007
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 007 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 007 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md
new file mode 100644
index 00000000..d14b2109
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001008
+type: decision
+---
+# Service Reliability Standard 008
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 008 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 008 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md
new file mode 100644
index 00000000..a0aba56b
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001009
+type: decision
+---
+# Service Reliability Standard 009
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 009 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 009 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md
new file mode 100644
index 00000000..e05b6069
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001010
+type: decision
+---
+# Service Reliability Standard 010
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 010 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 010 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md
new file mode 100644
index 00000000..1d0db351
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001011
+type: decision
+---
+# Service Reliability Standard 011
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 011 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 011 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md
new file mode 100644
index 00000000..4b32cbd4
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001012
+type: decision
+---
+# Service Reliability Standard 012
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 012 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 012 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md
new file mode 100644
index 00000000..65114482
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001013
+type: decision
+---
+# Service Reliability Standard 013
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 013 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 013 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md
new file mode 100644
index 00000000..65f1c2fd
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001014
+type: decision
+---
+# Service Reliability Standard 014
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 014 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 014 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md
new file mode 100644
index 00000000..5066c7cf
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001015
+type: decision
+---
+# Service Reliability Standard 015
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 015 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 015 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md
new file mode 100644
index 00000000..4e46807e
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001016
+type: decision
+---
+# Service Reliability Standard 016
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 016 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 016 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md
new file mode 100644
index 00000000..dd1bfba5
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001017
+type: decision
+---
+# Service Reliability Standard 017
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 017 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 017 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md
new file mode 100644
index 00000000..e3f139bb
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001018
+type: decision
+---
+# Service Reliability Standard 018
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 018 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 018 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md
new file mode 100644
index 00000000..183ec2e2
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001019
+type: decision
+---
+# Service Reliability Standard 019
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 019 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 019 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md
new file mode 100644
index 00000000..e050e062
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001020
+type: decision
+---
+# Service Reliability Standard 020
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 020 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 020 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md
new file mode 100644
index 00000000..97a6d414
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001021
+type: decision
+---
+# Service Reliability Standard 021
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 021 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 021 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md
new file mode 100644
index 00000000..a6ba9d88
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001022
+type: decision
+---
+# Service Reliability Standard 022
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 022 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 022 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md
new file mode 100644
index 00000000..47175ad6
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001023
+type: decision
+---
+# Service Reliability Standard 023
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 023 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 023 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md
new file mode 100644
index 00000000..0a431fcc
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001024
+type: decision
+---
+# Service Reliability Standard 024
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 024 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 024 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md
new file mode 100644
index 00000000..80f7f7ba
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001025
+type: decision
+---
+# Service Reliability Standard 025
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 025 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 025 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md
new file mode 100644
index 00000000..5204d021
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001026
+type: decision
+---
+# Service Reliability Standard 026
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 026 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 026 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md
new file mode 100644
index 00000000..17389ea5
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001027
+type: decision
+---
+# Service Reliability Standard 027
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 027 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 027 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md
new file mode 100644
index 00000000..e752e7c8
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001028
+type: decision
+---
+# Service Reliability Standard 028
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 028 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 028 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md
new file mode 100644
index 00000000..42ec6175
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001029
+type: decision
+---
+# Service Reliability Standard 029
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 029 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 029 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md
new file mode 100644
index 00000000..d57e95ad
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001030
+type: decision
+---
+# Service Reliability Standard 030
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 030 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 030 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md
new file mode 100644
index 00000000..dab7d0ef
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001031
+type: decision
+---
+# Service Reliability Standard 031
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 031 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 031 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md
new file mode 100644
index 00000000..3bb3fbe6
--- /dev/null
+++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md
@@ -0,0 +1,30 @@
+---
+schema_version: 1
+id: FEDEVAL-000000001032
+type: decision
+---
+# Service Reliability Standard 032
+
+## Status
+
+Accepted
+
+## Context
+
+Portfolio service 032 needs a stable navigation reference for operational guidance.
+
+## Decision
+
+Service 032 records its reliability boundary and links to the shared reference hub.
+
+## Consequences
+
+The relationship makes the hub highly connected without adding query vocabulary.
+
+## Category
+
+Technical
+
+## Related Decisions
+
+- FEDEVAL-000000000002
diff --git a/rust/fixtures/eval/federation/eval-config.json b/rust/fixtures/eval/federation/eval-config.json
new file mode 100644
index 00000000..4e2e9356
--- /dev/null
+++ b/rust/fixtures/eval/federation/eval-config.json
@@ -0,0 +1,14 @@
+{
+ "description": "Federated DecisionGrounding gate: preserve the precise inherited match and admit no hard negative in the top-five window.",
+ "tolerance": 0.02,
+ "floors": {
+ "negative_violations": 0,
+ "overall": {
+ "p_at_1": 0.9,
+ "r_at_5": 0.95
+ },
+ "by_category": {
+ "federated_large_parent": {"p_at_1": 0.9, "r_at_5": 0.95}
+ }
+ }
+}
diff --git a/rust/fixtures/eval/federation/queries.json b/rust/fixtures/eval/federation/queries.json
new file mode 100644
index 00000000..1d0efb03
--- /dev/null
+++ b/rust/fixtures/eval/federation/queries.json
@@ -0,0 +1,21 @@
+{
+ "description": "DecisionGrounding federation track: one child, a large inherited parent, a highly connected lexical hard negative, and no source preference.",
+ "cases": [
+ {
+ "id": "FQ01",
+ "tool": "search_artifacts",
+ "category": "federated_large_parent",
+ "query": "quantum ledger compaction anchor",
+ "relevant": ["FEDEVAL-000000000001"],
+ "must_not_return": ["FEDEVAL-000000000002"]
+ },
+ {
+ "id": "FQ02",
+ "tool": "search_artifacts",
+ "category": "federated_large_parent",
+ "query": "signed anchor checkpoint pruning",
+ "relevant": ["FEDEVAL-000000000001"],
+ "must_not_return": ["FEDEVAL-000000000002", "FEDEVAL-000000000003"]
+ }
+ ]
+}
diff --git a/rust/rac-engine/assets/portal/asdecided-portal-legacy-shell.html b/rust/rac-engine/assets/portal/asdecided-portal-legacy-shell.html
new file mode 100644
index 00000000..33b9b782
--- /dev/null
+++ b/rust/rac-engine/assets/portal/asdecided-portal-legacy-shell.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+ AsDecided — export viewer
+
+
+
+
+
+
+
+
+ The AsDecided export viewer needs JavaScript to render the corpus.
+
+
+
diff --git a/rust/rac-engine/assets/portal/asdecided-portal-shell.html b/rust/rac-engine/assets/portal/asdecided-portal-shell.html
index 33b9b782..1767c24c 100644
--- a/rust/rac-engine/assets/portal/asdecided-portal-shell.html
+++ b/rust/rac-engine/assets/portal/asdecided-portal-shell.html
@@ -1,5 +1,5 @@
-
+
@@ -18,7 +18,7 @@
-->