feat(resolve): add oddkit_resolve MCP action with transparent supersession#140
Merged
feat(resolve): add oddkit_resolve MCP action with transparent supersession#140
Conversation
…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.
Deploying with
|
| 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 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
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
chaintosupersession_chainin both CIRCULAR_SUPERSESSION return shapes so the envelope field name is uniform across all resolve statuses.
- Renamed
- ✅ 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), [...]) : undefinedpattern used by runGet.
- Both FOUND branches now emit state only when the caller passed input state, mirroring the
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.
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).
This was referenced Apr 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Phase 2 of the link-rot-elimination campaign. Implements the
oddkit_resolveMCP action perklappy://docs/oddkit/specs/oddkit-resolve(DRAFT v4 — KISS).URI in, current canonical answer out. Walks
superseded_bychains 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— newrunResolvefunction (~140 LOC) +resolvecase in the action dispatch switch +deriveUrlhelper. Reads only the existing index (fetcher.getIndex).workers/src/index.ts— newoddkit_resolvestandalone tool definition +resolveadded 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.package.json,workers/package.json, both lockfiles →0.25.0.Behavior contract
klappy://...that exists, no supersessionFOUNDresolvedpopulated, emptysupersession_chainklappy://...that exists, withsuperseded_bychainFOUNDresolvedis the terminus,supersession_chainlists every URI walked throughklappy://...that doesn't exist in indexNOT_FOUNDresolved, justinput_uriechoedklappy://URI (https://, raw path, empty)INVALID_INPUTCIRCULAR_SUPERSESSIONCIRCULAR_SUPERSESSIONsuperseded_bypoints at unknown URIFOUNDwithwarningVodka discipline
v3 of the spec proposed a richer surface (batch action,
resolve_linksflag,aliasesfield,supersession_responsefield, identity-by-meaning queries, build-time companion). v4 cut to the minimum. Cuts are captured with explicit revisit triggers inklappy://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
oddkit_audit(Phase 3, separate PR after this lands).PARTIAL_INDEXsemantics (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:FOUNDshape against a real direct-hit URI (e.g.,klappy://canon/values/orientation)superseded_byindex)NOT_FOUNDfor an obviously-fake URIINVALID_INPUTfor a non-klappy:// URISmoke 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 --noEmitcleannpm run test:productionRefs
klappy://docs/oddkit/specs/oddkit-resolve(DRAFT v4)klappy://canon/principles/identity-resolved-by-protocolklappy://docs/planning/link-rot-elimination-campaignklappy://docs/planning/link-rot-deferred-concernsklappy://canon/constraints/release-validation-gate,klappy://canon/principles/vodka-architecture,klappy://canon/methods/supersessionNote
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 unifiedoddkitaction:"resolve"), to resolveklappy://URIs to the current canonical document by walkingsuperseded_byfrontmatter chains and returningFOUND|NOT_FOUND|INVALID_INPUT|CIRCULAR_SUPERSESSIONplusresolvedmetadata andsupersession_chain.Implements
runResolveinworkers/src/orchestrate.tswith mixed-shape successor normalization (URI vs repo-relative path, with/without.md), cycle/depth protection, and a derived publicurl, and wires it intoVALID_ACTIONS+ the unified dispatch.Extends the Cloudflare production smoke script with new
oddkit_resolvetests (direct hit, supersession walk regression, NOT_FOUND, INVALID_INPUT) and bumps package versions/lockfiles to0.25.0with an updatedCHANGELOGentry.Reviewed by Cursor Bugbot for commit 86dee44. Bugbot is set up for automated code reviews on this repo. Configure here.