针对同一份数据的两条命令。你自己运行一下:

$curl -sS --get 'https://u58x3mt0.api.sanity.io/v2025-08-15/data/query/production' \
       --data-urlencode 'query=count(*)'

{"query":"count(*)","result":120,"syncTags":["s1:dmd/mg"],"ms":11}

120 个文档,没有密钥,没有账号。现在是我的智能体实际使用的路径:

$curl -sS -o /dev/null -w '%{http_code}\n' \
    'https://api.sanity.io/v1/context/organizations/<ORG>/mcp/self-correcting-systems'

401

同一份底层数据集。一条路由可以公开查询。我的智能体走的那条路由经过
一个需要认证的 Context MCP 端点,它需要一个组织 API token——而不是项目
token,Sanity 在那里明确不接受后者。我没有给 Pouya 配置任何访问我
组织的权限,所以克隆仓库并不能让他有办法认证智能体实际使用的那条路径。

我在这里对"同一份"很谨慎:一个 MCP 端点可以配置自己的数据源和一个
groqFilter,所以它不保证会暴露和公开路由相同的 120 个文档。相同的
数据源,不同的访问路径,不同的接口契约。这个区别结果证明就是
那个 bug。

这个缺口就是整个故事,而直到掉进这个缺口的贡献者
告诉我之前,我都不知道它存在。

发生了什么

一位名叫 Pouya 的开发者读了我的一篇文章,认定我的诊断是错的,克隆了仓库并
提交了一个 pull request。这是该项目有史以来第一个外部贡献。

PR #1   https://github.com/keniel13-ui/ask-the-record/pull/1
        PouyaZX4:fix/groq-schema-routing
opened  2026-09-22T12:33:52Z
merged  2026-09-24T00:53:58Z   ->  36.3 hours
        3 commits, 1 file, +25 / -12, merge 4a2940f

它已经被合并了,所以不会出现在默认的 pull request 列表里,那个列表只显示未合并的。
一位审查者告诉我,正因为这个原因,仓库里根本没有 PR,这算是
这篇整篇文章所讲的那个错误的一个小实例。

他的诊断是合理的。我的智能体用 GROQ 查询一个 Sanity 数据集,而他以为
失败来自模型猜测文档类型并编造查询语法。他的修复方案:把
真实的 schema 和示例放进工具描述里,这样模型被告知结构
而不是去猜。

想法很合理。我也会尝试同样的做法。

然后我运行了他的示例。

这两条查询

都来自 pull request 描述。下面这条是针对一条 claim、对实时数据集运行的:

*[_type == "claim" && _id == "claim-ledger-population"][0].{status, expiryStatus}

-> HTTP 400  "attribute expected"

一个多余的句点,位于[0]和{之间。删掉它就能正常工作:

*[_type == "claim" && _id == "claim-ledger-population"][0]{status, expiryStatus}

-> {"status": "standing", "expiryStatus": "no_expiry_set"}

还有针对一条 patch 记录的这条:

*[_type=="patch"&&findingRef._ref=="finding-b1"][0]->null

Null for two separate reasons. There is nofindingReffield on my patch documents — it is
findings, and it is an array. And the ids are case sensitive:finding-B1, notfinding-b1.

Written correctly:

*[_type=="patch"&&"finding-B1"infindings[]._ref][0]{sha,inMain}->{"sha":"dd1a654","inMain":false}

Six field names that do not exist

The patch did not just carry two broken examples. It wrote a schema into the tool description,
and the schema did not match my data:

the patch told the model what the database actually has
claim.subject nothing
claim.statement text
finding.finders foundBy
finding.verifiedReceipts commentIdorcommentIds, depending on the record
patch.findingRef findings[]
patch.commitHash sha

Check it yourself, no key required:

curl -sS --get 'https://u58x3mt0.api.sanity.io/v2025-08-15/data/query/production' \
  --data-urlencode 'query=*[_id=="finding-B1"][0]'

That is the record his example referenced. An earlier draft of this post ran the same command
againstfinding-B8instead, because B8 carriescommentIdsand B1 carries the singular
commentId, which made my table read cleaner. That is picking the sample that proves the point,
in a post about not doing that, and a reviewer caught it before it shipped.

So a patch written to stop a model from inventing schema would have installed a mismatched schema
into the exact place the model reads it from. Worse than the bug it fixed, because a guess made at
runtime is a guess, while a guess sitting in the tool description arrives with the authority of
documentation.

Why the names were wrong, which is not what I assumed

I wrote a draft of this post that said I did not know how the patch was produced and was not going
to guess. That was the right call, because when I asked, the answer was nothing I would have
guessed. In his words, published with his permission:

The dataset itself is public of course, but I wasn't able to replicate the full MCP setup on my
end at the time I tested it so I was hitting that 401 on the Context MCP endpoint. So I just
tested the query generation logic against an offline mock schema on LM Studio instead, which is
where those field names came from.

Nothing was hallucinated. He hit the 401 at the top of this post.

He could read every one of my 120 documents in a browser. He could not run my agent, because the
agent does not take the public route — it goes through an org-scoped MCP endpoint needing an
一个我没有提供、也不打算随仓库发布的组织凭证。

所以他做了谨慎的事。他构建了一个 schema 的离线 mock,并针对它测试查询
生成逻辑。而在构建这个 mock 时,他选了比我更好的名字:

创建那个 schema 时,我用了干净、自描述的语义化命名(用 commitHash 而不是
sha,用 verifiedReceipts 而不是 commentIds,用 finders 而不是 foundBy),因为显式命名
能让 SLM 更容易理解各个字段代表什么,并避免混淆。

顺便说一句,他在这点上是对的。commitHash是比sha更好的名字。foundBy比
更差finders。那张表里每一个错误的名字,都是有能力的人在
设计 schema 而非阅读 schema 时会选的名字。

其中一个问题比命名差异更严重。verifiedReceipts不只是重命名
commentIds——它断言了数据无法支撑的东西。comment id 表示在那个地址存在一条评论。
它完全没有说明是否有人验证过它。而这个区别正是它所描述的
记录的全部主题。

这个缺陷是我的

mock 不是 bug。mock 是对 401 的合理应对。

bug 在于我的仓库宣传了一条复现路径,而系统实际使用的并不是这条路径。
uses.
README 写着“Public dataset”,并给出了本文顶部的 URL。这是真的,
而且这正是我在这篇文章早先版本里放的内容——一个curl,无需密钥,自己检查
。它复现的是数据。它没有复现智能体,后者通过一个对除我之外所有人返回 401 的端点访问同样的
记录。

按照我的 README 操作的贡献者会落入一个所有记录可见、但什么都无法运行的地方。唯一的出路是构建一个替身。而在这个边界上构建的替身
并不会停留在边界——他 mock 的字段名从本地 LM Studio 测试一路进入了
工具描述,而我的 harness 正是在那里明确告诉模型 schema 是什么。
tool description, which is where my harness explicitly tells the model what the schema is.

这就是全部区别,一句话概括。运行时的猜测显而易见是猜测。把同样的猜测放进
工具描述里,我就把它提升成了权威指引。

我也犯过同一类错误。我曾向某人描述我自己的验证器,说它检查
被引用的记录是否与检索到的内容匹配。它并不匹配。我当时是在凭记忆描述我
本打算构建的系统,而不是打开那个文件。

真正解决问题的方法

不是争论。是两条命令。

我拉取了他的分支,针对实时数据集运行了两个示例,并把他收到的确切内容发给了他:
那个 400、那个 null,以及修正后的查询及其真实输出。没有对他
方法的意见,没有关于诊断的争论。

他大约两小时内推送了一个修订版。每个字段名都修正了,两个查询都重写了:

claimclaim-ledger-population->standing/no_expiry_setpatchforfinding-B1->dd1a654/falsefindingfinding-B8->commentIds['3ee98'],statusunbuilt

这三条至今仍然返回的正是那个结果。注意第三条是B8,而不是我上面让你 curl 的 B1
—— B1 携带的是单数形式commentId "3eanf"和 statusimplemented。那是两条
不同的记录,而我在写这篇文章时已经把它们弄混过一次了。

路由规则里还有一处过时的引用,所以我也把它发了过去,连同它产生的那个 null。他
那天早上修好了。我重新运行了那三个模式,检查了文件仍然能解析、仍然没有
第三方导入,然后合并了它。

三次提交,三十六小时,发生在两个从未见过面的人之间。还有一个我喜欢的细节:
拉取请求正文里仍然显示着[0].{status, expiryStatus},那个损坏的游离点形式,即便
合并后的代码已经改用了修正后的形式。文档可以在可执行代码停止使用某个模式之后,继续让一个错误的模式存活着,这跟整篇文章的失败是同一回事,只是高了一层。
the executable stops using it, which is the same failure as this whole post, one layer up.

值得带走的东西

如果你维护着任何外部人可能贡献的东西,运行这个检查:

克隆你仓库的人,真的能执行你的系统所走的路径吗,还是只能执行
你的 README 所记录的路径?

对我来说这两者是不同的,而这个差距从内部是看不见的,因为我持有那个 token。
一切在我的机器上都能跑,原因是我从来不必去想的。

当两者不同时,贡献者唯一的选择就是做一个 mock。他们会做出一个好的——Pouya 的
命名比我的更好。然后这个 mock 的假设就成了你的文档,因为
工具描述就是文档,而模型并不知道它是针对一个
测试夹具写的。

还有第三种选择,它比我最初写下的那两个都要好。我说过
修复方法是让真实路径可达,或者在 README 中说明它不可达。两者都很弱,
因为两者都仍然让贡献者自己去发明契约。

如果外部人员无法执行某个特权依赖,就为他们提供一个可复现的契约。
从真实 schema 生成、并检入仓库的 fixture,让人可以针对相同的
接口进行测试,而无需拿到我所在组织的凭据。流水线变成了

production schema -> generated contract fixture -> contributor harness

而不是

README -> unreachable MCP -> contributor invents a substitute schema

问题从来不是 Pouya mock 了边界。而是我的仓库没有给他一个
可供 mock 的规范边界,所以他不得不自己设计一个——而设计得再好的猜测终究只是猜测。

这一点可以推广到 Sanity 之外,也推广到智能体之外。任何外部人员无法运行的东西——一个私有 API、一个
支付沙箱、一个内部队列、一个 OAuth 服务——都具有这种形态。如果你不掌握那个
替身,你的贡献者就会自己造一个,而他们的替身会编码他们的假设,而不是
你的。


感谢 Pouya 提交补丁,感谢他接受了两轮修正却从未对
诊断提出异议,也感谢他在本可以轻易让我发表一个猜测的情况下,仍然回答了那些名字从何而来的问题。合并是
。上面每一个查询都可以由4a2940f任何人针对公开数据集运行——而事实证明,这正是关键所在。
anyone against the public dataset — which, as it turns out, is exactly the point.