Repository navigation
Description
saveMetaItem persists the caller's _packageId / _packageVersion / _provenance verbatim, and for every NON-object type boot hydration then reads that body back as the truth — so a Studio GET-then-PUT round trip can make a tenant's own item permanently un-editable.
The contradiction, in this file's own words
saveMetaItem's docblock already states the rule and the reason (packages/metadata-protocol/src/protocol.ts, the _provenance: 'org' IS STAMPED HERE, SERVER-SIDE section):
Client-supplied provenance cannot be trusted here:
metadata-read-decorations.tsdeliberately does NOT strip_provenance, so a Studio GET → PUT round-trip echoes whatever the served document carried. Every row this method sees came out of asys_metadatawrite, which is tenant-authored by definition (ADR-0010_provenance: 'org') — so the server states that fact rather than reading it back from the caller.
It then enforces that statement in exactly one place: applyObjectRegistryMutation / the boot re-hydration object branch, which register { ...body, _provenance: 'org' } on a COPY. The persisted body is never cleaned, and the non-object branch of the same two seams does not restate the fact either — hydrateOverlayIntoRegistry calls registerItem(type, mergeArtifactProtection(data, artifact), 'name') with the stored body as-is.
So for app, view, page, dataset and every other non-object type, the row's own body decides. SchemaRegistry.getArtifactItem's bare-key fallback accepts that hydrated entry as a code artifact when it carries a truthy _packageId that is not the sys_metadata sentinel and is not _provenance: 'org' (isCodeArtifactBody / isTenantAuthored), isArtifactBacked turns true, and saveMetaItem's overlay gate refuses every later write with NOT_OVERRIDABLE — permanently, since the next boot re-derives the same verdict from the same row.
Reproduction (measured, not argued)
Real ObjectQL engine + real ObjectStackProtocolImplementation(engine, undefined, environmentId) + real SchemaRegistry, sys_metadata registered, no code package loaded at all:
await protocol.saveMetaItem({
type: 'app', name: 'pet_hospital', packageId: 'app.sdbh',
item: { name: 'pet_hospital', label: 'Pet Hospital',
_packageId: 'app.sdbh', _packageVersion: '1.0.0', _provenance: 'package' },
});
// stored row:
// {"name":"pet_hospital","label":"Pet Hospital","_packageId":"app.sdbh","_packageVersion":"1.0.0","_provenance":"package"}
// fresh engine + protocol over the same driver, then loadMetaFromDb():
// {"loaded":1,"errors":0,"invalid":0,"storeUnavailable":false}
await protocol.saveMetaItem({ type: 'app', name: 'pet_hospital', item: { name: 'pet_hospital', label: 'edit' }, packageId: 'app.sdbh' });
// throws:
// code=NOT_OVERRIDABLE status=403
// [not_overridable] Metadata item 'app/pet_hospital' is provided by a code package
// and the type has not opted into per-org overlay writes (allowOrgOverride=false)No code package ships app/pet_hospital. The refusal is produced entirely by the tenant's own stored bytes. It is also unconditional once isArtifactBacked is true: the writable-base limb above it only re-codes the error when the named base is read-only, so authoring into the org's own writable base does not rescue it.
Why the cloud fix is not this fix
cloud#2050 / cloud PR #2065 stopped ONE producer — service-ai-studio's stageDraft waist — from writing the three keys back. cloud#2069 (PR objectstack-ai/cloud#2111) back-fills the rows already written. Neither closes the door: any other read-modify-write consumer of GET /meta/TYPE/NAME → PUT /meta/TYPE/NAME re-poisons the row, because the read stamps the envelope (mergeArtifactProtection, ADR-0010 §3.3), the read decorations deliberately keep it, and the write persists it. The console's own metadata editors are exactly that shape.
Per the cloud repo's contract-first rule, cloud does not want a tolerant strip in a second consumer; the door belongs here.
What the fix probably is (a suggestion, not a ruling)
The three keys are read-side DERIVED — mergeArtifactProtection re-computes them from the artifact on every read, so a stored copy is never load-bearing and its removal is observable only where the copy was a lie. Two shapes, both narrow:
- Strip at the write door.
saveMetaItemdrops_packageId/_packageVersion/_provenancefrom the body it persists, the same three keys and NOT the_lock*family (a lock is author-declarable, and dropping one is the fail-open direction — the line cloud PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065 drew at its producer). - Restate the fact at hydration for every type, not only
object: make the non-objectbranch ofhydrateOverlayIntoRegistrysay what theobjectbranch already says.⚠️ This one needs care — the artifact's envelope must still win where a real artifact exists, which is whatmergeArtifactProtectionis doing in that same call.
(1) alone converges the stored corpus on what the read already re-derives; (2) alone makes today's rows harmless without rewriting them. Which one — or both — is a maintainer call.
Filed from cloud#2069 (PR objectstack-ai/cloud#2111), which measured the mechanism at framework pin a5eccf92577490da8a4ee82285fcdcb5b876c0e1 while running against checkout c383352cb.
Generated by Claude Code
Activity
os-zhuang commentedon Sep 8, 2026
分诊:
domain:engine/bug/priority:p1/pm:queue/ typeBug车道:
packages/metadata-protocol/src/protocol.ts—— 车道表domain:engine行(packages/metadata*)。复核(
origin/main5e53d73d)两处
_provenance: 'org'都是{ ...x, _provenance: 'org' }的展开副本 ⇒ 落库的那份没有被改写;而非object分支的registerItem(type, mergeArtifactProtection(data, artifact), 'name')里没有对应的重述。卡面主张的结构在树上成立。⛔ 我没有复跑卡面那段真引擎探针(它要起一个真
ObjectQL+ 真驱动)。我核的是"声明在哪、执行在哪、两者是否只覆盖object"这三件,三件都对上。priority:p1这是本批第一张我判 p1 的卡,判据逐条对:
isArtifactBacked为真之后拒绝是无条件的,"往组织自己的可写 base 里写"救不回来(上层 writable-base 那条肢只在具名 base 只读时改写错误码)。NOT_OVERRIDABLE「provided by a code package」,而根本没有任何代码包发布app/pet_hospital。⇒ 拿到这个 403 的人会去查代码包、查allowOrgOverride,而真凶是他自己上一次保存写进去的字节。⛔ 错误信息把排查方向指反了,这比不报还贵。不是 p0:⛔ 没有插队的必要 —— 它不破坏已在运行的读路径,也不扩散到未被编辑过的行。
不入决策箱 —— 卡面自己给出了判定所需的那句话
卡面把两条修法交给「maintainer call」,但它同时写下了让这件事变成确定的一句:
⇒ 这正是"只否定本就已经坏掉的东西"的形状:删掉存储副本,唯一能观察到差别的地方,就是那份副本在撒谎的地方。代价为零的收窄是确定的,不需要裁决。 既不迁移存量数据形状(存量回填已由 cloud#2069 / cloud PR #2111 在做),也不删除任何已发布能力(读侧照旧每次重算这三个键)。
而且两条修法不互斥,卡面自己也这么说("which one — or both"):(1) 让存量语料向读侧已经在重算的那个真相收敛,(2) 让今天已经写坏的行在不重写的情况下无害。建议两条都做,理由是它们覆盖的是不同的时间窗:(1) 管未来写入,(2) 管已在库里、且回填可能漏掉的行。只做 (1),今天的坏行仍然砖着;只做 (2),语料继续积累谎言。
protocol.ts:4923-4924:这是另一条路径(从存储行的
package_id列把_packageId盖回 body),不是本卡的写门 —— ⛔ 我不把它算作同一个缺陷。但它意味着_packageId还能从列进入 body。⇒ 一个只剥 body 键的修法,必须先回答"那一列在非object类型上是怎么被写入的、会不会成为第二条投毒路径"。承接者请给出这条读数,⛔ 不要默认它无关。验收口径(承接 PR 请照抄进
## 验收备注)_provenance: 'package'的app→ 新引擎 →loadMetaFromDb()→ 再次saveMetaItem不再 403。⛔ 不要用改写后的等价脚本代替 —— 要验的正是卡面那条路径。_packageId/_packageVersion/_provenance。⛔ 不要碰_lock*家族 —— 锁是作者可声明的,剥掉一个锁是 fail-open 方向,这条线 cloud PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065 在它的生产者侧已经划过,本卡沿用。app(真 artifact 在场),在修复后必须仍然被NOT_OVERRIDABLE拒绝。一个"无条件放行"的实现会让第 1 条绿掉,同时把包保护整层删掉。object与非object都要有断言:object分支今天是对的,回归后必须仍然对。修法若是"给非 object 分支也重述一次",⛔ 不得顺手把 object 分支改写成共用路径而不测它。mergeArtifactProtection的优先级不变:真 artifact 在场时,artifact 的信封仍须压过存储副本(卡面在修法 2 的row.package_id列那条相邻面给出读数(见上)。本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱,理由见上 —— 若承接者认为"两条都做"越出了确定性范围,请在本卡反驳,我会重判并送入决策箱。
Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Contract-review handoff — PR #16796 @
bbe4590c0: CHANGES REQUIRED → patch round (director seat, 2026-09-08)Review: #16796 (comment) (isolated
CONTRACT_REVIEW_TIERseat).Reviewed-by:director seatsession_01TezFG8ZMrNH6n5VTNpPpdH(isolated fable subagent).Implemented-by:branchclaude/issue-16702-strip-derived-provenance-keys.Owed before re-review: F1 —
Check Changesetis red: change@objectstack/metadata-protocolfrompatchtominor(declaredClause-②: yes; ⛔ do not flip the declaration). F2 — state in the changeset and body that door 2 corrects the authorization verdict, not the served document (a poisoned row keeps serving_provenance: 'package'until re-saved or backfilled by cloud#2069), and carry that into the criterion-7 reply on cloud#2069. F3 — qualify "door 1 covers them transitively" to post-PR bodies. F5 optional one-line pin.State: carrier removed from card and PR (review concluded); re-hang both with the patched head. Card stays
pm:dispatched, assignee unchanged.Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Contract review recorded — PR #16796 @
e50b911ab: PASS, landed by the director seat (2026-09-08 16:07Z)Review: #16796 (comment) (
claude-fable-5-1, isolated seat). Prior F1 (changesetminor), F2 (served-document residual stated), F3 (post-PR-only coverage of promote/rollback/revert stated), F5 (storedobjectrow pin) closed; F4/F6 accepted as recorded. Patch round touched only the changeset and the test file. Governed paths: no. Clause-② yes,minor, no BREAKING. CI 34/34 green,mergeable_state: cleanbefore undrafting. Maintainer-only merge: no.Reviewed-by:
claude-fable-5-1isolated review seat. Implemented-by: os-musk (domain:engine).Handoff:
needs:contract-reviewdropped on PR and card; PR taken out of draft; auto-merge armed 16:06:57Z (squash lands permainhistory). Card closes on merge viaFixes #16702.Criterion 7 discharged by the director seat (the implementing seat could not reach
cloud): reply posted on objectstack-ai/cloud#2069 (https://github.com/objectstack-ai/cloud/issues/2069#issuecomment-5588181407) — door shut upstream, served-document residual named; that card is closednot_plannedunder 「2069 不考虑现有数据」, so no cloud action is owed.Non-blocking notes from the review, not carried as work: the PR body lacks the attribution footer (attribution is in all 8 commit trailers;
AGENTS.mddoes not make it a merge condition);toMatchObjectnit in the new pin.Generated by Claude Code
github-actions commentedon Sep 8, 2026
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.d2b6fa023f, merged intomain.bug,priority:p1,domain:engine— ownership, priority and outcome are not state claims.A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.
posted by half-state-patrol run 34270387866 · trigger
scheduleGenerated by Claude Code
fix(metadata-protocol): stop persisting derived provenance keys, and …