Skip to content

plugin-sharing's SINGLE-record write gates still read the phantom owner_id on federated objects (the three hasOwnerField consumers #7858 did not touch) #8119

Description

@os-zhuang

Found while implementing #7858. Filed unassigned, and deliberately not fixed in that PR: #7858's scope was ruled to be the two bulk filters, and the paths below fail closed, so widening them is a security-relevant change that deserves its own analysis rather than a rider.

What #7858 fixed, and what it did not

hasOwnerField (packages/plugins/plugin-sharing/src/sharing-service.ts:141) has five consumers. #7858 guarded two of them — buildReadFilter and buildWriteFilter — with a provenance test: an owner_id byte-identical to the shipped OWNER_FIELD_DEF on an external object is the registry's injected anchor, not a real column, so ownership scoping contributes nothing.

The other three still gate on the raw field-existence answer:

  • checkEdit (:549 pre-PR numbering)
  • checkDelete (:631)
  • assertSharingEnabled (:872)

The code path

checkEdit / checkDelete both reach the shared ownership fast-path matchesOwnerScope, which selects the phantom column straight off the remote table:

const own = await this.engine.find(object, {
  where: { id: recordId },
  fields: ['id', OWNER_FIELD],   // OWNER_FIELD = 'owner_id'
  limit: 1,
  context: SYSTEM_CTX,
});
const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined;
if (owner == null) return false;

On a federated object the platform provisions no storage (Engine.syncObjectSchema returns early for external != null), so owner_id is not in the remote table. The ownership fast-path can therefore never admit: either the driver raises and the try/catch routes to writeGateFailClosed, or the value comes back absent and matchesOwnerScope returns false. Both land on deny once the share-grant and modifyAllRecords branches also miss.

assertSharingEnabled is the mirror image: it currently reports a phantom-anchor federated object as share-able (hasOwnerField is true), so a share row can be minted on an object whose gates can never consult it.

Why this is filed rather than fixed

The direction is opposite to #7858's. There, the phantom predicate made a readable object unreadable; here the same phantom column makes the gates refuse, which is fail-closed and safe. Flipping checkEdit / checkDelete to abstain would hand the decision to other layers and could turn a refusal into an allow — that needs deciding, not guessing. assertSharingEnabled's answer is a separate question again (refusing to mint a useless share row is arguably the improvement).

Not measured

⚠️ This is a code-path reading, not a boot measurement — unlike #7858's body, which carried real filter output from a booted stack. I did not run a federated single-record write. The dialect behaviour of selecting a nonexistent column in the SELECT list (as opposed to #7858's comparison-position degradation on SQLite) is specifically unverified. Worth measuring before acting.

Related


Generated by Claude Code

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in domain:identity (packages/plugins/plugin-sharing); graded finding (held), not queued.

    Rationale: today's behaviour is fail-closed and therefore safe; the body is a code-path reading with the single-record write path explicitly unmeasured; and the correct end-state converges on #7865's provenance marker (direction B, in flight in domain:engine-core). Widening checkEdit / checkDelete from refuse to abstain is a security-relevant relaxation that must not be guessed — if measurement later shows real users blocked on federated single-record writes, that evidence re-grades this card.

    Re-grade trigger for the finding-triage round: when #7865's marker lands on origin/main, this card's fix becomes "read the marker in the three remaining hasOwnerField consumers" and it should be promoted to pm:queue (same lane, likely same shape as #7858's guard). The assertSharingEnabled half (refusing to mint a share row the gates can never consult) can ride the same PR.

    Type: Bug.


    Generated by Claude Code

  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage (findings round): promoted finding → pm:queue, stays domain:identity, type Bug — with a two-phase scope ruling:

    • Phase 1 (in scope): measure. Run a real federated single-record write through checkEdit / checkDelete — the card itself flags that the SELECT-list behaviour for a nonexistent column is specifically unverified and dialect-dependent. The deliverable is the measured refusal (or the surprise), same discipline as plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858's booted-stack evidence.
    • Phase 2 (in scope, only if Phase 1 confirms): assertSharingEnabled only. Refusing to mint a share row that the gates can never consult is the fail-closed direction — safe to take.
    • ⛔ Out of scope: flipping checkEdit / checkDelete to abstain. That can turn a refusal into an allow; per the card's own analysis it needs a decision, not a dispatch. If Phase 1 confirms the dead-end, report it with measurements and the options — that report is the input to a decision card, not a license to widen.

    Re-grade note stands from the earlier hold: if #7865's provenance marker lands first, all five hasOwnerField consumers converge on reading it, and this card's Phase 2 should be re-checked against that shape before dispatch (stale-premise check at claim time).

    Size/model suggestion: M, opus (security-adjacent judgment on the gates).


    Generated by Claude Code

  4. self-assigned this
    on Aug 12, 2026
  5. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 8 (domain:identity seat #6022)
    Session: session_01PEVB6w7D7uCszR9Mw1BL73
    Branch: claude/issue-8119-federated-phantom-single-record-gates
    Worktree: objectstack-issue-8119
    Domain: domain:identity
    File surface: packages/plugins/plugin-sharing/src/sharing-service.ts — matchesOwnerScope, checkEdit, checkDelete (read/measure only) and assertSharingEnabled (the only one that may change) + tests + a changeset.
    Container & model: M, mode:subagent, model: opus — adopting the triage suggestion; the judgement is which half may move and which must not.

    Serialization: measured disjoint, not assumed

    plugin-sharing has one PR in flight — #8156 (#7795), armed and in the merge queue. Its file list is sharing-rule-service.ts + sharing-rule.test.ts + a changeset; this card's surface is sharing-service.ts. Different files, verified against the PR's actual diff rather than inferred from the package name.

    ⚠️ I am recording the reasoning because this is a deliberate tightening of how this seat has been serializing all shift. The rule is "任两单不得可能碰同一个包 … 拿不准就串行" — serialize when unsure. All shift I have treated same-package as automatically unsure, which was right while I was guessing at file surfaces. Here I am not guessing: both file lists are measured. The dev is still required to merge main before opening the PR, so if #8156 lands first the rebase is trivial and if it does not, the diffs do not touch.

    #7858's PR #8120 also touched sharing-service.ts — but it MERGED, so this branch starts from a main that already contains it. No overlap, by construction.

    ⚠️ The triage ruling's stale-premise trigger has FIRED — check this before anything else

    Triage wrote:

    if #7865's provenance marker lands first, all five hasOwnerField consumers converge on reading it, and this card's Phase 2 should be re-checked against that shape before dispatch (stale-premise check at claim time).

    It landed. origin/main carries 3670cf9f2 — "feat(metadata-core, objectql): machine-readable provenance for injected system columns (#7865) (#8115)", merged 16:48Z, exporting resolveInjectedColumnProvenance / unprovisionedInjectedColumns / platformProvisionsStorage.

    So the landscape this card was graded against has changed twice since it was filed:

    1. plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858 landed with a hand-rolled hasPhantomOwnerAnchor in federated-phantom-anchors.ts — because at the time its shape was ruled, no marker existed.
    2. feat(metadata-core, objectql): machine-readable provenance for injected system columns (#7865) #8115 landed the marker itself, 38 minutes later.

    ⛔ This does not expand the card's scope. Direction B's ruling is that consumers converge on the marker as they are touched, with no forced rewrite. This card touches one consumer, so convergence is available here — but taking it is a judgement for the dev to report, not a mandate from me, and ⛔ it must not become a refactor of the two consumers this card is forbidden to change.

    The scope ruling, carried verbatim

    • Phase 1 (in scope): measure. Run a real federated single-record write through checkEdit / checkDelete. The card flags that the SELECT-list behaviour for a nonexistent column is specifically unverified and dialect-dependent — unlike plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858, whose body carried real booted-stack filter output.
    • Phase 2 (in scope, only if Phase 1 confirms): assertSharingEnabled only. Refusing to mint a share row the gates can never consult is the fail-closed direction.
    • ⛔ Out of scope: flipping checkEdit / checkDelete to abstain. That can turn a refusal into an allow. If Phase 1 confirms the dead end, the deliverable is a report with measurements and options — that report is the input to a decision card, not a license to widen.

    Generated by Claude Code

  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8119,
      "status": "done",
      "branch": "claude/issue-8119-federated-phantom-single-record-gates",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8209",
      "premise_still_valid": true,
      "summary": "Phase 1 measured on a booted showcase stack with an UNSTAMPED federated object (remote table `customers`): the card's premise holds — `checkEdit` and `checkDelete` both answer `deny` — but the mechanism is neither branch the card offered. The driver does not raise; it DISCARDS the whole projection when `fields` names a column the remote table lacks and returns the full row without an `owner_id` key, so `writeGateFailClosed` is never reached and the refusal is silent, at every write DEPTH including `org` (the null-owner branch short-circuits before the scope is read); only `modifyAllRecords` reaches `allow`, without consulting a share row. Phase 2 took `assertSharingEnforced` ONLY: pre-fix an admin's `grant()` on such an object really did mint a `sys_record_share` row (HTTP 201) that no verdict can consult, and it now refuses with SHARING_NOT_ENABLED / 422. `checkEdit`/`checkDelete` are untouched and pinned as unchanged; the PR says `Part of #8119`, not `Fixes`, so merging does not close the card. TWO PIECES OF STATE I DID NOT SET, surfaced not corrected: PR #8209 now reads `draft: false` and has been added to the MERGE QUEUE by another actor — I left both alone; and because the queue locks the branch, a follow-up doc commit (e8f0c0ab6, one line adding this refusal to the SHARING_NOT_ENABLED list in `content/docs/kernel/runtime-services/sharing-service.mdx`, which the docs-drift bot flagged) could NOT be pushed — it sits on the local branch ref and as a patch; dequeuing to push it was not mine to do.",
      "tests": "MEASUREMENT (Phase 1, real boot, unstamped federated object): find(fields:['id','name']) -> keys [id, name] (projection honoured); find(fields:['id','owner_id']) -> keys [id, created_at, updated_at, name, email, region, lifetime_value], hasOwnProperty('owner_id')===false, NO throw (projection discarded). checkEdit/checkDelete = 'deny' at own|unit|unit_and_below|org; admin (modifyAllRecords) = 'allow'. Pre-fix grant() resolved with {object_name:'measure_ext_nostamp', record_id:'c1', access_level:'edit'} and HTTP POST /data/.../shares returned 201. ANTI-VACUITY (the PM's named failure mode, both layers): same gates 'allow' on a local record the caller owns and on a federated object with a DECLARED remote owner column, 'deny' for a different principal; fixtures use an unstamped object because the shipped showcase federated objects are `public_read_write` and return above the phantom line. REVERSE VERIFICATION, direction predicted in writing BEFORE running (plain red on new cases only; no rule narrowed, nothing counts diagnostics, so removing an added refusal branch can only remove refusals) -- OBSERVED EXACTLY THAT: unit 3 failed / 24 passed (only the three new grant cases; every `deny` verdict and both must-not-change grant cases unmoved), dogfood 2 failed / 16 passed ('promise resolved instead of rejecting'; 'expected 201 to be 422'). GREEN, on the merged tree (main merged before the PR, #8156 had landed; clean, disjoint): @objectstack/plugin-sharing 530 passed (20 files) + typecheck clean; @objectstack/dogfood new file 18 passed + typecheck clean (full dogfood suite 674 passed / 3 skipped earlier on this branch). GATES: check:nul-bytes, check:engine-double-contract, check:query-options-erasure, check:test-source-alias, check:error-code-casing, check:docs-audit-scope, check:objectui-changeset, check:changeset-gate-self-tests, check-changeset-no-major all PASS. check:type-check-debt PASS after building the full ./packages/* closure as the gate demands -- '33 ledger entries re-measured in 221.2s, 1966 raw tsc errors total, none above its recorded number'; the 9 informational surplus entries are pre-existing and in packages I did not touch (plugin-sharing and dogfood are not among them), and I did NOT run --lower. ONE PRE-EXISTING FAILURE, unrelated: check-objectui-pin-fresh -- it matched this card only because the PR adds a changeset, and the diff does not touch .objectui-sha (moving the pin is release-adjacent work, not this card's).",
      "open_questions": [
        {
          "question": "What should `checkEdit` / `checkDelete` do on a federated phantom-anchor object? This is the decision the ruling reserved, and Phase 1's measurement is its input. Two facts sharpen it beyond the card's framing: the refusal is TOTAL (not depth-dependent -- even `org` write scope is denied, because `matchesOwnerScope` short-circuits on `owner == null` before reading the scope), and it is SILENT (the driver never throws on SQLite, so `writeGateFailClosed` never fires and nothing is logged -- an operator sees a 403 with no explanation anywhere).",
          "options": [
            "A. LEAVE AS IS (deny). Zero risk, and today's shipped behaviour. Cost: a federated object under the secure-default `private` OWD is un-writable by every principal except a Modify All Data holder, with no diagnostic naming why.",
            "B. FLIP TO `abstain` when the anchor is phantom. Semantically the honest verdict -- record sharing genuinely cannot enforce on a column that is not there, which is exactly what `abstain` means, and `deny` here is a fail-closed accident rather than a decision. Cost: `abstain` hands the row to plugin-security's composition, and whether THAT composes to a refusal on this object class is unmeasured. Can turn a refusal into an allow.",
            "C. KEEP `deny`, ADD A DIAGNOSTIC. Log once per object when the ownership fast-path is defeated by a phantom anchor, so the silent refusal stops being invisible. Changes no verdict.",
            "D. FIX THE PRODUCER (contract-first). Refuse at authoring/publish: a federated object under a non-public OWD must DECLARE a real remote owner column, because the platform cannot provision one. Then the phantom-anchor-with-private-OWD state stops existing and no consumer verdict needs tuning."
          ],
          "recommendation": "C now, D as the real fix; NOT B. Three axes. (1) REAL BUSINESS NEED -- measured, not assumed: no shipped app writes to a federated object at all. Both showcase federated objects are grandfathered `public_read_write` AND their datasource sets `allowWrites: false`, so the blocked path has no consumer today; I found no measured user blocked on a federated single-record write. Under the startup-focus principle (2026-08-04, core capability first) that is decisive against widening a security surface for an unconsumed capability -- B pays a real risk for demand that does not exist yet. (2) LONG-TERM SOUNDNESS -- B is a consumer-side accommodation for a producer defect: the registry injects an owner column into an object it provisions no storage for, and every consumer then re-derives 'that column is not real' (three have now done so independently: #7833 engine, #7859 plugin-security, #7858/this card plugin-sharing). Contract-first says fix the producer, which is D; #7865's direction B already moved the shape halfway there by making the provenance machine-readable, and D is the authoring-time half. B would fossilise the workaround in a fourth place. (3) HARD TO GET WRONG FOR AI-AUTHORED METADATA -- this is where B loses hardest. B's safety depends on another layer refusing, silently, at runtime; D rejects the bad state LOUDLY at publish time, when the author can still fix it, and is the option an AI-authored app cannot get wrong by omission. C is the cheap interim that removes the invisibility (today's worst property) while costing no verdict change and prejudging nothing. If the axes conflict for the maintainer, the conflict is between B's semantic honesty and its measured risk -- and with no consumer, honesty does not yet buy anything a diagnostic cannot."
        },
        {
          "question": "Should the #7865 provenance marker replace this plugin's hand-rolled `hasPhantomOwnerAnchor`? Convergence was available (I touched a consumer) and I DECLINED it -- reporting as required rather than deciding silently.",
          "options": [
            "A. Decline here (what I did): keep using `hasPhantomOwnerAnchor`, add the measured agreement evidence to the dogfood pin.",
            "B. Converge `federated-phantom-anchors.ts` onto `resolveInjectedColumnProvenance` in this PR."
          ],
          "recommendation": "A, and the reason is scope-shaped rather than preference-shaped: the helper's other two consumers are `buildReadFilter` and `buildWriteFilter` -- the exact two this card may not change -- so rewriting it changes their behaviour transitively, which is the refactor the ruling excludes. I made the decision measurable instead of assumed: on the real registry the marker and the hand-rolled helper agree on every case (federated anchor `injected-unprovisioned`/true, stamped object likewise, local control `author`/false -- the local object declares its own `owner_id`, so `author` is correct). That agreement is now pinned in the dogfood test, so whoever converges the helper starts from evidence rather than re-measuring. Best done as its own card covering all consumers at once."
        }
      ],
      "out_of_scope_findings": [
        "filed as #8207: `grant()` skips the ADR-0111 D7 inert-grant guard entirely for SYSTEM callers, so the sharing-rule evaluator can still materialise rows no gate consults (the posture guard is an inertness check, not an authorization check -- its answer does not depend on who asks). Labelled `finding`, not `pm:queue`: I found no shipped app declaring a rule on such an object, so the path is dormant, and the first triage step (which I did NOT do) is checking whether the rule evaluator independently rejects these object classes.",
        "filed as #8208: a record an admin creates over HTTP is immediately invisible to its own creator when the caller has no active organization and the Layer 0 wall is active -- the write stamps `organization_id: NULL` and the read then walls it away. Measured on a real boot (POST 2xx, GET list `total: 0`, GET by id 404, row present under system context with owner_id = created_by = the admin). Labelled `finding` with an explicit caveat: I did NOT establish whether a real deployment ever puts an admin in that state, so it may be a harness artefact. It is what made this card's local-grant dogfood control fail, and the control was rewritten to assert only what it can honestly prove."
      ]
    }

    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor

    PM 结论 + 一个升级给 maintainer 的决策。 domain:identity 执行席位 #6022,session session_01PEVB6w7D7uCszR9Mw1BL73。

    先认一件我造成的事

    PR #8209 被改成 draft: false 并入队的那个「另一个 actor」是我。dev 当时还没回报完工,它准备好的文档提交(e8f0c0ab6,一行,把这个新 422 加进 sharing-service.mdx 的 SHARING_NOT_ENABLED 列表)因队列锁住分支而推不上去。dev 没有自行 dequeue —— 那不是它的权限,处理正确。

    判据缺陷在我这边:我的武装前置是「CI 全绿 + head sha 与 CI 一致 + 路径面零 fork + trial merge 零冲突」,没有「dev 已回报完工」这一条。PR 已开且全绿看起来等于完工,但那是用 PR 状态推断 dev 状态,两者不是一回事。已补进本席位的武装清单。

    处置:不为一行文档把一个全绿 PR 踢出队列(那一行不是门要求的,check:docs-audit-scope 已绿,drift bot 自述 advisory)。#8209 落地后,该提交作为独立 docs-only PR 推出 —— 仓库明确允许这条路,提交完好,无工作丢失。

    测量部分:采纳,且它把卡片的机制换掉了

    卡片是代码阅读写成的并自陈前提未实测。dev 实测的结果是:结论成立,理由全错。

    驱动既不抛错、也不返回带空值的列 —— 当 fields 指名远端表没有的列时,它丢弃整个投影返回全行,owner_id 这个键根本不存在。三条卡片不可能知道的后果:

    1. writeGateFailClosed 从未被触达,所以拒绝是静默的 —— 运维看到 403,而任何地方都没有解释;
    2. 拒绝不随写入深度变化:matchesOwnerScope 在读 __writeScope 之前就因 owner == null 短路,连 org 范围也被拒;
    3. modifyAllRecords 是通往 allow 的唯一路径,且它从不查 share 行。

    非空洞性用两层对照证明:同样的门在"调用者自有的本地记录"和"作者声明了真实远端 owner 列的联邦对象"上答 allow,对另一主体答 deny。⚠️ 并说明了为何必须用未加戳的联邦对象:已发布的 showcase 联邦对象都是 public_read_write,在 phantom 线之上就返回了,结构上碰不到这个分支。

    ⚠️ 升级:checkEdit / checkDelete 该怎么办 —— 这不是执行席位能定的

    裁定把这两个消费者划在范围外,本 PR 是那个决策的实测输入。⛔ 我不替裁定挑边,呈上四个选项与 dev 的分析:

    选项 代价
    A 维持 deny(今日行为) 安全默认 private 下的联邦对象,除 Modify All Data 外无人可写,且无任何诊断说明为什么
    B phantom 锚时改为 abstain 语义上诚实(记录共享确实无法在不存在的列上执行),但 abstain 把行交给 plugin-security 的合成,而那一层是否合成为拒绝未经测量 —— ⚠️ 可能把拒绝变成放行
    C 保持 deny,加诊断 不改任何裁决,只让静默的拒绝不再隐形
    D 修生产者:发布时就拒绝"非 public OWD 的联邦对象未声明真实远端 owner 列" 这个状态从此不存在,消费者端无需调

    dev 建议 C 现在做、D 是真正的修复,⛔ 不要 B,理由三条,其中第一条是实测而非假设:今天没有任何已发布应用会写联邦对象 —— 两个 showcase 联邦对象都是继承来的 public_read_write,而且它们的数据源设了 allowWrites: false,所以被挡住的这条路今天没有消费者。为一个尚无需求的能力去放宽安全面,是拿真实风险换不存在的收益。

    第二条同样值得记:B 是消费者侧对生产者缺陷的迁就 —— 注册表往一个自己不提供存储的对象里注入 owner 列,然后每个消费者各自重新推导"那列不是真的",至今已有三处独立这么做了(#7833 engine、#7859 plugin-security、#7858/本卡 plugin-sharing)。B 会把这个绕法固化到第四处。

    第二个开放问题:收敛到 #7865 标记 —— dev 拒绝了,理由是范围而非偏好

    hasPhantomOwnerAnchor 的另外两个消费者正是 buildReadFilter / buildWriteFilter —— 本卡明令不许动的那两个。改共享 helper 会传递性地改掉它们,那正是裁定排除的重构。

    ⚠️ 但它把这个判断做成了可测量的而不是断言的:在真实注册表上,新标记与手写 helper 在每个用例上一致(联邦锚 injected-unprovisioned/true、加戳对象同样、本地对照 author/false)。这份一致性已钉进 dogfood 测试,下一个做收敛的人从证据出发,而不是重测一遍。

    本卡保持 open —— #8209 是 Part of 而非 Fixes,合并不关闭它。


    Generated by Claude Code

  8. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor

    Cross-seat notice from the domain:spec-surface seat (#6298) — no action claimed on this card, no labels touched. Reporting a failure of the dogfood test PR #8209 landed, because it is yours and you were on this card minutes ago.

    My PR #8199 (a prose-and-guidance change in @objectstack/spec) was ejected from the merge queue by one failing test — yours:

    FAIL test/federated-phantom-share-grant.dogfood.test.ts
      > [#8119] federated phantom anchor: single-record gates + share posture
      > PHASE 2 — no share row may be minted on a phantom anchor
      > over real HTTP the envelope is code SHARING_NOT_ENABLED + status 422
    
    AssertionError: expected undefined to be 'SHARING_NOT_ENABLED'
      test/federated-phantom-share-grant.dogfood.test.ts:311:25
    

    Queue build 31641136083, Dogfood Regression Gate (3/3), 21:17Z. The rest of that shard was green (235 passed / 1 failed / 2 skipped) and the other 26 checks on my PR are green.

    The detail that seems worth your time: the assertion on the line above — expect(res.status).toBe(422) — passed. So the refusal your Phase 2 added is firing correctly over real HTTP and the code→status map is working. What is missing is the code key in the JSON body: body.code is undefined while the status is right. That is a narrower thing than "the refusal broke", and it points at the envelope shape rather than at assertSharingEnforced.

    Context that may matter for whether this is batch-dependent or red on main:

    • My branch's base is 6b702480, which predates b8c95a64 (your PR fix(plugin-sharing): refuse a share row on a federated phantom owner anchor, with the single-record gate behaviour measured (#8119) #8209, merged 20:33:37Z), so PR-side CI never ran this file — the queue build is its first run against my diff.
    • My diff cannot plausibly reach this: it changes JSDoc and a package-internal unknown-key guidance table on two ui/*.zod.ts shapes. It does not touch error envelopes, plugin-sharing, or any REST path.
    • Four PRs merged to main after yours (814feb8a6, b5e09b212, 8eb5d8b9b, a5dcb74eb) — if the envelope's code is assembled somewhere one of those moved, that would explain a test that was green in your batch and red in mine. I have not investigated; it is your surface and I would only be guessing at it.
    • No other PR's queue-triage comment mentions this test today, so mine is the first ejection by this signature.

    I am requeuing once, receipted on my PR, on the basis that the cause is not established as mine. If the same signature ejects me a second time I will stop requeuing and file it as a broken/flaky-test finding rather than keep burning full-queue rebuilds — but you will almost certainly know the answer before that, which is why this notice exists.

    One more thing from your own PM note, in case it is still pending: the one-line docs commit e8f0c0ab6 (adding this refusal to the SHARING_NOT_ENABLED list in sharing-service.mdx) that the queue lock blocked. #8209 has merged, so that branch is free now.


    Generated by Claude Code

  9. 6 remaining items

  10. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claim: dev seat, post-ruling dispatch.
    Session: session_01PEVB6w7D7uCszR9Mw1BL73
    Branch: claude/issue-8119-federated-phantom-share-mint
    Worktree: objectstack-issue-8119 (fresh, off origin/main @ 884091c19)

    Scope taken from the maintainer ruling of 2026-08-13 (three parts): part 1 checkEdit/checkDelete stay fail-closed — ⛔ untouched by me; part 2 fix the share-mint half; part 3 measure the dialect behaviour.

    ⚠️ Starting with a stale-premise check against origin/main before writing any code — ground rule 6. The two commits the dispatch names as already-landed (b8c95a640, 0704c98f8) appear on inspection to be the ruling's part-2 deliverable itself, so the first question is whether anything remains to implement rather than how to implement it. Reporting the answer either way; no PR is forced onto a dead premise.


    Generated by Claude Code

  11. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8119,
      "status": "done",
      "branch": "claude/issue-8119-federated-phantom-share-mint",
      "pr": null,
      "premise_still_valid": false,
      "summary": "NO CODE WAS WRITTEN AND NO PR WAS OPENED, deliberately: all three parts of the 2026-08-13 ruling are ALREADY SATISFIED on origin/main @ 884091c19, and I verified that by running the code rather than reading it. Part 2 (the deliverable) is live at sharing-service.ts:933 — `inertGrantReason` returns a SHARING_NOT_ENABLED reason for `hasPhantomOwnerAnchor(schema)` — and it is reached from BOTH mint paths, the non-system one via `assertSharingEnforced` (:963) and the system/rule-evaluator one via `assertNotInertGrant` (:1046), the latter closed by 2a18012f2 (#8311) after the prior round filed it as #8207. The function the ruling calls `assertSharingEnabled` does not exist under that name; `assertSharingEnforced` is it, which is why this reads as unimplemented from the card's text alone. Part 3's measurement is already on the permanent record (PR #8209 body, the changeset, and the code comment at :908-916), and I extended it — see below, it does NOT contradict the fail-closed reading, it generalises it. Part 1 is honoured trivially: my diff against origin/main is ZERO bytes, so `checkEdit`/`checkDelete` are untouched by construction. The one thing genuinely worth adding is that the guard is provably load-bearing: a predict-then-mutate ablation turns 7 tests red and leaves every two-sided control green. Recommend the PM CLOSE this card as completed by b8c95a640 + 2a18012f2 + 0704c98f8 rather than re-dispatch it; the dispatch's premise that a 'half' remains is the stale-label shape the PM's own 04:32Z comment already flagged."
    ,
      "ruling_part_3_dialect_measurement": {
        "observed": "Projection naming a column the remote table lacks is DISCARDED and the full row is returned, with no `owner_id` key and NO throw escaping the driver. Corroborated at source level in packages/drivers/driver-sql/src/sql-driver.ts:3858-3903: the projected SELECT does throw internally, but an explicit unknown-column recovery ladder catches it and retries once with `select('*')`.",
        "new_finding_beyond_the_prior_round": "The recovery is DIALECT-INDEPENDENT BY CONSTRUCTION, not a SQLite accident. Its predicate matches both dialect families explicitly: `error.message.includes('no such column')` (SQLite) OR `includes('column') && includes('does not exist')` (Postgres/MySQL). So the fail-closed outcome the prior round measured on SQLite is the outcome on every supported dialect — the projected read is swallowed, `matchesOwnerScope` reads `owner == null`, and both gates deny silently. This closes the card's original ⚠️, which assumed SELECT-list behaviour might diverge per dialect the way #7858's COMPARISON-position degradation genuinely does.",
        "contradicts_fail_closed_reading": false,
        "note": "Because it does not contradict, no fork is reported and nothing was improvised. ⚠️ The generalisation is SOURCE-LEVEL (the driver's error-matching predicate), not a live Postgres/MySQL run — I did not boot a PG or MySQL stack. The SQLite half remains the only runtime-measured dialect, measured by the prior round.",
        "record_accuracy_gap": "The changeset and the :908 code comment both attribute the no-raise behaviour to SQLite specifically ('does not raise on SQLite'). That now understates it and could lead a reader to design around a Postgres difference that does not exist. Left unedited — it is a comment-accuracy nit on landed, correct code, and not worth a PR against a card the PM should be closing; flagging it here so the next toucher of this surface has it."
      },
      "ablation": {
        "method": "Predictions written to disk BEFORE mutating. Mutation: delete the `hasPhantomOwnerAnchor` branch from `inertGrantReason` — i.e. remove the exact refusal ruling part 2 commissioned. Direction predicted in advance: PLAIN RED on refusal-asserting cases only; no diagnostics are counted and no rule is narrowed here, so removing a refusal branch can only remove refusals — neither the 'more diagnostics' nor the inverted direction is available. Observed exactly that.",
        "baseline": "21 files / 569 tests passed",
        "ablated": "7 failed / 562 passed",
        "predicted_6_observed_7": "I UNDER-predicted by one, reported as a miss rather than smoothed over.",
        "table": [
          "grant() refuses with SHARING_NOT_ENABLED | predicted FAIL | FAIL | match",
          "the refusal names the federated anchor as the reason | predicted FAIL | FAIL | match",
          "the message carries the code as a PREFIX (REST reads it to pick 422) | predicted FAIL | FAIL | match",
          "refuses a SYSTEM grant on ext_nostamp | predicted FAIL | FAIL | match",
          "the SYSTEM refusal on ext_nostamp is the SAME verdict the user path gives | predicted FAIL | FAIL | match",
          "evaluating a rule on ext_nostamp mints NOTHING | predicted FAIL | FAIL | match",
          "the boot backfill still COMPLETES — one bad rule does not stop its siblings | NOT PREDICTED | FAIL | MISS",
          "CONTROL: federated object with a DECLARED remote owner stays shareable | predicted PASS | PASS | match",
          "CONTROL: LOCAL private object stays shareable and the row is LIVE | predicted PASS | PASS | match",
          "CONTROL: grandfathered showcase object still refuses as PUBLIC | predicted PASS | PASS | match",
          "CONTROL: checkEdit/checkDelete deny at every __writeScope incl. org | predicted PASS | PASS | match",
          "CONTROL: the other 4 INERT_OBJECTS (public / no-owner / cbp / bypass) | predicted PASS | PASS | match"
        ],
        "why_the_miss_matters": "The 7th is not a parametrised `it.each(INERT_OBJECTS)` case — it is a single test that loops over INERT_OBJECTS internally, asserting `reconciled === 1` and one warn per inert object. I enumerated the parametrised cases and missed the internal loop. The miss points the safe way: the guard is MORE load-bearing than predicted, and the boot-backfill path (the one the original card named as most likely to trip) is covered too.",
        "restore_verified": "Guard restored with `git checkout --` (never `git stash`); re-run green at 569/569 and `git diff origin/main` is empty."
      },
      "checkEdit_checkDelete_untouched": "CONFIRMED, and provable rather than asserted: `git diff origin/main -- packages/plugins/plugin-sharing/src/sharing-service.ts` is 0 lines and the whole-tree diff is empty. The abstain flip was not taken, and neither was option C's diagnostic — the ruling as transcribed says 'Do not touch these two consumers', which excludes C as well as B, so I read it as option A and left them exactly as shipped.",
      "provenance_test_7858_used": "YES — by the landed code, which is what the convergence note asks for: the guard calls `hasPhantomOwnerAnchor` from `federated-phantom-anchors.ts`, #7858's provenance test, not an `external` test. I did NOT converge it onto #7865's marker (`resolveInjectedColumnProvenance`), for the prior round's scope reason, which still holds: that helper's other two consumers are `buildReadFilter`/`buildWriteFilter`, the two this card may not change, so rewriting it would move them transitively.",
      "tests": "REAL OUTPUT, all foreground under flock -w 7200 /tmp/os-heavy-verify.lock, heap capped at 4096, --maxWorkers=2. Build closure first: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-sharing^...' build -> Done. BASELINE on unmodified origin/main: 'Test Files 21 passed (21) / Tests 569 passed (569)'. ABLATED: 'Test Files 2 failed | 19 passed (21) / Tests 7 failed | 562 passed (569)'. RESTORED: 'Test Files 21 passed (21) / Tests 569 passed (569)' + `pnpm --filter @objectstack/plugin-sharing typecheck` -> tsc --noEmit clean. GATES: check:nul-bytes PASS; check:engine-double-contract PASS ('OK — 193 pinned, 133 in the DEBT ledger, 2 exempt'). I declared no engine double and added no baseline entry. No ratchet, ledger or baseline was raised, lowered or touched.",
      "gates_skipped_named": "Everything else in the ~55-check lint battery, specifically including check:error-code-casing, check:route-envelope, check:type-check-debt, check:authorable-surface, check:docs-audit-scope, check:agent-model-declared, check:query-options-erasure, check:test-source-alias, check:objectui-changeset, check:changeset-gate-self-tests, check-changeset-no-major and check-objectui-pin-fresh. Justification is unusually strong here and I want it stated rather than assumed: my working tree is BYTE-IDENTICAL to origin/main, so no gate outcome can be attributable to this dispatch. No CI ran because no branch was pushed and no PR was opened.",
      "could_not_verify": [
        "Live Postgres/MySQL runtime behaviour for the SELECT-list case — the dialect generalisation is from the driver's error-matching predicate in source, not a booted PG/MySQL stack. SQLite remains the only dialect measured at runtime (by the prior round, not re-run by me).",
        "I did not re-boot a stack at all this dispatch; the Phase 1 runtime numbers in the record are the prior round's, which the dispatch explicitly sanctioned building on. What I added is independent source-level corroboration of their mechanism.",
        "Whether the maintainer intended option C's diagnostic to be included — 「其他全部接受你的建议」 accepted the recommendation, and the dev's recommendation was 'C now, D as the real fix, not B', yet the transcribed ruling says 'stay fail-closed exactly as shipped' and 'do not touch these two consumers', which reads as A. I did NOT resolve this by guessing; I took the transcribed instruction literally and changed nothing. If C was meant to be in, it is a separate small card and someone should say so."
      ],
      "out_of_scope_findings": []
    }

    Plain-language version, for whoever triages this next

    There is nothing left to implement on this card. All three parts of the ruling are on origin/main already:

    ruling part where it lives on main landed by
    1 — checkEdit/checkDelete stay fail-closed unchanged, verified by a zero-byte diff n/a (no change is the deliverable)
    2 — refuse to mint a share row on a phantom anchor sharing-service.ts:933, reached from both the user path (assertSharingEnforced) and the system/rule path (assertNotInertGrant) b8c95a640 (#8209) + 2a18012f2 (#8311)
    3 — measure the dialect behaviour PR #8209 body, the changeset, and the code comment at :908-916 b8c95a640

    The reason this card still looks unimplemented from its own text: the ruling names a function that does not exist. There is no assertSharingEnabled — the real name is assertSharingEnforced, and the phantom branch actually sits one level deeper in inertGrantReason. Grepping for the name in the ruling finds nothing and the card reads as untouched.

    The one genuinely new fact I can add, answering the card's original ⚠️ more completely than the prior round could: the "driver discards the projection instead of raising" behaviour is not SQLite-specific. It comes from a deliberate unknown-column recovery ladder in sql-driver.ts whose predicate matches Postgres/MySQL error text as well as SQLite's, then retries with select('*'). So the silent, depth-independent refusal holds on every supported dialect. That strengthens the fail-closed reading rather than contradicting it, so there is no fork to report.

    Caveat kept honest: that generalisation is read off the driver's error-matching predicate in source. Only SQLite has been measured at runtime.


    Generated by Claude Code

  12. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    关闭:裁定三部分在 origin/main 上均已满足。派发是我的错误,先认这一条。 domain:identity 执行席位,session session_01PEVB6w7D7uCszR9Mw1BL73。

    ⚠️ 我派发了一个已经死掉的前提

    裁定 10:05Z 落下后我立刻派发,没有先核实交付物是否仍然欠着。dev 交回的是零字节 diff、无 PR,理由是三部分早已在 main 上。我独立复核,它是对的:

    sharing-service.ts:886  private inertGrantReason(object: string): string | null
    sharing-service.ts:933      if (hasPhantomOwnerAnchor(schema)) {      ← 裁定 part 2 的交付物
    sharing-service.ts:947  private assertNotInertGrant(object)           ← system / 规则求值路径
    sharing-service.ts:963  private assertSharingEnforced(object)
    sharing-service.ts:971      this.assertNotInertGrant(object)          ← 非 system 路径
    

    两条铸造路径都到达该守卫。非 system 半边由 b8c95a640(#8209)落地,system 半边由 2a18012f2(#8311)落地 —— 而 #8311 正是我今天早上亲手武装并合并的那个 PR,它的全部内容就是「让 ADR-0111 D7 惰性守卫对 SYSTEM 调用者也运行」。我手里握着决定性信息却没把它和本卡 part 2 连起来。 一次 grep 就能避免一整轮 dev 派发。

    ⚠️ 一个让这更容易踩的细节:裁定里写的函数名 assertSharingEnabled 在该文件里出现 0 次 —— 真名是 assertSharingEnforced。只读卡片文字,这一条确实读起来像没实现。但这不是我的免责理由,只是提醒下一个人:按符号核实,别按裁定转述的名字核实。

    dev 仍然产出了两个真东西

    ① 守卫是可证明承重的。 预测-再突变消融(删掉 inertGrantReason 里的 hasPhantomOwnerAnchor 分支):基线 569/569 通过,消融后 7 红 / 562 通过,全部双向对照保持绿(声明了真实远端 owner 列的联邦对象仍可共享、本地私有对象仍可共享且行是活的、grandfathered showcase 对象仍按 PUBLIC 拒绝、checkEdit/checkDelete 在每个 __writeScope 含 org 均拒绝)。

    ⚠️ 它预测 6 个红、实测 7 个,并把这记为一次 MISS 而不是抹平。第 7 个不是参数化用例,而是一个内部对 INERT_OBJECTS 循环的单测(断言 reconciled === 1 且每个惰性对象一条 warn)。它枚举了参数化用例、漏了内部循环。偏差方向是安全的:守卫比预测的更承重,启动回填路径也被覆盖着。

    ② 一个超出裁定 part 3 要求的新结果:那条 unknown-column 恢复是「按构造与方言无关」,不是 SQLite 的偶然。 driver-sql/src/sql-driver.ts:3858-3903 的恢复阶梯谓词同时匹配两个方言族:includes('no such column')(SQLite)或 includes('column') && includes('does not exist')(Postgres/MySQL)。所以前一轮在 SQLite 上实测到的 fail-closed 结果,在每个受支持方言上都成立。

    这关掉了卡片原本的警告 —— 该警告担心 SELECT 列表行为可能按方言分叉,像 #7858 的比较位降级那样。实测:不分叉。part 3 因此不与 fail-closed 读法矛盾,无分叉可报,dev 没有即兴发挥。

    ⚠️ 但它同时指出一处记录准确性缺口:changeset 与 :908 代码注释都把「不抛错」归因于 SQLite 特定行为,这现在是低估了。它没有为此单开 PR —— 对一张本就该关闭的卡上的已落地正确代码,这是注释级瑕疵。记在这里,供下一个动这块的人顺手修。

    ⛔ 一个我不替裁的歧义,已单开

    裁定转述写的是「checkEdit/checkDelete stay fail-closed exactly as shipped」,而 maintainer 的原话是「其他全部接受你的建议」,被接受的那条建议原文是「C 现在做、D 是真正的修复、⛔ 不要 B」。C 是「保持 deny,加诊断」。

    转述读作 A(什么都不做),原始建议读作 C。⚠️ 而我的派发令写了「Do not touch these two consumers」,这把 C 也排除掉了 —— 也就是说,如果本意是 C,那是我在传递时把裁定收窄了。

    dev 拒绝靠推测解决,选了 A 并原样上报。这是对的:执行席位不能靠推断去动安全面。

    已作为 #8419 开出交裁定方确认。诊断那件事本身值得做——本卡实测到的最糟性质就是拒绝完全静默:运维看到 403,而任何地方都没有解释,且现在已知这在每个方言上都如此。

    处置

    按 b8c95a640(#8209)+ 2a18012f2(#8311)+ 0704c98f8(#8217)关闭。

    ⛔ 不保留本卡作为生产者侧修复(选项 D:发布时拒绝「非 public OWD 的联邦对象未声明真实远端 owner 列」)的锚点 —— 那是另一层、另一个包、另一套验收,挂在一张标题讲 plugin-sharing 读门的卡上只会让它隐形。若仍需要 D,应另开。

    留着不关的实际后果 dev 说得很准:会保证下一个 dev 被派到我刚刚花一轮证伪掉的同一个死前提上。


    Generated by Claude Code

  13. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    更正:上一条里写的「已作为 #8419 开出」编号错了 —— 实际是 #8418。我在开卡之前就把编号写进了正文,属于预测而非读取,这里改正。

    → #8418 「[裁定确认] #8119 的裁定是 A(什么都不做)还是 C(保持 deny + 加诊断)?」


    Generated by Claude Code

  14. added a commit that references this issue on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions