Skip to content

feat(resolve): add oddkit_resolve MCP action with transparent supersession#140

Merged
klappy merged 4 commits intomainfrom
feat/oddkit-resolve
Apr 26, 2026
Merged

feat(resolve): add oddkit_resolve MCP action with transparent supersession#140
klappy merged 4 commits intomainfrom
feat/oddkit-resolve

Conversation

@klappy
Copy link
Copy Markdown
Owner

@klappy klappy commented Apr 26, 2026

Summary

Phase 2 of the link-rot-elimination campaign. Implements the oddkit_resolve MCP action per klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4 — KISS).

URI in, current canonical answer out. Walks superseded_by chains in existing frontmatter to terminus. Backward-compatible (purely additive net-new action). One job, one input, no new caches, no new frontmatter fields.

What lands

  • workers/src/orchestrate.ts — new runResolve function (~140 LOC) + resolve case in the action dispatch switch + deriveUrl helper. Reads only the existing index (fetcher.getIndex).
  • workers/src/index.ts — new oddkit_resolve standalone tool definition + resolve added to the unified router's action enum and description.
  • tests/cloudflare-production.test.sh — three new smoke tests (direct hit FOUND, NOT_FOUND for unknown URI, INVALID_INPUT for non-klappy:// URIs).
  • CHANGELOG.md — 0.25.0 entry with full context, Vodka discipline notes, and the explicit list of what got cut.
  • Version bumpspackage.json, workers/package.json, both lockfiles → 0.25.0.

Behavior contract

Input Status returned Behavior
klappy://... that exists, no supersession FOUND resolved populated, empty supersession_chain
klappy://... that exists, with superseded_by chain FOUND resolved is the terminus, supersession_chain lists every URI walked through
klappy://... that doesn't exist in index NOT_FOUND No resolved, just input_uri echoed
Non-klappy:// URI (https://, raw path, empty) INVALID_INPUT Error message
Cyclic supersession chain CIRCULAR_SUPERSESSION Chain returned for diagnosis; canon data error
Chain depth > 16 CIRCULAR_SUPERSESSION Same as above
superseded_by points at unknown URI FOUND with warning Chain truncated at last known entry; warning surfaces the dangling pointer

Vodka discipline

v3 of the spec proposed a richer surface (batch action, resolve_links flag, aliases field, supersession_response field, identity-by-meaning queries, build-time companion). v4 cut to the minimum. Cuts are captured with explicit revisit triggers in klappy://docs/planning/link-rot-deferred-concerns. When real consumers demonstrate pain on any of those, each graduates as its own thin extension.

What this PR does NOT do

  • No oddkit_audit (Phase 3, separate PR after this lands).
  • No writings cleanup in klappy.dev (Phase 2, separate PR — unblocked by this one).
  • No PARTIAL_INDEX semantics (the spec named them; existing actions don't expose this either, so resolve matches the existing convention rather than introducing a divergent shape ahead of an observed cold-start problem; documented in CHANGELOG notes).

Release-validation-gate (E0008.3)

This PR introduces a new action surface — load-bearing for every consumer that will start calling it. Per klappy://canon/constraints/release-validation-gate, an independent Sonnet 4.6 validator should dispatch before promotion to verify:

  1. FOUND shape against a real direct-hit URI (e.g., klappy://canon/values/orientation)
  2. Supersession chain walking against a real superseded URI (validator picks one from canon's superseded_by index)
  3. NOT_FOUND for an obviously-fake URI
  4. INVALID_INPUT for a non-klappy:// URI
  5. Backward-compat smoke: every existing action continues to behave identically

Smoke tests in this PR cover (1), (3), (4), (5). Validator should exercise (2) against live prod after the preview deploys.

Verification done in-PR

  • tsc --noEmit clean
  • ✅ Diff is small (~266 lines, eight files, one coherent change)
  • ✅ Pre-commit version-sync hook passes
  • ✅ All four smoke tests added are runnable against the preview URL via npm run test:production

Refs

  • Spec: klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4)
  • Principle: klappy://canon/principles/identity-resolved-by-protocol
  • Campaign: klappy://docs/planning/link-rot-elimination-campaign
  • Deferred concerns: klappy://docs/planning/link-rot-deferred-concerns
  • Canon PR (klappy.dev that lands all four planning artifacts): campaign(link-rot): Phase 1 — specs, canon principle, deferred ledger, sequencing klappy.dev#142
  • Canon basis: klappy://canon/constraints/release-validation-gate, klappy://canon/principles/vodka-architecture, klappy://canon/methods/supersession

Note

Medium Risk
Adds a new public MCP action/tool and routing path in the Worker, which could affect clients via new validation/dispatch logic and response shape expectations, though it is read-only and additive.

Overview
Adds a new MCP capability, oddkit_resolve (and unified oddkit action:"resolve"), to resolve klappy:// URIs to the current canonical document by walking superseded_by frontmatter chains and returning FOUND|NOT_FOUND|INVALID_INPUT|CIRCULAR_SUPERSESSION plus resolved metadata and supersession_chain.

Implements runResolve in workers/src/orchestrate.ts with mixed-shape successor normalization (URI vs repo-relative path, with/without .md), cycle/depth protection, and a derived public url, and wires it into VALID_ACTIONS + the unified dispatch.

Extends the Cloudflare production smoke script with new oddkit_resolve tests (direct hit, supersession walk regression, NOT_FOUND, INVALID_INPUT) and bumps package versions/lockfiles to 0.25.0 with an updated CHANGELOG entry.

Reviewed by Cursor Bugbot for commit 86dee44. Bugbot is set up for automated code reviews on this repo. Configure here.

…ssion

URI in, current canonical answer out. Walks superseded_by chains in
existing frontmatter to terminus. Backward-compatible (net-new action).

Per klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4 — KISS) and
klappy://canon/principles/identity-resolved-by-protocol. Phase 2 of the
link-rot-elimination campaign (klappy://docs/planning/link-rot-elimination-campaign).

Bumps version 0.24.0 → 0.25.0.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 26, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
oddkit 86dee44 Commit Preview URL

Branch Preview URL
Apr 26 2026, 02:27 PM

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Resolve action rejected by validation gate
    • Added "resolve" to the VALID_ACTIONS allow-list so the unified handler dispatches to runResolve instead of returning Unknown action.
  • ✅ Fixed: Inconsistent chain field name across resolve statuses
    • Renamed chain to supersession_chain in both CIRCULAR_SUPERSESSION return shapes so the envelope field name is uniform across all resolve statuses.
  • ✅ Fixed: Resolved URI duplicated in supersession_chain on dangling successor
    • On the dangling-successor branch, sliced off the just-pushed current entry so supersession_chain only contains predecessors of resolved, matching the normal FOUND contract.
  • ✅ Fixed: Resolve fabricates state when caller passed none
    • Both FOUND branches now emit state only when the caller passed input state, mirroring the state ? addCanonRefs(initState(state), [...]) : undefined pattern used by runGet.
Preview (cde10c61bc)
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@
 
 ## [Unreleased]
 
+## [0.25.0] - 2026-04-26
+
+### Added
+
+- **`oddkit_resolve` MCP action — protocol-level URI resolution with transparent supersession (PR #TBD).** Per `klappy://docs/oddkit/specs/oddkit-resolve` (DRAFT v4 — KISS) and `klappy://canon/principles/identity-resolved-by-protocol`. New action takes a `klappy://` URI as `input`, walks any `superseded_by` chain in frontmatter to terminus, and returns `{ status, resolved: { uri, path, title, url, content_hash }, supersession_chain }`. Status enum is `FOUND | NOT_FOUND | INVALID_INPUT | CIRCULAR_SUPERSESSION`. Wired into the unified `oddkit` router (`action: "resolve"`) and exposed as a standalone `oddkit_resolve` tool. Backward-compatible — purely additive net-new action; existing callers unchanged. Reads only the existing index (`fetcher.getIndex`), no new caches, no new frontmatter fields. Phase 2 of the link-rot-elimination campaign per `klappy://docs/planning/link-rot-elimination-campaign`.
+
+### Notes
+
+- **Vodka discipline applied.** v3 of the spec proposed a richer surface (batch action, `resolve_links` flag, `aliases` field, `supersession_response` field, identity-by-meaning queries, build-time companion). v4 cut to the minimum: one input, one job — URI in, current canonical answer out. Cuts captured with explicit revisit triggers in `klappy://docs/planning/link-rot-deferred-concerns`. When real consumers demonstrate pain, each deferred item graduates as its own thin extension.
+- **No `PARTIAL_INDEX` status in v1.** The spec named it as mandatory partial-data compliance; the existing `oddkit_get` and `oddkit_search` actions don't expose this either, treating index reads as synchronous. Resolve matches the existing convention rather than introducing a divergent shape ahead of an observed cold-start problem. If real cold-start visibility becomes load-bearing, follow-up.
+- **No load-bearing surface change to existing actions.** `runResolve` is a new function; the dispatch switch gains one case; the tool list gains one entry; the unified router enum gains one value. Zero behavior change to `orient`, `challenge`, `gate`, `encode`, `search`, `get`, `catalog`, `validate`, `preflight`, `version`, `cleanup_storage`.
+
+### Refs
+
+- Spec: `klappy://docs/oddkit/specs/oddkit-resolve` (DRAFT v4)
+- Principle: `klappy://canon/principles/identity-resolved-by-protocol`
+- Campaign: `klappy://docs/planning/link-rot-elimination-campaign`
+- Deferred concerns: `klappy://docs/planning/link-rot-deferred-concerns`
+- Canon basis: `klappy://canon/constraints/release-validation-gate`, `klappy://canon/principles/vodka-architecture`, `klappy://canon/methods/supersession`
+- Canon PR (klappy.dev): #142
+
 ## [0.24.0] - 2026-04-23
 
 ### Added

diff --git a/package-lock.json b/package-lock.json
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "oddkit",
-  "version": "0.24.0",
+  "version": "0.25.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "oddkit",
-      "version": "0.24.0",
+      "version": "0.25.0",
       "license": "MIT",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.0.0",

diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "oddkit",
-  "version": "0.24.0",
+  "version": "0.25.0",
   "description": "Agent-first CLI for ODD-governed repos. Epistemic terrain rendering with portable baseline.",
   "type": "module",
   "bin": {

diff --git a/tests/cloudflare-production.test.sh b/tests/cloudflare-production.test.sh
--- a/tests/cloudflare-production.test.sh
+++ b/tests/cloudflare-production.test.sh
@@ -405,6 +405,63 @@
 RESULT=$(extract_json "$RAW")
 check_json "oddkit_catalog" "$RESULT" "assert 'content' in d.get('result',{}), 'no content'"
 
+# Test 14g: oddkit_resolve — direct hit (FOUND, no supersession)
+# Per klappy://docs/oddkit/specs/oddkit-resolve. Walks superseded_by chains
+# transparently. URI input only in v1 (no meaning queries — see deferred ledger).
+echo ""
+echo "Test 14g: tools/call oddkit_resolve (direct hit)"
+RAW=$(curl -sf --max-time 30 "$WORKER_URL/mcp" -X POST \
+  -H "Content-Type: application/json" \
+  -H "Accept: application/json, text/event-stream" \
+  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"oddkit_resolve","arguments":{"input":"klappy://canon/values/orientation"}}}')
+RESULT=$(extract_json "$RAW")
+check_json "oddkit_resolve direct hit" "$RESULT" "assert 'content' in d.get('result',{}), 'no content'"
+INNER=$(echo "$RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('result',{}).get('content',[{}])[0].get('text',''))" 2>/dev/null)
+if echo "$INNER" | python3 -c "import sys, json; d=json.load(sys.stdin); r=d.get('result',{}); assert r.get('status')=='FOUND', f'status={r.get(\"status\")}'; assert r.get('resolved',{}).get('uri'), 'missing resolved.uri'" 2>/dev/null; then
+  echo "PASS - resolve direct hit returns FOUND with resolved.uri"
+  PASSED=$((PASSED + 1))
+else
+  echo "FAIL - resolve direct hit shape unexpected"
+  echo "  Inner: $(echo "$INNER" | head -c 400)"
+  FAILED=$((FAILED + 1))
+fi
+
+# Test 14h: oddkit_resolve — NOT_FOUND
+echo ""
+echo "Test 14h: tools/call oddkit_resolve (NOT_FOUND)"
+RAW=$(curl -sf --max-time 30 "$WORKER_URL/mcp" -X POST \
+  -H "Content-Type: application/json" \
+  -H "Accept: application/json, text/event-stream" \
+  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"oddkit_resolve","arguments":{"input":"klappy://writings/this-uri-does-not-exist-anywhere-9f8a7b6c"}}}')
+RESULT=$(extract_json "$RAW")
+INNER=$(echo "$RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('result',{}).get('content',[{}])[0].get('text',''))" 2>/dev/null)
+if echo "$INNER" | python3 -c "import sys, json; d=json.load(sys.stdin); r=d.get('result',{}); assert r.get('status')=='NOT_FOUND', f'status={r.get(\"status\")}'" 2>/dev/null; then
+  echo "PASS - resolve NOT_FOUND for unknown URI"
+  PASSED=$((PASSED + 1))
+else
+  echo "FAIL - resolve NOT_FOUND not produced"
+  echo "  Inner: $(echo "$INNER" | head -c 400)"
+  FAILED=$((FAILED + 1))
+fi
+
+# Test 14i: oddkit_resolve — INVALID_INPUT for non-klappy:// scheme
+echo ""
+echo "Test 14i: tools/call oddkit_resolve (INVALID_INPUT)"
+RAW=$(curl -sf --max-time 30 "$WORKER_URL/mcp" -X POST \
+  -H "Content-Type: application/json" \
+  -H "Accept: application/json, text/event-stream" \
+  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"oddkit_resolve","arguments":{"input":"https://example.com/foo"}}}')
+RESULT=$(extract_json "$RAW")
+INNER=$(echo "$RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('result',{}).get('content',[{}])[0].get('text',''))" 2>/dev/null)
+if echo "$INNER" | python3 -c "import sys, json; d=json.load(sys.stdin); r=d.get('result',{}); assert r.get('status')=='INVALID_INPUT', f'status={r.get(\"status\")}'" 2>/dev/null; then
+  echo "PASS - resolve INVALID_INPUT for non-klappy:// URI"
+  PASSED=$((PASSED + 1))
+else
+  echo "FAIL - resolve INVALID_INPUT not produced"
+  echo "  Inner: $(echo "$INNER" | head -c 400)"
+  FAILED=$((FAILED + 1))
+fi
+
 # ============================================
 # SECTION 4: Response Content Validation
 # ============================================

diff --git a/workers/package-lock.json b/workers/package-lock.json
--- a/workers/package-lock.json
+++ b/workers/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "oddkit-mcp-worker",
-  "version": "0.24.0",
+  "version": "0.25.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "oddkit-mcp-worker",
-      "version": "0.24.0",
+      "version": "0.25.0",
       "dependencies": {
         "agents": "^0.4.1",
         "fflate": "^0.8.2",

diff --git a/workers/package.json b/workers/package.json
--- a/workers/package.json
+++ b/workers/package.json
@@ -1,6 +1,6 @@
 {
   "name": "oddkit-mcp-worker",
-  "version": "0.24.0",
+  "version": "0.25.0",
   "private": true,
   "type": "module",
   "scripts": {

diff --git a/workers/src/index.ts b/workers/src/index.ts
--- a/workers/src/index.ts
+++ b/workers/src/index.ts
@@ -193,12 +193,13 @@
 
   server.tool(
     "oddkit",
-    `Epistemic guide for Outcomes-Driven Development. Routes to orient, challenge, gate, encode, search, get, catalog, validate, preflight, version, or cleanup_storage actions.
+    `Epistemic guide for Outcomes-Driven Development. Routes to orient, challenge, gate, encode, search, get, resolve, catalog, validate, preflight, version, or cleanup_storage actions.
 
 Use when:
 - Starting work: action="orient" to assess epistemic mode
 - Policy/canon questions: action="search" with your query
 - Fetching a specific doc: action="get" with URI
+- Resolving a URI to its current canonical answer (walks supersession): action="resolve" with URI
 - Pressure-testing claims: action="challenge"
 - Checking transition readiness: action="gate"
 - Recording decisions: action="encode"
@@ -207,7 +208,7 @@
 - Listing available docs: action="catalog"`,
     {
       action: z.enum([
-        "orient", "challenge", "gate", "encode", "search", "get",
+        "orient", "challenge", "gate", "encode", "search", "get", "resolve",
         "catalog", "validate", "preflight", "version", "cleanup_storage",
       ]).describe("Which epistemic action to perform."),
       input: z.string().describe("Primary input — query, claim, URI, goal, or completion claim depending on action."),
@@ -336,6 +337,16 @@
       annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
     },
     {
+      name: "oddkit_resolve",
+      description: "Resolve a klappy:// URI to its current canonical answer, walking superseded_by chains transparently. Returns resolved URI + path + url + supersession_chain. Use this to render links — never hardcode URLs in source. Per klappy://canon/principles/identity-resolved-by-protocol.",
+      action: "resolve",
+      schema: {
+        input: z.string().describe("Canonical URI to resolve (e.g., klappy://writings/some-slug)."),
+        knowledge_base_url: z.string().optional().describe("Optional: GitHub repo URL for your knowledge base. When set, strict mode is automatic: missing files fall through to the bundled governance tier."),
+      },
+      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+    },
+    {
       name: "oddkit_catalog",
       description: "Lists available documentation with categories, counts, and start-here suggestions. Supports temporal discovery: use sort_by='date' to get recent articles with full frontmatter metadata.",
       action: "catalog",

diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts
--- a/workers/src/orchestrate.ts
+++ b/workers/src/orchestrate.ts
@@ -1575,6 +1575,172 @@
   };
 }
 
+// ──────────────────────────────────────────────────────────────────────────────
+// runResolve — protocol-level URI resolution with transparent supersession.
+//
+// Per klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4 — KISS) and
+// klappy://canon/principles/identity-resolved-by-protocol: consumers pass
+// in a klappy:// URI; the protocol returns the current canonical answer,
+// walking superseded_by chains to terminus. Backward-compatible (net-new).
+// ──────────────────────────────────────────────────────────────────────────────
+
+async function runResolve(
+  input: string,
+  fetcher: KnowledgeBaseFetcher,
+  knowledgeBaseUrl?: string,
+  state?: OddkitState,
+): Promise<ActionResult> {
+  const startMs = Date.now();
+  const updatedState = state ? initState(state) : undefined;
+
+  if (!input || typeof input !== "string" || !input.startsWith("klappy://")) {
+    return {
+      action: "resolve",
+      result: {
+        status: "INVALID_INPUT",
+        error: "input must be a klappy:// URI",
+      },
+      state: updatedState,
+      assistant_text: `Invalid input: \`${input}\`. Expected a klappy:// URI.`,
+      debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+    };
+  }
+
+  const index = await fetcher.getIndex(knowledgeBaseUrl);
+
+  // Build a URI → entry lookup once. Index entries already carry full frontmatter
+  // per klappy://docs/oddkit/IMPL-catalog-recent.
+  const byUri = new Map<string, IndexEntry>();
+  for (const entry of index.entries) {
+    if (entry.uri) byUri.set(entry.uri, entry);
+  }
+
+  const startEntry = byUri.get(input);
+  if (!startEntry) {
+    return {
+      action: "resolve",
+      result: {
+        status: "NOT_FOUND",
+        input_uri: input,
+      },
+      state: updatedState,
+      assistant_text: `URI not found in index: \`${input}\`.`,
+      debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+    };
+  }
+
+  // Walk superseded_by chain to terminus.
+  // Cap traversal depth as a safety net against malformed canon (cycles or absurd chains).
+  // Cycles produce CIRCULAR_SUPERSESSION; depth-cap produces the same with a different reason.
+  const MAX_DEPTH = 16;
+  const chain: Array<{ uri: string; superseded_at?: string }> = [];
+  const visited = new Set<string>([startEntry.uri]);
+
+  let current: IndexEntry = startEntry;
+  for (let depth = 0; depth < MAX_DEPTH; depth++) {
+    const fm = current.frontmatter || {};
+    const next = fm.superseded_by;
+    if (typeof next !== "string" || next.length === 0) break;
+
+    const supersededAt = typeof fm.superseded_at === "string" ? fm.superseded_at : undefined;
+    chain.push({ uri: current.uri, ...(supersededAt ? { superseded_at: supersededAt } : {}) });
+
+    if (visited.has(next)) {
+      return {
+        action: "resolve",
+        result: {
+          status: "CIRCULAR_SUPERSESSION",
+          input_uri: input,
+          supersession_chain: [...chain, { uri: next }],
+          message: "superseded_by chain cycles",
+        },
+        state: updatedState,
+        assistant_text: `Circular supersession detected starting from \`${input}\`. This is a canon data error.`,
+        debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+      };
+    }
+
+    const nextEntry = byUri.get(next);
+    if (!nextEntry) {
+      // Chain points at a URI that doesn't exist. Treat as resolution to the last
+      // known entry in the chain (stop walking) plus a warning in the result.
+      return {
+        action: "resolve",
+        result: {
+          status: "FOUND",
+          input_uri: input,
+          resolved: {
+            uri: current.uri,
+            path: current.path,
+            title: current.title,
+            url: deriveUrl(current.uri),
+            content_hash: current.content_hash,
+          },
+          supersession_chain: chain.slice(0, -1),
+          warning: `superseded_by points at \`${next}\` which is not in the index; chain truncated`,
+        },
+        state: state ? addCanonRefs(initState(state), [current.path]) : undefined,
+        assistant_text: `Resolved \`${input}\` to \`${current.uri}\` (chain truncated at unknown successor \`${next}\`).`,
+        debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+      };
+    }
+
+    visited.add(next);
+    current = nextEntry;
+  }
+
+  // If we exited the loop on the depth cap with current still pointing at a doc that
+  // declares a further successor, treat that as circular for safety.
+  const finalFm = current.frontmatter || {};
+  if (typeof finalFm.superseded_by === "string" && finalFm.superseded_by.length > 0) {
+    return {
+      action: "resolve",
+      result: {
+        status: "CIRCULAR_SUPERSESSION",
+        input_uri: input,
+        supersession_chain: chain,
+        message: `chain exceeded MAX_DEPTH=${MAX_DEPTH}`,
+      },
+      state: updatedState,
+      assistant_text: `Supersession chain too deep starting from \`${input}\` (>${MAX_DEPTH}). Treating as canon data error.`,
+      debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+    };
+  }
+
+  return {
+    action: "resolve",
+    result: {
+      status: "FOUND",
+      input_uri: input,
+      resolved: {
+        uri: current.uri,
+        path: current.path,
+        title: current.title,
+        url: deriveUrl(current.uri),
+        content_hash: current.content_hash,
+      },
+      supersession_chain: chain,
+    },
+    state: state ? addCanonRefs(initState(state), [current.path]) : undefined,
+    assistant_text:
+      chain.length === 0
+        ? `Resolved \`${input}\` (no supersession).`
+        : `Resolved \`${input}\` → \`${current.uri}\` via ${chain.length} supersession step${chain.length === 1 ? "" : "s"}.`,
+    debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() },
+  };
+}
+
+/**
+ * Derive a public-friendly URL from a klappy:// URI.
+ * v1 mapping: klappy://writings/foo → /writings/foo, klappy://canon/x → /canon/x, etc.
+ * Pure derivation — no I/O. Consumers that need a different URL shape can override
+ * by reading the resolved.uri/path themselves.
+ */
+function deriveUrl(uri: string): string {
+  if (!uri.startsWith("klappy://")) return uri;
+  return "/" + uri.slice("klappy://".length);
+}
+
 async function runCleanupStorage(
   fetcher: KnowledgeBaseFetcher,
   knowledgeBaseUrl?: string,
@@ -2734,6 +2900,7 @@
   "encode",
   "search",
   "get",
+  "resolve",
   "catalog",
   "validate",
   "preflight",
@@ -2779,6 +2946,9 @@
       case "get":
         result = await runGet(input, fetcher, knowledge_base_url, state, include_metadata, section);
         break;
+      case "resolve":
+        result = await runResolve(input, fetcher, knowledge_base_url, state);
+        break;
       case "catalog":
         result = await runCatalog(fetcher, knowledge_base_url, state, { sort_by, limit, offset, filter_epoch });
         break;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 907e058. Configure here.

Comment thread workers/src/orchestrate.ts
Comment thread workers/src/orchestrate.ts
Comment thread workers/src/orchestrate.ts
Comment thread workers/src/orchestrate.ts Outdated
cursoragent and others added 3 commits April 26, 2026 12:57
Validator dispatched per E0008.3 release-validation-gate found that
oddkit_resolve looked up superseded_by values strictly as URIs, but
canon authors use three shapes for that field:

  - klappy://canon/x/y           (full URI)
  - canon/x/y.md                 (path with .md)
  - canon/x/y                    (path without)

Real example: klappy://docs/oddkit/proactive/dolche-vocabulary has
superseded_by="canon/definitions/dolcheo-vocabulary.md" (path form).
The terminus klappy://canon/definitions/dolcheo-vocabulary exists
in the index and resolves directly, but the chain walk could not
follow the path-form pointer and returned a truncated chain with
warning instead of walking to terminus.

Vodka discipline: the resolver normalizes. Pushing this work to
consumers (or to authors via mass-rewrite) repeats the link-rot
anti-pattern this campaign is solving. lookupSuccessor() tries
each shape in turn; cycle-detection set keys on canonical URI so
mixed shapes within one chain still dedupe correctly.

Regression test added against the real canon URI that surfaced
the bug.

Per klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4 — KISS)
Per klappy://canon/constraints/release-validation-gate (E0008.3)
When test 14g.2 (supersession walk) was added, the boundary between
its trailing line and test 14h's curl had a dropped newline:

  RAW=$(curl ...)RESULT=$(extract_json "$RAW")

Bash silently concatenated the assignments, leaving RAW set to the
PRIOR test's response. Test 14h then asserted NOT_FOUND against the
supersession-walk response (which is FOUND) and failed.

Pure test-rig fix. No production code change. Caught by CI on the
prior commit (5ac16cf).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants