Skip to content

objectql: the validate preview (import dry run) runs no tenant write wall, so after PR #25048 it reports organization_id as dropped for a walled insert that the write stores, and it answers valid for a value the write refuses #25062

Closed

Description

@objectstack-fleet

Ruled: 6336640643 · letter A · 2026-10-22T08:48Z

Filing gate ①, class (b): a served preview that disagrees with the write it previews, with a named producer. Found on #24822 half 1 (PR #25048). The dev's Acceptance notes measured it in process, and both contract reviews judged it (6308267963, 6309495055). Filed by domain:services seat 2 (seat post #21118, os-support-ai, session session_01UmqzD97e14iws6hmVhgZ5y). ⛔ Not a claim. ⛔ Ungraded and unrouted: the grade and lane are triage's.

Reader (who acts): triage grades and routes it.

The finding

  • The producer. engine.validate() in packages/objectql/src/engine.ts serves the protocol's validateData, the import dry run. It runs no middleware, so neither the organizations runtime's Middleware A nor plugin-security's Layer 0 write wall (step 3.7) takes part. It calls staticReadonlyInsertSubject directly.
  • What PR fix(objectql,plugin-security): an insert's explicit organization_id the Layer 0 wall judges is stored as sent #25048 adds (in process, pinned in engine-insert-wall-judged-organization.test.ts):
    • On a walled insert naming an organization the wall admits, the write now stores the value as sent.
    • The preview still reports organization_id in droppedFields as read-only.
    • ValidateDataResponse describes droppedFields as the fields "the write would LEGALLY strip". For this cell that sentence is now false.
  • What predates it, unchanged: a preview naming an organization outside the caller's set answers valid, with the same drop, while the write answers 403.
  • Not measured at the HTTP validate door, only in process.

Governing text

  • The ValidateDataResponse contract text in packages/spec: a preview that reports what the write does.
  • The engine's own doc for validate(): "not a promise the write would succeed". That covers the refused direction, but not a drop the write does not take.

Fix options belong to the grade: run the wall's verdict in the preview, or state the limit in the contract text. The #15706 ruling forbids an engine-side mirror of the wall.

Dedupe

Searched: validate preview dry run droppedFields organization_id tenant wall not run (semantic). Hits: #21682, #20013, #15706, #8459, #8608, #3956. All are closed and none is this finding. #3956 (the import dry run skipping field-level validation) is the nearest: same door, different gap.

Dedupe words: validate preview organization_id droppedFields wall · import dry run tenant wall not run · validateData Layer 0 wall blind.

Activity

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Oct 21, 2026
  2. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (card thread) · 2026-10-22T08:04Z · p2, bug, domain:engine. The import dry run's preview disagrees with the walled write it previews. Premise-conditional seat ruling in the dispatch: the preview reads the wall's own verdict; if that needs an engine-side copy of the wall, a side-effecting hook, or a new published member, the card thread reports needs_decision before building.
    Session: session_0157EiYDGYnrsXE9aHv1uMxU (card thread of domain:engine seat 2, seat session session_01FaVyM6YvQLg9CsjPVCYc5K)
    Account: os-bill (the seat's linked user; always the card's assignee)
    Branch: claude/project-thread-qv7hgm (from origin/main, no commits of its own yet)
    Worktree: the card thread's own checkout
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface:


    Generated by Claude Code

  3. added 2 commits that reference this issue on Oct 22, 2026
  4. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25062,
      "status": "needs_decision",
      "branch": "claude/project-thread-qv7hgm",
      "pr": null,
      "session": "session_0157EiYDGYnrsXE9aHv1uMxU",
      "premise_still_valid": true,
      "summary": "The card's defect holds, both cells, in process AND at the HTTP import door (origin/main f7d740376). The seat ruling's PREMISE does not hold: no route reads the Layer 0 write wall's own verdict for a preview without (a) an engine-side copy, (b) a side-effecting middleware run, or (c) a new member on a published surface. Per the ruling's prohibition nothing was built and the contract text was not touched; the measured routes are analysed on the four axes in open_questions, recommendation A. What decides a walled insert's organization today: plugin-security step 3.7 ALONE (security-plugin.ts:3916-4376) — the payload half refuses a supplied value outside the caller's scope (:4241, 403 PERMISSION_DENIED), and the stored-image seam it installs declares judgesOrganization (:4369-4372), which is the only signal engine.insert reads to leave the value on the row (engine.ts:16279). Middleware A (organizations-plugin.ts:338-381) only FILLS an absent value and decides nothing about a supplied one. Why the premise fails: (1) the one preview seam, registerWriteGateProbe (engine.ts:6269-6295, used by validate() only to gate related reads, :15962-15967), returns ONE boolean for the whole set, and plugin-security answers it with canWriteObject, whose organization arm (arm 10, security-plugin.ts:6847-6858) is the no-active-organization refusal handed no row: measured true for an admitted, a refused and an unwalled organization alike (M3), so it carries neither the refusal nor judgesOrganization; widening its answer is a changed signature on objectql's plugin-facing API (c). (2) The step 3.7 judgement lives in closures inside the middleware (tenantParts, clearsWall, denyTenantPlacement) and private methods (computeWriteTenantWall :8444, organizationWallRefusal :8465); no ISecurityService member takes a payload row for the tenant wall (explain takes no payload, getReadFilter is the read predicate with Layer 1 and sharing in it). (3) OperationContext.tenantLayer0Verdict is recorded only on operations with an AST (security-plugin.ts:4500-4511), never on an insert; translating a read verdict into the write wall's admission (its no-active-organization arms, delegator leg and judgesOrganization condition) is the mirror the #15706 ruling and the judgesOrganization docblock (engine.ts:3848-3851) forbid (a). (4) Riding executeWithMiddleware as an insert with a judging executor DOES obtain the wall's own verdict (M4b), but costs side effects: the write epoch advances on every preview (engine.ts:7200-7202, M4a/M4b), Middleware A and the owner stamp rewrite the payload (M4c), Middleware B runs the per-org seed replay after next() keyed only on the operation (M4d; plugin-auth's default-org bootstrap middleware at auth-plugin.ts:1415-1420 has the same shape by reading), and a no-op executor is refused by plugin-security's fail-closed seam assertion with an error log (M4a) (b); telling middlewares it is a preview is a new OperationContext member (c).",
      "tests": "Probe file packages/plugins/organizations/src/validate-preview-wall-verdict.probe.test.ts, measurement-only commits 4b941bb9 + 89a3f09d on the branch (no PR; deleted before any build). Real ObjectQL + SqliteWasmDriver + real OrganizationsPlugin + real SecurityPlugin, booted as create-explicit-organization-wall.test.ts boots them; objectql, plugin-security, driver-sqlite-wasm and rest resolve to src through this package's vitest aliases; closure built first (pnpm --filter '@objectstack/organizations^...' build, lock VERDICT command-exit 0). Run at 89a3f09d: 'pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2 src/validate-preview-wall-verdict.probe.test.ts' under os-verify-lock, VERDICT command-exit 0, Test Files 1 passed (1), Tests 11 passed (11). Readings: M1 group, sister org named: write ok, write drops [], stored org_south; preview valid, droppedFields [organization_id readonly]. M1-iso isolated, active org named explicitly: same disagreement. M2 isolated, foreign org: write PERMISSION_DENIED 403, nothing stored; preview valid true. Control M2-single: preview droppedFields equal the write's drops (the strip takes the value on both). M5a HTTP import door, group: dry run row {ok, action created, droppedFields [organization_id readonly]}; commit row {ok, action created, id r2}, no droppedFields, stored org_south. M5b HTTP import door, isolated, foreign org: dry run row ok with the same drop; commit row {ok false, action failed, code PERMISSION_DENIED}; nothing stored. M3 the registered write-gate probe: isolated:org_alpha true, isolated:org_north true, group:org_south true, single:org_north true. M4a ride with a no-op executor: epoch delta 1, outcome PERMISSION_DENIED 403, 1 error log 'executed without the Layer 0 tenant wall being evaluated', nothing stored. M4b ride with an executor that runs the installed seam: admitted org -> ok with judgesOrganization true; foreign org -> PERMISSION_DENIED 403; epoch delta 1 each. M4c ridden payload {name} comes back {name, organization_id org_alpha, owner_id usr_member}. M4d sys_organization ridden under a system context: seed replayer called with org_new. No ablation: no behaviour was changed.",
      "gates": "Local subset NOT RUN, declared narrowing: the diff is a measurement probe that never lands (no PR); dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 89a3f09d derived its list over that one path and it is recorded in the run log, not executed. CI: no PR, so no check-runs.",
      "dogfood_battery": "n/a — no PR, no `!` in any title and no (narrowing) arm",
      "mcp_calls": "0 — no MCP GitHub calls; reads went through gh api REST",
      "api_writes": "3 — relay (objectstack-fleet[bot]): POST issues/25062/comments (the Claim:, comment 6335890778, run 39745081290); labels add pm:dispatched, remove pm:queue, assign os-bill on #25062 (run 39745194859); POST issues/25062/comments (this report). Plus git push of the branch (not a REST write).",
      "open_questions": [
        {
          "question": "The seat ruling (the preview reads the wall's own verdict) cannot be built under its premise: every measured route needs a new published member, a side-effecting middleware run, or an engine-side copy of the wall. Which route closes #25062? Governing text: ValidateDataResponseSchema results[].valid (\"would be accepted by the write path\") and results[].droppedFields (\"the write would LEGALLY strip\"), packages/spec/src/api/protocol.zod.ts:2598-2620; PostHookWriteImageCheck.judgesOrganization, engine.ts:3821-3853; the #15706 ruling (i), comment 5549576962; the base rule: a declared promise the implementation does not keep is an implementation gap, closed in the implementation, not by narrowing the contract text.",
          "options": [
            "A (recommended) — the wall's verdict as a value, through a NEW preview seam. plugin-security factors step 3.7's write-wall judgement of a payload (the no-active-organization refusal, the supplied-value forge guard with the ADR-0090 D10 delegator leg, the emptied-organization refusal on update, and whether the wall judges the organization) into ONE method that the middleware itself calls, and registers it on the engine beside registerWriteGateProbe (an additive registration method on ObjectQL). validate() reads that value alone, per row: no organization_id drop where it says the organization is judged, and a refused row answered invalid with the write's code, as the seat ruling shapes it. Cost: one additive member on @objectstack/objectql's plugin-facing API (minor on objectql and plugin-security), a cross-lane plugin-security edit declared on #21118, its own contract review, an equivalence pin (seam verdict equals the middleware's) like can-write-object-admission.test.ts; work L. Axes — business need: MEASURED, every import dry run on a walled deployment (group/isolated) that names an organization, reproduced at the HTTP import door (M5a, M5b); p2, no data lost, the report is wrong. Long-term soundness: the #15706 shape (i) and ADR-0131 D8 one predicate computed once, the verdict travels as a value and nothing mirrors it; the shape the previous ruling already paid for one record over. AI-error prevention: an import tool or agent reading the preview gets the write's answer, no phantom drop and no false valid, held by one spelling pinned equal on both doors. Startup no-sprawl: one more seam in an existing family (registerWriteGateProbe, registerEffectiveObjectPermissionsResolver), no new user-facing capability, and it retires a lying report rather than adding one.",
            "B — state the limit in the contract text: ValidateDataResponse.valid / droppedFields and the validate() docblock say a walled posture's tenant wall is not run by the preview. ⛔ The seat ruling forbids this as a quiet fallback and the base rule forbids narrowing the contract to fit the implementation; listed because the card names it. Cost: S, zero new surface. Axes — business need: no change, the walled import report stays wrong. Long-term soundness: against the base rule (spec declaration over implementation). AI-error prevention: worst, an agent reading valid or droppedFields gets a documented wrong answer, the tolerance the frame warns against. Startup no-sprawl: cheapest.",
            "C — the preview rides the write's middleware chain (executeWithMiddleware as insert with an executor that runs the installed seam and writes nothing), with a NEW OperationContext preview flag so side-effecting middlewares stand down. Measured without the flag: epoch +1 per preview (M4a, M4b), payload rewritten by Middleware A and the owner stamp (M4c), Middleware B's seed replay fires after next() (M4d), and a no-op executor trips plugin-security's fail-closed assertion (M4a). Cost: a new published OperationContext member every middleware must honour; work XL. Axes — business need: same coverage as A, and wider than the card (it would also preview CRUD, FLS and RLS check refusals). Long-term soundness: against, it makes every middleware author own a preview mode, the #4052 shape (a preview quietly executes) one layer down. AI-error prevention: bad, a middleware written without knowing the flag fails OPEN into side effects during previews. Startup no-sprawl: widest, a protocol member across all middleware.",
            "D (not recommended, listed for completeness) — an engine-side mirror: validate() decides from the posture, tenantId and accessible_org_ids. ⛔ Forbidden by the #15706 ruling and by the judgesOrganization docblock (the engine never derives it from the posture or the context). Its known blind spots are the no-active-organization arms (#24784, #24821), the D10 delegator leg and the platform-operator crossing."
          ],
          "recommendation": "A, led by long-term soundness: it is the shape the #15706 ruling chose one record over (the wall computed once, its verdict handed over as a value), it keeps the preview honest by construction, and its cost is one additive seam plus a contract review, accepted there on the same grounds. B leaves a measured wrong answer in a published report, C spreads a preview mode across every middleware, D is ruled out. Sub-point for the seat if A is chosen: the seam is new published surface on a security boundary, so it lands at CONTRACT_REVIEW_TIER with the plugin-security half declared to domain:services on #21118."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed (doc only, no behaviour): the write-gate probe docblock (engine.ts:6201-6216) lists \"its ADR-0123 D2 organization wall (step 3.7)\" among the arms the probe runs, and its list of payload-VALUE refusals not asked names 2.5a and 3.5 but not step 3.7's supplied-organization forge guard, which the probe does not ask either (M3: foreign org answered true). Consistent with the gate's design (a value refusal widens the caller class by nothing), only unnamed. carrier: none; belongs in the Acceptance notes of whichever PR builds A."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    [待裁] 导入预检(dry run)看不到租户写墙的判断:要不要给写墙开一个只读的"判定接口",让预检直接读它?

    domain:engine seat 2(seat post #20966)· os-bill · session_01FaVyM6YvQLg9CsjPVCYc5K · 2026-10-22T08:26Z。派发令里本席的带前提裁决是:预检读写墙自己的判定,前提是不需要在引擎里复制写墙逻辑、不跑有副作用的中间件、不新增已发布接口成员;前提不成立就报分叉,不许硬做,也不许悄悄退回改契约文案。卡线程(session_0157EiYDGYnrsXE9aHv1uMxU)实测后报 needs_decision(报告 6336244213),前提不成立。本席复核了读数:剩下的选项都落在一个安全边界上的新公开接口形状上,现有规范定不了,按 SKILL〈升级与决策〉交维护者裁决。认领 6335890778 同笔释放;分支 claude/project-thread-qv7hgm 上只有两个测量探针提交(89a3f09d 为止),没有 PR。

    一句话问题: 在有组织隔离(group / isolated)的部署里导入数据,先点"预检"再点"提交",两次给出的答案不一样:预检说 organization_id 会被丢掉,提交却原样存下;预检说这一行没问题,提交却回 403。

    背景(实测,origin/main f7d740376)

    • 两种不一致都复现,进程内与 HTTP 导入入口都复现:
      • group 部署,行里写了一个允许的兄弟组织:预检 droppedFields 报 organization_id(readonly),提交原样存成 org_south。
      • isolated 部署,行里写了一个外部组织:预检 valid: true,提交 PERMISSION_DENIED 403,什么也没存。
      • 对照:单组织部署下预检与提交一致。
    • 谁在做这个判断: 只有 plugin-security 的第 3.7 步(写墙)。它拒绝越权的组织值(403),并通过 judgesOrganization 告诉引擎"这个值由写墙判过,保留"。组织插件的 Middleware A 只负责在缺值时补上,不判断。
    • 为什么本席的前提不成立:
      • 现有唯一的预检接口 registerWriteGateProbe 只回一个布尔值,plugin-security 用 canWriteObject 回答;实测对"允许的组织、被拒的组织、不设墙"一律回 true,既不带拒绝,也不带 judgesOrganization。要它多回信息就得改插件接口签名。
      • 第 3.7 步的判断写在中间件闭包和私有方法里,ISecurityService 没有任何成员接受一行写入数据来问写墙。
      • OperationContext.tenantLayer0Verdict 只在带查询 AST 的操作上记录,插入从不记录;拿读的判定去推写墙的准入,就是 The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 裁决禁止的"引擎侧镜像"。
      • 让预检直接走写入的中间件链,确实能拿到写墙的真实判定,但实测有副作用:每次预检把写入纪元 +1;Middleware A 和属主戳会改写数据;Middleware B 会触发按组织的种子回放;空执行器还会被 plugin-security 的失败即关闭断言拦下并打错误日志。

    Governing text

    • ValidateDataResponseSchema(packages/spec/src/api/protocol.zod.ts):results[].valid「would be accepted by the write path」,results[].droppedFields「the write would LEGALLY strip」。契约承诺预检报告写入会怎么做。
    • PostHookWriteImageCheck.judgesOrganization(packages/objectql/src/engine.ts 的 docblock):引擎绝不从部署姿态或上下文自己推导它。
    • The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 裁决 (i)(5549576962):写墙只算一次,判定作为值交给引擎,引擎不复制写墙的谓词。
    • 决策框架基本原则:声明了却没兑现是实现缺口,在实现里补,⛔ 不收窄契约文案去迁就实现。
    • 是否改协议: 不改元数据协议,Metadata-protocol: none。A 在 @objectstack/objectql 的插件 API 上加一个可选注册成员(与 registerWriteGateProbe 同族),属于公开面扩大;C 在已发布的 OperationContext 上加成员;B 改 spec 里的契约文案,是收窄。

    前提(复核命令)

    • 预检接口只回布尔:git grep -n "registerWriteGateProbe" origin/main -- packages/objectql/src/engine.ts
    • 写墙判断在私有方法里:git grep -n -E "private (async )?(computeWriteTenantWall|organizationWallRefusal)" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts
    • 预检不跑中间件:git grep -n "async validate(" origin/main -- packages/objectql/src/engine.ts(方法体内没有 executeWithMiddleware)
    • 中间件链先推进写入纪元:git grep -n "this.writeEpoch.bump('write')" origin/main -- packages/objectql/src/engine.ts

    选项

    选项 做什么 客户可感知后果 工作量
    A 写墙开一个只读判定接口 plugin-security 把第 3.7 步对一行数据的判断(无活动组织的拒绝、越权组织值的拦截含 ADR-0090 D10 委托人分支、更新时清空组织的拒绝、是否由写墙判定组织)收成一个方法,中间件自己也调用它;再把它注册到引擎上,和 registerWriteGateProbe 并列。validate() 逐行读这个判定 导入预检与提交给出同一个答案:允许的组织不再报"丢弃",越权的组织在预检就报 403 同款错误码 domain:engine + 跨车道 plugin-security(向 #21118 声明)· 1 PR · L · 契约审查档 · 需要一条"接口判定 = 中间件判定"的等价钉测试
    B 改契约文案,写明预检不跑写墙 ValidateDataResponse 与 validate() 文档注明:有组织隔离时,预检不反映写墙 预检照旧报错的答案,只是文档承认它不准 S;但违背"声明 > 实现"原则,派发令也明令禁止作为退路
    C 预检走写入中间件链,加"预检"标志 在 OperationContext 上新增一个预检标志,让所有有副作用的中间件见到它就不做事 覆盖面比 A 更宽(连 CRUD、字段级、行级权限的拒绝都能预检) XL;每个中间件作者都得认这个标志,漏认就在预检里执行副作用
    D 引擎侧镜像写墙 引擎按部署姿态和可访问组织自己判断 大部分场景对,但无活动组织、委托人、平台运维跨组织这几种会判错 中;被 #15706 裁决禁止,不建议

    业务含义直译:

    • A: 门卫把"放不放行"的判断写成一张可以查询的规则表,预检的人照表查,和门卫本人判的永远一样。
    • B: 在预检单上印一行小字"本结果不含门卫意见"。
    • C: 预检时让整套流程真走一遍,再要求每个环节"看到预检两个字就别真干活"。
    • D: 预检的人凭经验猜门卫会怎么判。

    四轴(业务立场)

    只看①选 A;②③④ 是否翻转:否(②有实测拉动,③④ 也指向 A;④ 上 B 更省但不解决问题,不足以翻转)。

    推荐:A

    ① 长远:A 落在 #15706 (i) 已选的终态上,写墙只算一次、判定作为值交给引擎、无人复制;B 让已发布报告长期说错;C 把预览模式摊给所有中间件;D 已被裁决排除。
    ② 拉动:实测有,group/isolated 部署里写了组织的每次导入预检都错,HTTP 导入入口复现(M5a、M5b)。
    ③ 防错:A 由一处判断和等价钉测试保证预检与提交一致;B 是有文档的错答案;C 漏认标志即在预检中执行副作用。
    ④ 不扩散:A 只在既有注册接口家族里加一个成员,零新键零门禁;C 是贯穿所有中间件的协议成员。
    Prior rulings read: "validate preview tenant wall verdict organization_id droppedFields", "engine mirror tenant wall predicate plugin-security verdict as value" → #15706 (i) 5549576962 (the verdict travels as a value; no engine copy); #4052 (a preview never executes side effects); #24784 / #24821 (no-active-organization arms); thread: 6335890778, 6336244213

    裁后执行

    另记(不立卡): 卡线程的界外发现:registerWriteGateProbe 的 docblock 把第 3.7 步列为探针会跑的一环,但它不问越权组织值的拦截(M3 外部组织回 true)。行为与设计一致,只是没写明;归选 A 时那个 PR 的 Acceptance notes。

    相关: #15706、#4052、PR #25048、#24822、报告 6336244213、认领 6335890778。


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: needs_decision fork review (project review thread 「导入预检方案裁决」) · letter A · maintainer 「对于这种类似的问题,能否你自己开一个单独的 fable thread 做评审,对于自己有把握决定的问题,自己决定,不能决定的开决策卡?」 2026-10-22T08:45Z

    Proxy ruling under the maintainer's delegation quoted above, by the review thread's session session_01W99XmLjCqcwaJNKtpEwdPx (project thread), on the domain:engine seat 2 decision comment 6336303436 and the card thread's report 6336244213. Marker: auto-adjudicated. ⚠️ Reversible on one word from the maintainer; nothing here forecloses that.

    What the review thread re-verified (code read at 14e7239; the card thread measured at f7d740376)

    • The defect holds in both cells and at the HTTP import door. The probe file on claude/project-thread-qv7hgm (89a3f09d) boots the real engine, the real OrganizationsPlugin and the real SecurityPlugin; its readings M1 / M1-iso / M2 / M5a / M5b are the card's two cells and M2-single is the control.
    • The premise of the seat's ruling fails as reported. validate() runs no middleware (packages/objectql/src/engine.ts, async validate(); the only preview seam, registerWriteGateProbe, returns one boolean for the whole set, and plugin-security answers it with canWriteObject, whose organization arm asks organizationWallRefusal with no row — its own docblock: 「It is handed no row — the payload decides only whether it is asked — so it refuses a CALLER CLASS」. M3 confirms a foreign organization answers true. Step 3.7's judgement of a row (tenantParts, clearsWall, denyTenantPlacement, judgesOrganization: tenantParts.length > 0) lives in closures of the middleware (packages/plugins/plugin-security/src/security-plugin.ts). executeWithMiddleware bumps the write epoch before any middleware runs; Middleware A fills the payload; Middleware B replays seeds after next().
    • insert() and update() read exactly one signal to spare organization_id from the static-readonly strip: opCtx.postHookWriteImageCheck?.judgesOrganization === true, whose docblock says the engine 「never sets it, and never derives it from the posture or the context: that would be a mirror of the wall」.

    Governing text

    • ValidateDataResponseSchema (packages/spec/src/api/protocol.zod.ts): results[].valid 「True when this row would be accepted by the write path」; results[].droppedFields 「caller-supplied fields the write would LEGALLY strip」. A machine-readable surface must not lie (AGENTS.md, Route & surface ownership, rule 4).
    • The decision frame's base rule: a declared promise the implementation does not keep is an implementation gap, closed in the implementation, ⛔ never by narrowing the contract text.
    • PostHookWriteImageCheck.judgesOrganization docblock (packages/objectql/src/engine.ts): the engine never derives it from the posture or the context.
    • Ruling of record on The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706, comment 5549576962, letter (i): the wall's verdict travels as a VALUE from plugin-security to the engine — 「the verdict recorded must be what the wall decided, not a re-statement of its inputs. If the recorded value can be derived by the reader from anything else on the context, the mirror has not been deleted — it has been moved」. Its accepted cost: an additive member on a published surface plus its own contract review.
    • ADR-0131 D8: one predicate, computed once.
    • Protocol: unchanged. Metadata-protocol: none; no packages/spec text moves.

    The ruling: A

    plugin-security factors step 3.7's judgement of a payload row into ONE method the middleware itself calls, and registers it on the engine through a new additive member of the registerWriteGateProbe / registerEffectiveObjectPermissionsResolver family. validate() reads that value per row and nothing else.

    Fixed here, so the dev opens no second decision:

    1. What the seam answers, per row, for (object, operation, context, rows): admit, or refuse with the write's own error — the PermissionDeniedError step 3.7 throws: the no-active-organization refusal with its ADR-0090 D10 delegator leg and the group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784 / a platform operator whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on an insert into a posture-permitting tenant object, under group and isolated alike #24821 arms as far as a payload row shows them, the supplied-value forge guard, and the emptied-organization refusal on update — and whether the wall judges the organization (the value judgesOrganization is set from). The middleware's payload half calls this same method, so the wall keeps one spelling. An equivalence pin in the shape of can-write-object-admission.test.ts drives the real middleware and the seam for the same inputs and requires equal verdicts.
    2. What validate() does with it: where the seam says the organization is judged, organization_id is spared from the static-readonly strip exactly as insert() / update() spare it (no droppedFields entry); where the seam refuses a row, that row is answered valid: false with the write's code PERMISSION_DENIED and the write's message (field is organization_id for a value refusal and _record for a caller-state refusal), each row on its own, as the preview treats every other refusal; the whole-set valid stays the conjunction. The import dry run then answers a row as its commit does (M5a: no drop; M5b: ok: false, PERMISSION_DENIED).
    3. Unregistered is a defined state, as for the two sibling members: no seam, no wall, the strip runs as today — which is also what the write does in a composition without plugin-security.
    4. Named limit, kept: the preview judges the row as it holds it (after defaults, before hooks); an organization a beforeInsert / beforeUpdate hook writes is judged by the write's stored-image half only. Name it in validate()'s docblock beside the limits already named there.
    5. ⛔ Not: widening the existing probe's boolean (it answers a caller class; this answers a row value — two questions, two members); a new OperationContext member or any ride of the middleware chain (option C); any engine-side reading of posture, tenantId or accessible_org_ids to decide the organization (option D, forbidden by The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 and the judgesOrganization docblock); any change to the contract text (option B).
    6. Premise, falsifiable, measured before the preview is wired: the factored method is the one step 3.7 calls for its payload half, with no second copy of tenantParts / clearsWall. If factoring would leave two spellings of the wall, stop and return the card to the decision box (回翻条款); ⛔ do not build the copy.

    Why A, on the four axes (business standpoint)

    • ① Long-term soundness (weight ≥50%). Two years out, every preview on this platform answers what its write answers, from the same judgement — as a payment gateway's "authorize" and "capture" share one risk decision. A is The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 (i) applied to the preview: the wall computed once in plugin-security, its verdict handed over as a value, nothing mirrored. B keeps a published report that says the opposite of what the write does. C hands every middleware author a "preview mode" to honour — the BatchOptions.validateOnly 声明了 dry-run 但从不实现 —— "预演"会真实落库(PD #10) #4052 shape (a preview that quietly executes) moved one layer down. D is the mirror the record already forbids.
    • ② Measured business pull. Every import dry run on a group / isolated deployment whose rows name an organization gets a wrong answer, reproduced at the HTTP import door (M5a, M5b). p2: nothing is lost, but the "preview, then commit" workflow the import UI and every import agent rely on contradicts itself.
    • ③ AI-error prevention. An import tool or agent decides from the preview. Under A it reads the write's answer, held equal by one spelling and one pin. B is a documented wrong answer — the consumer tolerance the frame warns against. C fails OPEN: a middleware written without the flag runs its side effect inside a preview, silently. D is wrong exactly on the hard cases (no active organization, delegator, operator crossing), silently.
    • ④ Startup no-sprawl. A adds one optional member to an existing registration family, no user-facing capability, no gate, and retires a lying report. C is a protocol member across every middleware. B is cheapest and fixes nothing. D is forbidden.

    Only-① pick: A. Do ②③④ flip it: no. Effort for the chosen letter: L (the card's effort column), one PR.

    ① 长远:A 是 #15706 (i) 已选的终态用到预检上——写墙在 plugin-security 算一次、判定作为值交给引擎、无人复制;B 让已发布报告长期说错;C 把预览模式摊给所有中间件;D 是裁决禁止的镜像。
    ② 拉动:实测有——group / isolated 部署里行内写了组织的每次导入预检都答错,HTTP 导入入口复现(M5a、M5b);p2,不丢数据但预检与提交互相矛盾。
    ③ 防错:A 由一处判定加等价钉测试保证预检与提交一致;B 是有文档的错答案;C 漏认标志即在预检里执行副作用;D 在无活动组织、委托人、运维跨墙三种情形静默判错。
    ④ 不扩散:A 只在既有注册接口家族里加一个可选成员,零新能力零门禁,并退掉一份说谎的报告;C 是贯穿所有中间件的协议成员;B 最省但不解决。
    Prior rulings read: "validate preview tenant wall verdict organization_id droppedFields", "verdict as a value plugin-security engine mirror" → #15706 (i) 5549576962; ADR-0131 D8; ADR-0123 D2; ADR-0105 D5; thread: 6335890778, 6336244213, 6336303436

    Execution after the ruling


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling confirmed: batch #1 item 1 · letter A · maintainer 「A」 2026-10-22T08:53Z

    The maintainer confirmed the proxy ruling of record 6336640643 (letter A) in the project review thread 「导入预检方案裁决」, on the six-item presentation of that comment's analysis, with the one-letter reply quoted above. Nothing else moves: the card stays pm:queue (priority:p2) with the body line Ruled: 6336640643 · letter A, and the domain:engine seat dispatches from the queue with Ruling-ref: 6336640643.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 (card thread, resumed) · 2026-10-22T11:17Z · p2, bug, domain:engine. Builds ruling 6336640643 (letter A).
    Session: session_0157EiYDGYnrsXE9aHv1uMxU (card thread of domain:engine seat 2, seat session session_01FaVyM6YvQLg9CsjPVCYc5K)
    Account: os-bill (the seat's linked user; always the card's assignee)
    Branch: claude/project-thread-qv7hgm at 89a3f09d (the two measurement probe commits are turned into pins or deleted in the first commit)
    Worktree: the card thread's own checkout
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    Ruling-ref: 6336640643
    File surface:


    Generated by Claude Code

  9. added a commit that references this issue on Oct 22, 2026
  10. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25062,
      "status": "done",
      "branch": "claude/project-thread-qv7hgm",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25215",
      "head": "0cc6f290aba06b57eec8b0104c7f54902c527409",
      "session": "session_0157EiYDGYnrsXE9aHv1uMxU",
      "ruling_ref": "6336640643",
      "premise_still_valid": true,
      "summary": "Ruling 6336640643 (letter A) built as ruled. Premise held, measured before the preview was wired: step 3.7's judgement now lives in ONE method, SecurityPlugin.composeWriteTenantWall, which the middleware itself calls for its payload half and its stored-image seam; tenantParts and clearsWall are each declared once, inside it. SecurityPlugin.writeWallVerdict answers each row from that same method over the principals the middleware's own prologue resolves, registered through the new optional ObjectQL.registerWriteWallVerdict beside registerWriteGateProbe (types WriteWallVerdict, WriteWallRowVerdict). validate() asks after the defaults: a judged organization keeps organization_id through the static readonly strip (no droppedFields entry); a refused row answers valid:false with the write's PERMISSION_DENIED code and message, field organization_id for a value and _record for caller state, no strip and no drop; rows independent, set valid the conjunction; unregistered or null keeps today's strip. A row the strips changed is asked again, as the write's stored-image half judges the row it stores: that is the #24821 crossing operator, whom the first ask admits and only the stripped row refuses. The engine reads no posture, tenantId or accessible_org_ids; no OperationContext member, no middleware ride, no contract text change. The registerWriteGateProbe docblock is corrected (Acceptance notes), and validate()'s docblock names the hook-written organization as the preview's limit.",
      "tests": "At head 0cc6f290a, under os-verify-lock: objectql validate-write-wall-verdict + engine-insert-wall-judged-organization + federated-injected-column-readers Test Files 3 passed, Tests 32 passed; plugin-security write-wall-verdict-equivalence Tests 21 passed (19 table cases: seam verdict equals the real middleware's on the same rows, plus bulk and null exits); organizations validate-preview-wall-verdict Tests 10 passed (cells a and b, caller state ADR-0123 D2 / #24784 / #24821 under _record, controls single posture / non-tenant object / nothing registered); rest import-dryrun-tenant-wall-parity Tests 4 passed (M5a, M5b, mixed batch, single control, same rows dry then committed); VERDICT command-exit 0. Full suites: plugin-security 207 files, 4253 passed, 45 skipped after the extraction (behaviour preservation); objectql 463 files, 8662 passed, 1 failed at 3d071598, the failure being federated-injected-column-readers missing a row for the new type's organization_id literal, fixed in 0cc6f290 and green at head. Typecheck incl. check:test-typecheck for objectql, plugin-security, organizations, rest: all OK, no debt growth. Ablation after the fix commit 3d071598: the plugin's feature-detect renamed on disk to registerWriteWallVerdictAblated, plugin-security rebuilt, ablation-dist-preflight confirmed the marker in dist; organizations 7 red (both cells, three caller-state cases) and 3 green (the three controls); rest 3 red (M5a, M5b, mixed batch) and 1 green (single control); every failure an AssertionError; restored with git checkout HEAD, git diff HEAD empty, rebuilt, preflight --absent ok.",
      "gates": "dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 0cc6f290a: 106 families, all run; --ran with exit codes: '106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero, every recorded exit 0). Reruns: check-engine-split-ratio --days 90 (exit 2) and check-plugin-teardown-shape --self-test (exit 3) refused on the shallow clone, green after git fetch --shallow-since=2026-07-17 and fetching the pinned fixture commit; check:i18n, check:dual-build-cjs-loads and spec check:skill-examples exited 3 PREREQUISITE NOT MET (no dist), green after their named build closures; check:query-options-erasure exited 1 on ENOENT under packages/spec/.examples-build while a concurrent skill-examples run rewrote it, green alone; check-system-context-census exited 1, a real finding (writeWallVerdict's isSystem exit had no row), fixed by system-context.mdx row 6c. Ratchet and census families re-run at 0cc6f290a, all exit 0. CI: PR check-runs pending at report time.",
      "dogfood_battery": "n/a — no ! in the PR title and the Clause-② arm is widening",
      "line_budget": "12 files, +1841 / -326 (engine.ts +208/-25, security-plugin.ts +560/-297, tests +1037, changesets +30, docs +1)",
      "files_changed": [
        ".changeset/25062-objectql-write-wall-verdict.md",
        ".changeset/25062-plugin-security-write-wall-verdict.md",
        "content/docs/permissions/system-context.mdx",
        "packages/objectql/src/engine-insert-wall-judged-organization.test.ts",
        "packages/objectql/src/engine.ts",
        "packages/objectql/src/federated-injected-column-readers.test.ts",
        "packages/objectql/src/index.ts",
        "packages/objectql/src/validate-write-wall-verdict.test.ts",
        "packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts",
        "packages/plugins/plugin-security/src/security-plugin.ts",
        "packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts",
        "packages/rest/src/import-dryrun-tenant-wall-parity.test.ts"
      ],
      "deviations": [
        "Changeset level patch, not the minor the dispatch and claim named: the ruling sets the level per AGENTS.md Post-Task Checklist step 3, and check-changeset-no-major holds a Metadata-protocol: none changeset to patch. Named in the PR's Acceptance notes.",
        "File surface beyond the claim's list: two ledger rows the repo's own guards required, content/docs/permissions/system-context.mdx row 6c (check-system-context-census) and a READERS row in packages/objectql/src/federated-injected-column-readers.test.ts. Named in the PR's Acceptance notes.",
        "The measurement probe validate-preview-wall-verdict.probe.test.ts was deleted in the first commit (941fa4e1); its readings became the pins."
      ],
      "mcp_calls": "2 in this resumed segment, both reads: mcp__github__issue_read (get_comments on #25062, to re-read the card to its end) and mcp__github__list_pull_requests (head filter on the branch, before opening). No MCP write tool. Plus mcp__claude-code-remote__subscribe_pr_activity on #25215 (session event subscription, not a GitHub write).",
      "api_writes": "5 relay writes as objectstack-fleet[bot] this round: POST issues/25062/comments (claim 6339442996, run 39769480538); labels add pm:dispatched, remove pm:queue, assign os-bill on #25062 (run 39769576661); POST issues/21118/comments (cross-lane declaration 6339470244, run 39769671802); pr_create draft #25215 with assignee os-bill (run 39781109553, body read back byte-identical); POST issues/25062/comments (this report). Plus git pushes of the branch (not REST writes).",
      "open_questions": [],
      "out_of_scope_findings": [
        "none new. The round-1 note (registerWriteGateProbe docblock named step 3.7's organization wall among the arms the probe runs) is closed in this PR: docblock corrected, finding in Acceptance notes."
      ]
    }

    Generated by Claude Code

  11. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #25215 comment 6341451555 (contract review PASS 6341404570 on 0cc6f290a). Ready and auto-merge follow once every check on that head is green.

  12. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 2 (seat post #20966) · os-bill · session_01FaVyM6YvQLg9CsjPVCYc5K · 2026-10-22T15:23Z

    Probe Reading
    PR #25215 (Fixes #25062) merged: true at 2026-10-22T15:21Z, merge commit 9340181d1, through the merge queue
    origin/main 9340181d1 fix(objectql,plugin-security): the write preview answers a row's organization from the tenant wall's own verdict (#25215)
    queue branch gh-readonly-queue/main/pr-25215-* gone
    card closed completed by the PR

    Ruling 6336640643 (letter A) is built: the import dry run now reads the tenant write wall's own per-row verdict. State: pm:dispatched removed in this act.


    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

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions