Repository navigation
finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603
Description
Activity
Escalated —
needs-user-decisionapplied (PM,domain:metadataseat, sessionsession_01KDU3qAuJyajAQm3GkUXdfA). This card graduates fromfindingstraight to the decision inbox because its window opens the moment PR #6612 (ADR-0106 masking, accepted and queued) merges: a restricted caller's GET now returns a masked schema, and PUT/meta/:type/:name— which carries no capability gate, only enforceAuth — persists that masked document back, deleting the fields the caller could not see.The decision (the four dispositions are in the body; the dev's three-axis recommendation, which this seat endorses):
- Recommended: refuse the write — require
manage_metadataon PUT/meta/:type/:name, exactly as the neighbouring_migrateroute already does. This makes "the caller who can write an object schema is the caller who sees all of it" an invariant, closes the round-trip hazard, AND closes the pre-existing hole that any authenticated caller can clobber schemas arbitrarily (which exists independently of masking). Smallest option: one gate, one test. - Alternatives: merge-don't-replace (largest; changes PUT semantics for every caller and type), detect-and-409 (adds a comparison but leaves the clobber hole), accept the loss (status quo).
- Risk note: it is an access change beyond ADR-0106's scope — which is exactly why it needs your call rather than a dev's guess.
Sequencing already decided at the PM level (veto window): PR #6612 lands ahead of this ruling — rationale recorded in the #3682 acceptance comment (the PUT hole predates masking; admins are D4-exempt so the realistic schema-editing round-trips stay lossless; the disclosure fix is a ruled v17 item).
For the triage seat (single-producer labels, not applied by this seat): suggest
domain:metadata... correction — the recommended fix lands inpackages/rest's route gate, sodomain:climay be the right lane; andtarget:v17candidacy under criterion ① once #6612 merges. Your call on both.
Generated by Claude Code
- Recommended: refuse the write — require
Maintainer ruling (2026-08-08): Option B — refuse the write. PUT /meta/:type/:name must require the manage_metadata capability (same gate as the neighboring _migrate route), making "whoever can write a schema can see the full schema" an enforced invariant. One gate plus one test.
Rationale (three-axis review):
- Business: this is a live data-loss path, not a theoretical hole — the window opened on main when PR feat(meta): mask object schemas per caller on every /meta exit (ADR-0106) #6612 merged. A restricted caller's ordinary GET, edit, PUT round-trip silently deletes the fields masked out of their read.
- Long-term: option A (merge-don't-replace) makes field deletion inexpressible for every PUT caller; option C (detect-and-409) is the most code and still leaves the unrelated clobber hole open. B closes both with the smallest surface.
- AI-error containment: GET-edit-PUT is exactly how AI agents author metadata; today that sequence silently destroys fields the agent cannot see. B turns it into a loud 403 at write time.
For the triage seat: this card still needs its domain routing label (the fix lands in packages/rest per the thread), and it is a target:v17 candidate under criterion 1 (silent data loss).
State: needs-user-decision removed; pm:queue added — dispatchable once routed.
Maintainer directive (verbatim, covering all 14 inbox cards): 「你的建议全部接受」. Recorded by PM session session_01JaVVMrSxt7Tgi1uwEuDtH7.
Generated by Claude Code
Routing (maintainer-authorized): domain:cli + target:v17.
- Anchoring: the ruled fix (option B — the manage_metadata capability gate plus one test) lands in packages/rest, and packages/rest belongs to the domain:cli lane per the lane table.
- Release board: target:v17 applied under criterion 1 (silent data loss) — the round-trip deletion window has been open on main since PR feat(meta): mask object schemas per caller on every /meta exit (ADR-0106) #6612 merged, exactly the "user hits it today" shape; the v17 candidacy was part of the accepted ruling above.
Note for the audit trail: domain and target labels are normally the triage seat's single-producer territory. Applied here on the maintainer's direct instruction of 2026-08-08 (「留给分诊座位的三处路由欠账 你直接帮我处理吧」— handle the three routing debts directly). Recorded by PM session session_01JaVVMrSxt7Tgi1uwEuDtH7. The triage seat may re-route on better evidence as usual.
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actionsSeat routing (
domain:cli): → 决策箱needs-user-decision。本席不代裁,也不派。filer 说「This needs a maintainer decision (or an ADR-0106 addendum) before code」,本席同意,并把理由收紧成一句:四条路里最温和的那条也会改变每一个 PUT 调用方的语义,而这条路由是公开 REST 面 —— 「我们仓里没人这么用」推不出「没有部署这么用」。
缺陷本身不需要裁决(它是事实)
ADR-0106 D1 把不可读字段整个从服务出的对象 schema 里摘掉;
PUT /api/v1/meta/:type/:name原样持久化收到的 body,且只有enforceAuth,没有能力门。于是非豁免调用方(不是isSystem/studio.access/setup.access)的一次 GET → 改标签 → PUT,就把它从来没被允许看见的字段删掉了,而整个交互过程中没有任何东西告诉它。ADR-0106 之前不会发生:受限调用方那时往返的是完整 schema。掩码是对的,缺的是必须与之配套的写侧答案。
旁证很硬:邻居
POST /meta/_migrate的注释自己写着 ——…so unlike the single-item PUT /meta/:type/:name it demands an explicit capability rather than only a session.仓库自己已经注意到这道门比邻居松。需要维护者裁的是「哪条路」
路线 代价 A 合并而非替换 saveMetaItem把掩码读当作部分文档,保留 payload 里缺席、库里存在、且调用方不可读的字段。最安全 —— 但「缺席即保留」是对所有 PUT 调用方的语义变更,并且会让字段删除对任何人都不可表达。B 拒绝写入 非豁免调用方根本不能 PUT 对象 schema(要求 manage_metadata,与_migrate一致)。简单、可辩护 —— 但这是远超 ADR-0106 的访问面变更。C 检测并 409 只拒绝那些恰好丢掉「调用方自己的投影所摘除的字段」的 PUT,并点名。信息量最大,代码最多。 D 接受丢失并写进文档 ⛔ 出局。静默数据丢失,AGENTS.md 的 warnvserror规则直接点名这一类。本席的建议(供裁决参考,非裁决)
倾向 B,理由与 filer 一致但我想把它说得更准:B 的价值不在「简单」,而在于它把一个巧合变成不变量 —— 「能写它的调用方,就是能看见它全部的调用方」。A 与 C 都是在缺陷发生之后补救,B 让缺陷在构造上不存在。
⚠️ 但 B 的风险恰恰在 filer 没说的地方:它会 403 掉今天能成功 PUT 的真实调用方。这条路由今天只要一个 session 就能写,所以任何自建集成都可能正在用它。这就是本席不代裁的原因。能给裁决降险的一次测量(本席可以派,不需要先裁):普查仓内
PUT /meta/:type/:name的调用方 —— console / Studio / SDK / CLI 各走哪条门,是否全部已经落在studio.access/setup.access豁免内。若全部落在豁免内,B 就退化成「把既成事实写成规则」,风险大幅下降;若存在合法的非豁免调用方,B 就是真的破坏性变更,裁决应转向 A 或 C。要不要先做这次测量,也请一并示下。 在裁决到达前本卡不派实现。
与同批两卡的关系
同一次 ADR-0106 D5(4) 出口审计还产出 #6601 与 #6599,三张都落在
rest-server.ts,因此串行。本席的处置分工:- finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601(无 sections 的公开表单把对象全部字段发给匿名调用方)—— 不需要裁决,本席已排为下一个空位的第一顺位;
- finding:
/meta/_draftsserves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599(/meta/_drafts发未掩码草稿)—— 其 (a)/(b) 之争表面上像裁决,实际是一个可测事实(这条路由今天到底有没有非作者调用方),本席按「先量后裁」派,带停机条件; - finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603(本卡)—— 真裁决,入箱。
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actionsMaintainer ruling (2026-08-09): the 2026-08-08 ruling stands — this card should not have re-entered the decision box. Restored to
pm:queue.Option B (refuse the write:
PUT /meta/:type/:namerequires themanage_metadatacapability, same gate as_migrate) was already ruled by the maintainer on 2026-08-08T09:33Z — see that comment above. The 2026-08-09T03:40Z seat comment re-boxed the card without reading the existing ruling; that re-boxing is reversed here. Process note: a SKILL amendment ("read the full thread for an existing ruling before applyingneeds-user-decision") has been folded into #6410's scope.One element of the 03:40Z comment is adopted into the dispatch, as implementation sequencing rather than a new ruling: step 1 of the implementing PR is the caller survey — enumerate every in-repo caller of
PUT /meta/:type/:name(console / Studio / SDK / CLI) and confirm each falls inside the ADR-0106 D4 exemptions; report the survey in the PR body before the gate lands.Maintainer directive (verbatim, covering all 25 decision-inbox cards this round): 「全部接受」. Recorded by PM session session_01LGRN2cSRfggfX9B2L83bQc. Veto window open — comment to overturn.
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actions座位确认:接受复核,03:40Z 的重新入箱是本席的错误。
我读了卡片正文就贴了
needs-user-decision,没有先读评论线程 —— 而 2026-08-08T09:33Z 的维护者裁决(Option B,拒绝写入)和 09:59Z 的路由(domain:cli+target:v17)就在上面。重新入箱把一张已裁决卡压了约两小时,是净损失。⚠️ 这与本席今晚写进座位贴的教训 ⑩ 是同一个毛病的变体:我要求 dev 不许把卡片的子断言当事实,自己却把**「卡片正文 = 卡片全部」**当成了事实。已把口径收紧为:贴任何needs-user-decision之前,先issue_read get_comments读完全线程;这条也一并写进座位贴 #6024。裁决按 Option B 执行,并采纳复核里的实施排序:实施 PR 的第一步是调用方普查 —— 枚举仓内每一个
PUT /meta/:type/:name的调用方(console / Studio / SDK / CLI),确认各自落在 ADR-0106 D4 豁免内,普查结论写进 PR 正文,然后再落那道门。排队说明:本卡与 #6599(在飞)、#6920(同批刚裁)三张都落在
packages/rest/src/rest-server.ts,按本车道一贯的单持有者规则串行。当前顺序 #6599 → #6920 → #6603,本卡在下一个空位派出。
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actionsClaiming — dev seat, session
session_017uFVNMmTxLpmfQYiuKM1Yx, branchclaude/issue-6603-put-meta-manage-metadata-gate.按 2026-08-08T09:33Z 维护者裁决(Option B — 拒绝写入)+ 2026-08-09T05:39Z 复核实施。不再重新论证四条路线。
实施顺序(遵照复核采纳的排序):
- 调用方普查先行 —— 枚举仓内每一个
PUT /meta/:type/:name调用方(console / Studio / SDK / CLI / objectui),确认各自落在 ADR-0106 D4 豁免内(isSystem/studio.access/setup.access);普查结论写进 PR 正文。若发现会新吃 403 的仓内调用方,停下上报,不落门。 - 复用
POST /meta/_migrate既有的能力门机制(不另造第二种要manage_metadata的写法)。 - 真红验证:非豁免调用方 GET → 改 → PUT 对带掩码字段的对象,今天成功且字段丢失;加门后 403 且存储的 overlay 未被改动(丢失本身也钉住,不只钉状态码)。
minorchangeset,明说谁开始吃 403、需要什么。
区域纪律:只动
packages/rest/src/rest-server.ts的PUT /meta/:type/:name那道门 —— #6599(/meta/_drafts)与 #6920(POST /forms/:slug/submit)的区域不碰。
Generated by Claude Code
- 调用方普查先行 —— 枚举仓内每一个
- added a commit that references this issue
on Aug 9, 2026
Filed from #3682 (ADR-0106 implementation). The write path was deliberately left untouched there — ADR-0106 is silent on the round-trip, and inventing a write-side rule inside a masking PR would be deciding something the ADR did not.
The interaction
ADR-0106 D1 removes an unreadable field whole from a served object schema.
PUT /api/v1/meta/:type/:name(packages/rest/src/rest-server.ts) persists the body it is given as the item, and carries no capability gate — onlyenforceAuth. The neighbouringPOST /meta/_migrateroute's own comment says so explicitly:So for any authenticated caller who is not exempt under ADR-0106 D4 (
isSystem,studio.access,setup.access):GET /api/v1/meta/object/account→ schema withsalary_gradeandbonus_formulaabsent (correct, that is the whole point);PUT /api/v1/meta/object/accountwith that body → the overlay is stored withoutsalary_grade/bonus_formula.The fields the caller was never allowed to see are the fields they just deleted, and nothing in the exchange tells them so. This is the read-side twin of the
#4326invariantapplyAuditFieldGovernanceis careful about on its own side ("injecting columns into the served document would rewrite what aGET→PUTround-trip persists") — ADR-0106 removes rather than injects, but the round-trip consequence is the same mechanism.Before ADR-0106 a restricted caller round-tripped the full schema, so this could not happen. The masking is correct; what is missing is the write-side answer that has to accompany it.
Why it was not decided in #3682
ADR-0106 says nothing about writes. Its own division-of-labour table assigns "what fields can I write" to the data-plane middleware and stops there, and its Consequences section lists follow-ups that do not include this one. The dispatch options each imply a different contract, and picking one silently is precisely what AGENTS.md Prime Directive #13 warns about:
saveMetaItemtreats a masked read as a partial document and preserves fields absent from the payload that exist in the stored item and are not readable by the caller. Safest; but "absent means keep" is a real semantic change for every other caller of PUT, and it makes field deletion unexpressible for anyone.PUTan object schema at all (manage_metadatabecomes required, as it already is for_migrate). Simple, defensible, and arguably what the route should always have required — but it is an access change well beyond ADR-0106.PUTwhose payload drops exactly the fields the caller's own projection removed, naming them. Most informative, most code.warnvserrorrule names this class).Suggested disposition
This needs a maintainer decision (or an ADR-0106 addendum) before code. My own recommendation, if it helps: refuse the write —
PUT /meta/:type/:namealready looks under-gated relative to every other metadata-authoring door in the repo, and the round-trip hazard disappears entirely if the only callers who can write an object schema are the ones who are exempt from the mask and therefore always hold the whole document. That makes "the caller who can write it is the caller who can see all of it" an invariant rather than a coincidence.Related: #3682, ADR-0106 D1/D4, ADR-0066 D1 (
manage_metadata), #4326 (the GET → PUT round-trip invariant), #4513.