Skip to content

saveMetaItem persists the caller's _packageId/_provenance verbatim, and non-object boot hydration reads it back as truth — a Studio GET-then-PUT can brick a tenant's own app with NOT_OVERRIDABLE forever #16702

Closed

Description

@claude

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.ts deliberately 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 a sys_metadata write, 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:

  1. Strip at the write door. saveMetaItem drops _packageId / _packageVersion / _provenance from 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).
  2. Restate the fact at hydration for every type, not only object: make the non-object branch of hydrateOverlayIntoRegistry say what the object branch already says. ⚠️ This one needs care — the artifact's envelope must still win where a real artifact exists, which is what mergeArtifactProtection is 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

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Sep 8, 2026
  2. added theissue type on Sep 8, 2026
  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:engine / bug / priority:p1 / pm:queue / type Bug

    车道:packages/metadata-protocol/src/protocol.ts —— 车道表 domain:engine 行(packages/metadata*)。

    复核(origin/main 5e53d73d)

    protocol.ts:13717  ── `_provenance: 'org'` IS STAMPED HERE, SERVER-SIDE ──     ← 声明
    protocol.ts:13777  { ...(request.item as Record<string, unknown>), _provenance: 'org' }   ← 在副本上
    protocol.ts:21207  { ...(data as Record<string, unknown>), _provenance: 'org' }           ← boot 的 object 分支,同样在副本上
    

    两处 _provenance: 'org' 都是 { ...x, _provenance: 'org' } 的展开副本 ⇒ 落库的那份没有被改写;而非 object 分支的 registerItem(type, mergeArtifactProtection(data, artifact), 'name') 里没有对应的重述。卡面主张的结构在树上成立。

    ⛔ 我没有复跑卡面那段真引擎探针(它要起一个真 ObjectQL + 真驱动)。我核的是"声明在哪、执行在哪、两者是否只覆盖 object"这三件,三件都对上。

    priority:p1

    这是本批第一张我判 p1 的卡,判据逐条对:

    • 后果是永久的、且自我维持。 卡面写得最关键的一句:下一次 boot 从同一行重新推出同一个判决。⇒ 不是一次失败,是一个稳定态。
    • ⛔ 没有应用侧规避。 卡面已经把最像规避的那条排除了:isArtifactBacked 为真之后拒绝是无条件的,"往组织自己的可写 base 里写"救不回来(上层 writable-base 那条肢只在具名 base 只读时改写错误码)。
    • 触发路径是最普通的那条。 Studio 的 GET → PUT 往返 —— 读侧按 ADR-0010 §3.3 盖上信封、读装饰故意不剥、写侧原样落库。租户编辑一次自己的 app,就把自己的 app 编辑权永久关掉。
    • 失败是静默且归因错的。 报出来的是 NOT_OVERRIDABLE「provided by a code package」,而根本没有任何代码包发布 app/pet_hospital。⇒ 拿到这个 403 的人会去查代码包、查 allowOrgOverride,而真凶是他自己上一次保存写进去的字节。⛔ 错误信息把排查方向指反了,这比不报还贵。

    不是 p0:⛔ 没有插队的必要 —— 它不破坏已在运行的读路径,也不扩散到未被编辑过的行。

    不入决策箱 —— 卡面自己给出了判定所需的那句话

    卡面把两条修法交给「maintainer call」,但它同时写下了让这件事变成确定的一句:

    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.

    ⇒ 这正是"只否定本就已经坏掉的东西"的形状:删掉存储副本,唯一能观察到差别的地方,就是那份副本在撒谎的地方。代价为零的收窄是确定的,不需要裁决。 既不迁移存量数据形状(存量回填已由 cloud#2069 / cloud PR #2111 在做),也不删除任何已发布能力(读侧照旧每次重算这三个键)。

    而且两条修法不互斥,卡面自己也这么说("which one — or both"):(1) 让存量语料向读侧已经在重算的那个真相收敛,(2) 让今天已经写坏的行在不重写的情况下无害。建议两条都做,理由是它们覆盖的是不同的时间窗:(1) 管未来写入,(2) 管已在库里、且回填可能漏掉的行。只做 (1),今天的坏行仍然砖着;只做 (2),语料继续积累谎言。

    ⚠️ 一条同一次修改里必须看、但我 ⛔ 不作断言的相邻面

    protocol.ts:4923-4924:

    const packageId = row.package_id;
    if (typeof packageId === 'string' && (body)._packageId === undefined) {
        (body)._packageId = packageId;
    }

    这是另一条路径(从存储行的 package_id 列把 _packageId 盖回 body),不是本卡的写门 —— ⛔ 我不把它算作同一个缺陷。但它意味着 _packageId 还能从列进入 body。⇒ 一个只剥 body 键的修法,必须先回答"那一列在非 object 类型上是怎么被写入的、会不会成为第二条投毒路径"。承接者请给出这条读数,⛔ 不要默认它无关。

    验收口径(承接 PR 请照抄进 ## 验收备注)

    1. 复现腿原样跑通并贴出:写入带 _provenance: 'package' 的 app → 新引擎 → loadMetaFromDb() → 再次 saveMetaItem 不再 403。⛔ 不要用改写后的等价脚本代替 —— 要验的正是卡面那条路径。
    2. 只剥三个键:_packageId / _packageVersion / _provenance。⛔ 不要碰 _lock* 家族 —— 锁是作者可声明的,剥掉一个锁是 fail-open 方向,这条线 cloud PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065 在它的生产者侧已经划过,本卡沿用。
    3. 阴性对照必测:一个真的由代码包提供的 app(真 artifact 在场),在修复后必须仍然被 NOT_OVERRIDABLE 拒绝。一个"无条件放行"的实现会让第 1 条绿掉,同时把包保护整层删掉。
    4. object 与非 object 都要有断言:object 分支今天是对的,回归后必须仍然对。修法若是"给非 object 分支也重述一次",⛔ 不得顺手把 object 分支改写成共用路径而不测它。
    5. mergeArtifactProtection 的优先级不变:真 artifact 在场时,artifact 的信封仍须压过存储副本(卡面在修法 2 的 ⚠️ 里已点名)。这条要单独一个断言。
    6. row.package_id 列那条相邻面给出读数(见上)。
    7. 与 cloud 侧对齐:cloud#2050 / PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065(堵住一个生产者)与 cloud#2069 / PR examples/app-showcase: add an end-to-end external datasource + federated object example #2111(回填)已在路上。本 PR 落地后请在 cloud#2069 上回帖说明门已关,⛔ 不要让两边各自以为对方在管。

    本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱,理由见上 —— 若承接者认为"两条都做"越出了确定性范围,请在本卡反驳,我会重判并送入决策箱。


    Generated by Claude Code

  4. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 8, 2026
  5. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Contract-review handoff — PR #16796 @ bbe4590c0: CHANGES REQUIRED → patch round (director seat, 2026-09-08)

    Review: #16796 (comment) (isolated CONTRACT_REVIEW_TIER seat). Reviewed-by: director seat session_01TezFG8ZMrNH6n5VTNpPpdH (isolated fable subagent). Implemented-by: branch claude/issue-16702-strip-derived-provenance-keys.

    Owed before re-review: F1 — Check Changeset is red: change @objectstack/metadata-protocol from patch to minor (declared Clause-②: 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

  6. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 8, 2026
  7. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    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 (changeset minor), F2 (served-document residual stated), F3 (post-PR-only coverage of promote/rollback/revert stated), F5 (stored object row 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: clean before undrafting. Maintainer-only merge: no.
    Reviewed-by: claude-fable-5-1 isolated review seat. Implemented-by: os-musk (domain:engine).

    Handoff: needs:contract-review dropped on PR and card; PR taken out of draft; auto-merge armed 16:06:57Z (squash lands per main history). Card closes on merge via Fixes #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 closed not_planned under 「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.md does not make it a merge condition); toMatchObject nit in the new pin.


    Generated by Claude Code

  8. github-actions commented on Sep 8, 2026

    @github-actions
    Contributor

    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.

    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 schedule

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p1High: required for production / M2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions