Skip to content

Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234

Closed

Description

@objectstack-fleet

Ruled: 6355640929 · letter A · 2026-10-23T02:04:08Z

Filing gate ②: from the maintainer's ruling on objectstack-ai/cloud#3149 (batch #386 item 5, item 2). Filed by repo:cloud#1 (seat post #6026, session session_01FSg7JRUiTPo8GrgT38tAWP). ⛔ Not a claim. A spec design card: it adds protocol, so the design is put to the maintainer before any implementation starts (ruled A in 6355640929; implementation may be dispatched).

Reader who acts: the objectstack lane. objectstack-ai/cloud#3149 (the tracker) is Blocked-by: this card.

The gap (cloud#3149 item 2)

  • rls-membership-resolver (ADR-0105 D11) can express supplier in current_user.portal_supplier_ids, but only through code.
  • Installed package code does not run on hosted (docs/design/installed-package-handler-functions.md in cloud).
  • hotsrm (docs: core SRM design, industry packs, ADRs; governance templates and generator hotsrm#1) therefore uses one position per supplier, with RLS portal_position in current_user.positions. Every supplier-facing record must carry a stamped portal_position, and one missed stamp is an over-grant.

The ask

  • A declarative membership key in metadata, as the metadata twin of ADR-0105 D11: current_user.<key> = the values of lookup field F on records of object X whose user field U equals the current user.
  • The design round names: the declaration's home (object or app), the closed shape (X, F, U, an optional filter), how it is cached per request, how a cycle or an unbounded set is refused at publish, and how it shows in the security explainers.

Acceptance (for the design round)

A design comment the maintainer rules on, with the zod shape, the refusal list, one worked example (hotsrm's supplier contact), and the resolver path it reuses.

Activity

  1. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 9
    Session: session_01EaD2f12DArwjgwXWtzWJAh (the card thread; seat session session_01B2EeooonSXrANpkpgUSfFZ posts this claim)
    Account: os-bill
    Branch: claude/project-thread-xnrc8p (the card thread's designated push branch; a design round, so no push is planned; claude/issue-N-slug is the convention, and this is the measured platform exception the pm-dispatch runbook records)
    Worktree: none (reading only)
    Domain: domain:spec
    Seat: domain:spec#2 (seat post #18549)
    File surface: none. Design round: one design comment on this card for the maintainer to rule on, plus one os-dev-report (needs_decision). No code, no PR (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: default (dispatch-gates --tier: no path-derived mandate for a reading-only round), effort: xhigh (design and four-axis analysis); the seat runs a CONTRACT_REVIEW_TIER review of the design before it goes to the maintainer
    Clause-②: yes (the card adds protocol by its own words; the design states the measured widening, and the implementation card that follows a ruling re-measures it)
    Responsibility: n/a — not a defect card
    Thread-read: none
    Serial constraints cleared: none. Sibling #25233 (the external role, in flight) shares the RLS area but not a file; the design names any interaction.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    维护者速读

    问题(客户看得见的样子): 供应商门户上线后,一个供应商的联系人只该看到自己供应商的采购单、到货通知和发票。今天在托管环境里只有一个办法:一个供应商建一个"门户岗位",每条面向供应商的单据都打上这个岗位标签;打错一条就给错人看,漏打一条就看不到。平台其实有"每个请求先算出一个集合、再用它过滤"的接口(ADR-0105 D11),但只能写代码注册,而托管环境不运行安装包里的代码。

    我建议 A: 在"供应商联系人"这类名单对象上加一条声明:"这张表里用户列是我的那些行,取供应商列的值,叫作 portal_supplier_ids"。平台每个请求按需查一次,权限规则直接写 supplier in current_user.portal_supplier_ids。不再打标签;联系人停用,下一个请求就看不到。声明写错(列不是用户或不是引用、过滤式写不下去、键名重复或与内核键撞名、拿去比较的列指向别的对象)在发布时直接拒收。

    代价: 协议新增一个对象可选键;实现约 3 个 PR(同形先例 defaultRowScope 三件套:#24398 +549/−83、#24451 +788/−102、#24511 +590/−133),另加 ADR-0105 D11 一条注记(受管面,需您合并)。

    另外两个字母: B 不加协议,用现有的"共享规则按记录上的用户列共享 + 主从继承"拼:门户用户要提交子单据就得有改供应商主档的权限,不挂在供应商下面的单据仍要各自挂一份名单(还是标签)。C 维持现状。

    请回一个字母:A / B / C。 A 里两个子项默认按推荐(声明放在名单对象上;每人每个键最多 1,000 个值,超出按拒绝处理);要改请写成「A,落点=app」这样。


    Readings taken 2026-10-22T15:16Z: main at 3e07389, HotCRM at c8a7a70, hotsrm at d3ed2e5. The design is in English; the four-axis section and the facets block are in Chinese (the 中文四通道 rule).

    Background

    objectstack-ai/cloud#3149 item 2, which the maintainer ruled in (「3149 1 2 5 且第 1 项加 external 角色」, quoted verbatim in #25233), filed as this card. The sibling #25233 (the external organisation role) removes the member-directory leak; this card removes the per-supplier stamp. The two are complementary; their one dependency is under premise 5.

    Governing text

    • ADR-0105 D11: "ExecutionContext.rlsMembership: productize the seam — a registered membership-resolver extension point (service contract) that plugins/apps implement; resolved sets merge under their declared keys". D11 names no metadata path. This card adds the metadata twin and reverses nothing, so the record is a dated note on D11, not a new ADR.
    • packages/spec/src/contracts/rls-membership-resolver.ts: fail closed by construction; RESERVED_RLS_MEMBERSHIP_KEYS = id, organization_id, positions, org_user_ids, accessible_org_ids, email.
    • ADR-0055 (controlled_by_parent), Decision paragraph: the platform already derives a membership set from metadata internally, "reusing the engine's existing pre-resolved-membership mechanism (ExecutionContext.rlsMembership + the compiler's field IN (current_user.KEY) form), with zero compiler changes" (the original's angle-bracket placeholder respelled KEY).
    • AGENTS.md Prime Directive Add comprehensive test suite for Zod schema validation #12 (contract-first: reject at authoring and publish), ADR-0078 (no silently inert metadata), ADR-0049 (enforce or remove).
    • ADR-0099 D2/D3: an EXTERNAL-rung principal gets explicit shares only; Layer 1 is not evaluated for it.
    • ShareRecipientType (packages/spec/src/security/sharing.zod.ts, ruling B on Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103): a field recipient and no manager member, because a graph walk needs re-materialisation that rides no record write.

    Protocol: this changes protocol. Metadata-protocol: added (one optional ObjectSchema key). Clause-②: yes (widening). No existing metadata changes meaning.

    Premises, each with its re-check

    1. The seam is code-only. SecurityPlugin.start probes one kernel service, ctx.getService(RLS_MEMBERSHIP_RESOLVER_SERVICE) (packages/plugins/plugin-security/src/security-plugin.ts:2013). stageRlsMembership (:8387) calls it once per request context, from two sites: the read side before Layer 1 compiles (:7842) and the write check (:8322). The compiler merges the bag and refuses reserved names (rls-compiler.ts:985). Nothing reads metadata to produce a key.
      Re-check: grep -rn "RLS_MEMBERSHIP_RESOLVER_SERVICE\|'rls-membership-resolver'" packages --include=*.ts | grep -v '\.test\.' gives 4 lines in 2 files (the constant and the probe) and 0 registrants; 6 test files register one. grep -rn membershipKeys packages/spec/src gives 0.

    2. Hosted runs no installed package code. The card's citation (docs/design/installed-package-handler-functions.md in cloud) is NOT MEASURED: cloud is private and answers 403 from this container. This repo corroborates it: ADR-0025's status line reads "no runtime path on which a distributed plugin's code executes — an environment artifact carries sys_package_version.manifest_json and never the blob". Re-check: sed -n 3p docs/adr/0025-plugin-package-distribution.md.

    3. Who would use it.

      Source Reading
      hotsrm One commit (d3ed2e5), a README only. hotsrm#1 is not readable from this container (403). The pull is the maintainer's ruling on cloud#3149 item 2.
      HotCRM c8a7a70 0 membership keys. current_user.* uses: can 18 (a CEL function in app visibility, not RLS), id 10, positions 2, organization 1. No roster object: src/docs/crm_admin.md says "there is no account-team roster object", and territory and account-team access are criteria sharing rules to positions.
      examples/ and packages/qa in this repo 0 non-kernel keys (email 19, id 17, positions 5, organization_id 4).
      Code resolvers 0 production registrants (premise 1).
      Spec prose rls.zod.ts examples say "the runtime stages territory_account_ids" and "the runtime pre-resolves team_member_ids"; nothing stages either.

      Re-check: grep -rhoE "current_user\.[a-z_]+" --include=*.ts SRC | sort | uniq -c in each tree. Result: one named consumer, pre-code, with a ruling; zero use elsewhere, partly because hosted has no path at all.

    4. What existing surfaces cover.

      • The six kernel keys; none means "records linked to me through an app object".
      • Positions: hotsrm's current design, one position per supplier plus a stamped portal_position on every supplier-facing record.
      • Criteria sharing rules with a field recipient: each matched record is shared with the users in a user column on that record (materialised sys_record_share). This covers a record that carries its own user list. It does not derive the list from a roster.
      • controlled_by_parent: a detail inherits its master's read scope, and creating, editing or deleting a detail needs EDIT on the master (ADR-0055 §3).
      • RLS only narrows. On a private object the sharing layer is AND-ed on top (security-plugin.ts:6045, analysed on Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103), so an RLS key, this one included, narrows public_read / public_read_write objects and cannot widen a private one. hotsrm's position design has the same shape. Re-check: grep -n "andComposeLayers(andComposeLayers" packages/plugins/plugin-security/src/security-plugin.ts.
    5. The Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233 dependency. Under ADR-0099 D3 an EXTERNAL-rung principal sees explicit shares only and Layer 1 is not evaluated. Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233's ruling names permission sets and sharing rules as the external member's channels, and its claim surface is the default sets, the identity row scopes and the auth role, not the posture ladder. So I read it as MEMBER posture with a narrowed baseline. This design depends on that reading: if external is ever mapped to the EXTERNAL rung, policies reading a membership key do not apply to those members (fail closed; the portal shows nothing).

    The question

    A supplier's portal contacts must see only their own supplier's documents, and on hosted the only way today is a stamp on every document. Which platform shape replaces the stamp?

    Options

    What What the customer sees Workload
    A ObjectSchema.membershipKeys: a declared key on the roster object, resolved per request by plugin-security beside the D11 resolver Access follows the roster row; a revoked contact loses access on the next request; no stamps; a wrong declaration is refused at publish spec + plugin-security + lint, 3 PRs. Precedent, the same three-part shape: #24398 (+549/−83), #24451 (+788/−102), #24511 (+590/−133), about +1,927/−318. Plus a dated ADR-0105 D11 note (docs-only, Tier H; no precedent measured)
    B No new protocol. A recipe from existing parts: a user list on each supplier plus a field sharing rule; supplier-facing objects as controlled_by_parent children Works for documents under the supplier. A portal user who submits a child document needs edit on the supplier record. A document not under the supplier (an RFQ invitation under an RFQ) needs its own user list, which is a stamp again. The list copies the roster, and a hook keeps it in sync 0 platform PRs (one docs recipe, no precedent measured); the work moves to hotsrm
    C Status quo One position per supplier, a stamp on every document 0

    Business meaning:

    • A is the Salesforce Sharing Set or the Power Pages "Contact / Account scope": what you can see follows whose contact you are.
    • B is assembled from parts. It works for a tree, leaks edit rights upward, and puts stamps back sideways.
    • C makes every supplier onboarding an admin job (create a position, assign it) and every document a stamp.

    The design of A

    Home: the roster object (sub-fork 1)

    Home Reading
    Object X, the roster (recommended) The key is a fact about X's rows. U and F are X's own fields, so every check is local to one merged object and no cross-object reference needs resolving. Object-level security declarations already live there (defaultRowScope, sharingModel: controlled_by_parent), read by the same plugin. Cross-package reach is closed by construction: ObjectExtensionSchema is closed (it merges fields, validations, indexes and labels only, per the stack.zod.ts docblock), so another package cannot attach a key to X. The package that owns the roster owns the key.
    App RLS is not app-scoped: a request to /data/srm_purchase_order carries no app, and current_user.* is one request-wide namespace. The App schema is navigation (ADR-0019) and holds no row-security declaration today. It would also name X, U and F from a distance, which adds cross-object references to check.
    Its own metadata type The cleanest list, but a new metadata type (registry row, overlay, REST, MCP, Studio) is the largest surface, and cross-package reach would then need its own refusal.
    Permission set Excluded. ADR-0105 D11 removed PermissionSet.contextVariables for zero consumers, and a key is request-wide, not per set: two sets defining one key differently would conflict.

    The closed shape

    // packages/spec/src/security/membership-key.zod.ts (new), exported from @objectstack/spec/security
    export const MembershipKeySchema = lazySchema(() => strictObject(
      {
        surface: 'this membership key',
        aliases: { user: 'userField', value: 'valueField', field: 'valueField', where: 'filter', condition: 'filter' },
      },
      {
        // U: the roster row's user. A single-valued user reference on this object.
        userField: SnakeCaseIdentifierSchema,
        // F: the column whose values form the set. A reference field on this object:
        // lookup, master_detail or user; multiple: true allowed.
        valueField: SnakeCaseIdentifierSchema,
        // Optional narrowing over this object's own columns, canonical CEL in the RLS
        // lowerable subset (the same grammar as rowLevelSecurity[].using). Reads no current_user value.
        filter: z.string().optional(),
        label: I18nLabelSchema.optional(),
        description: z.string().optional(),
      },
    ));
    
    // ObjectSchema, beside defaultRowScope. Record-keyed, so one object cannot declare a key twice.
    // MembershipKeyNameSchema = SnakeCaseIdentifierSchema, refined against RESERVED_RLS_MEMBERSHIP_KEYS
    // (read from the contract, not copied).
    membershipKeys: z.record(MembershipKeyNameSchema, MembershipKeySchema).optional(),

    Meaning: current_user.KEY is the distinct non-null values of F on the rows of X where U equals the caller's user id and filter holds, inside the caller's Layer 0 wall for X. X is implied by the declaring object, as defaultRowScope implies its object.

    Settled inside the shape, not forks:

    • U is single-valued in this slice. "U contains me" on a multi-user column needs an array-contains read in every driver; it widens on a named need.
    • F may be multi-valued; its values are flattened and de-duplicated.
    • No chaining: filter reads no current_user.*, so a key cannot be built from another key.
    • No authorable size: the bound is a platform constant (sub-fork 2).

    The resolver path it reuses (ADR-0105 D11)

    stageRlsMembership stays the one producer of context.rlsMembership, called from the same two sites, so a using and its check twin resolve the same keys. It gains a second source beside the registered resolver:

    1. Which keys. From the policies collectRLSPolicies returns for this object and operation, take the current_user.* names their predicates read (parsed once per policy by the compiler's own parser and cached).
    2. One read per key. For each name that is a declared key and not yet staged on this context: read X where U == context.userId and filter holds, projected to F, limited to CAP + 1, as system context with the caller's Layer 0 wall for X AND-ed in. That is the layer0 half of the derivation the read door already uses, taken without re-entering staging, and no Layer 1.
    3. Stage. Store the values; store [] when no row matches. On overflow or a read failure, leave the key unresolved. Every one of these fails closed through the existing path: the policy drops with its existing WARN, and explain answers denies.
    4. The code resolver is called as today, behind the same reserved-name screen. One namespace: a key both sources claim resolves from neither, and the boot prints one error naming both owners. Publish refuses the collision whenever the resolver is already registered (refusal 4).

    Why a system read. The caller need not read X (a portal contact may not see the roster), and X's own RLS can read the key it feeds: reading X under the caller's RLS is exactly the cycle. Layer 0 stays, so a roster row in another organisation never counts. The compiler's merge, its reserved-name refusal and the ExecutionContext.rlsMembership shape are unchanged.

    Per-request caching

    • Per request, per key. The memo is the execution-context object, the carrier D11 already uses ("the same context is reused across every object touched by one request"). It becomes per key instead of the single __rlsMembershipStaged flag, so a request that touches five objects reading one key issues one query.
    • Lazy. A key no applicable policy reads is never resolved; a request that meets none costs nothing.
    • No cross-request cache. The grants cache (OS_AUTHZ_GRANTS_CACHE_TTL_MS, default 0) is invalidated by writes to a fixed set of identity objects (sys_member, sys_user_position, sys_user_permission_set, sys_user and others; packages/core/src/security/resolve-user-grants-cache.ts:118). A roster is an app object outside that set, so a cached key would keep a revoked contact in for the TTL. Explain gets the same fresh read.
    • Cost. One read per referenced key per request, the shape resolveAuthzContext already pays for org_user_ids. The implementation card states whether X's declared indexes cover U; no new authoring requirement is proposed.

    The size bound (sub-fork 2)

    Option Reading
    Cap of 1,000 values per key per request; overflow fails closed (recommended) A cost ceiling like CBP_MAX_CHAIN_DEPTH in security-plugin.ts, not a semantic rule, and like it the value is chosen, not derived: a roster a person holds (contact of a few suppliers, manager of tens of reports) never meets it. A key declared over a non-roster object ("every order I created") fails loudly instead of growing every query's IN list with the data. Not authorable; raised only by a ruling.
    No cap Parity with controlled_by_parent master sets and org_user_ids, which carry none. An over-broad declaration then degrades in silence as data grows.

    The refusal list at publish

    Where it is judged: the schema at every door, and the authoring rules in the one registry that os validate, os build, os lint and the runtime publish gate run. The new rules register for object writes as well as permission writes. Today the RLS family runs on permission writes only (#24511's acceptance note), and hosted authoring is the door that matters here.

    # Refused Judged by
    1 An unknown member in a declaration; a key name that is not snake_case strict schema
    2 A key named id, organization_id, positions, org_user_ids, accessible_org_ids or email schema, reading the reserved list
    3 One key declared on two objects, two packages included defineStack / composeStacks cross-reference; the runtime gate against the registry
    4 A key the registered code resolver also owns runtime gate (it holds the resolver's keys)
    5 U missing on the merged object, not a user reference (user, or lookup / master_detail to sys_user), or multiple: true new authoring rule, on the merged object: an extension that replaces U is judged as merged, as stack.zod.ts judges a controlled_by_parent master
    6 F missing, not a reference (lookup, master_detail or user), or the same column as U same rule
    7 A filter that does not lower, names a column X lacks, or reads any current_user.* the rls-predicate-* judge, with X as the target
    8 A cycle Cannot be spelled: refusal 7 removes every key-from-key edge, and the resolution read never applies X's RLS. No detector is needed
    9 An unbounded set Cannot be spelled structurally: U is a required equality anchor, filter only AND-s, and there is no chaining. Row counts are not knowable at publish; the runtime bound holds them
    10 Cross-package reach Cannot be spelled: the key lives on X and ObjectExtensionSchema cannot carry it; a replaced U or F lands in refusal 5 or 6
    11 A policy comparing a declared key with a column of another target (buyer in current_user.portal_supplier_ids, where buyer references sys_user), with the id of an object other than F's target, or with a non-reference column an extension of the rls-predicate-* family; exact, because the declaration names the target
    12 A policy reading current_user.KEY in an in position that no kernel key, declaration or registered resolver owns runtime gate: exact. CLI: unchanged (unknowable, validate-rls-predicate-enforceability.ts:688), because a self-hosted code resolver may own it; at runtime it already drops with the existing WARN
    13 A declared key in a scalar position (==) already refused today; now exact

    None of these is a new CI check:* gate. They are the new key's own schema and authoring findings, in the registry that already exists.

    How it shows in the security explainers

    • /security/explain, object level. The rls layer's detail names each key the applicable policies read: the declaring object, F, U, the filter, and the resolved size, or why it did not resolve (no rows, over the bound, read failed). ExplainDecision gains no field. That is fix(plugin-security)!: collectRLSPolicies applies an object's declared default row scope when no held set carries a row policy for it #24451's choice for defaultRowScope (detail and via, no structured field).
    • Record grained. The policy's rule keeps kind: 'rls_policy' with via: 'membership:KEY', and the detail names the roster row whose F value matched this record (one extra system read, in explain only): "Alice reads PO-42 because her srm_supplier_contact row C-17 links her to supplier S-1."
    • Explaining another user, or a delegator. Keys resolve for whichever principal the context names, through the same staging that explain already composes through (computeLayeredRlsFilter), so the explanation is the enforcement answer.
    • os explain RULE carries an entry for each new rule id.
    • Access matrix: no change. Whoever can write X can grant the key, and the matrix already carries CRUD on X per set, so "supplier_portal gains edit on srm_supplier_contact" is already a reviewed diff.

    Worked example: hotsrm's supplier contact

    Illustrative: hotsrm has no code yet, and the names follow the card.

    // The roster.
    export const SupplierContact = ObjectSchema.create({
      name: 'srm_supplier_contact',
      fields: {
        user: Field.user({ required: true }),
        supplier: Field.lookup('srm_supplier', { required: true }),
        active: Field.boolean({ defaultValue: true }),
      },
      membershipKeys: {
        portal_supplier_ids: {
          userField: 'user',
          valueField: 'supplier',
          filter: 'active == true',
          description: 'Suppliers the signed-in user is an active contact of',
        },
      },
    });
    
    // The portal permission set, held through the external members' portal position (#25233).
    rowLevelSecurity: [
      { name: 'portal_supplier_self', object: 'srm_supplier', operation: 'select',
        using: 'id in current_user.portal_supplier_ids' },
      { name: 'portal_purchase_orders', object: 'srm_purchase_order', operation: 'select',
        using: 'supplier in current_user.portal_supplier_ids' },
      { name: 'portal_asn_insert', object: 'srm_asn', operation: 'insert',
        check: 'supplier in current_user.portal_supplier_ids' },
      { name: 'portal_contact_rows', object: 'srm_supplier_contact', operation: 'select',
        using: 'supplier in current_user.portal_supplier_ids' },
    ],

    One request. Alice is an active contact of S-1 and S-3 (rows C-17 and C-21). The first policy that reads the key triggers one read: srm_supplier_contact where user is Alice and active == true, inside Layer 0, projecting supplier, limited to 1,001 rows. That stages [S-1, S-3], so srm_purchase_order is filtered by supplier IN [S-1, S-3], and a later srm_asn read in the same request reuses the set. Deactivating C-21 removes S-3 on the next request.

    Before and after. Before: one position per supplier, one assignment per contact, and a portal_position on every PO, ASN and invoice. After: no portal_position column; the business supplier lookup is the only column read.

    What publish refuses here. valueField: 'supplier_name' (a text column) is refusal 6. filter: 'supplier in current_user.portal_supplier_ids' is refusal 7. using: 'buyer in current_user.portal_supplier_ids' on the PO, where buyer references sys_user, is refusal 11.

    Precondition. The supplier-facing objects are public_read and the portal set's policies narrow them (premise 4). An object the portal set can read and no policy narrows is read whole, exactly as with positions today.

    What A does not do

    • It does not widen a private object (the sharing layer is AND-ed on top).
    • It does not apply to an ADR-0099 EXTERNAL-rung principal (premise 5).
    • No chaining, no multi-valued U, no cross-request cache, no authorable bound.
    • It does not retire the code seam. The seam has 0 registrants in this tree, but cloud and self-hosted plugins are unmeasured, so retirement is not proposed here.
    • Studio's object form does not offer the key in the first slice: code, REST /meta and MCP authoring carry it. The objectui form row is a separate card, recorded in the form ledger as feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398 did.

    四维分析

    A:声明式成员键(推荐)

    • 实际业务需求: 今天撞上的是 hotsrm 供应商门户,维护者已裁定 cloud#3149 第 2 项;HotCRM 和仓内示例零使用,代码解析器零注册。拉动只有一家,但具名、已裁;零使用有一部分是因为托管环境根本没有路可走。
    • 项目长远合理性: 平台内部早已在"按元数据推导成员集合"(主从继承 ADR-0055 就是这么做的),A 把同一机制开放成一条声明,删掉"一个供应商一个岗位、每条单据打标签"这个特例。主流平台(Salesforce Sharing Set、Power Pages 表权限的 Contact/Account 范围、Odoo 记录规则)都把门户访问建模成声明式的关系范围。
    • 防 AI 犯错: AI 写错声明时,用户列、取值列、过滤式、键名、拿去比较的列在发布时逐项校验,响亮拒绝;运行时查不到、超上限一律按拒绝处理,日志和解释器说明原因,从不静默放宽。剩余风险是"谁能改名单表谁就能授权",这在现有权限矩阵里逐个权限集可审。
    • 创业阶段不扩散: 新增一个对象可选键,无新元数据类型、无新 CI 门禁;这是本轮唯一的扩张。

    B:不加协议,用现有零件拼

    • 实际业务需求: 主从树里能用;门户用户提交子单据需要改供应商主档的权限(业务上不该给);RFQ 邀请这类不挂在供应商下的单据还要再挂一份名单。
    • 项目长远合理性: 名单在每个主档或单据上各复制一份,靠钩子同步,正是本卡要删的标签,只是换成了用户列表。
    • 防 AI 犯错: AI 必须在每个钩子、每张单据上都不漏同步;漏一次就是静默的多给或少给。
    • 创业阶段不扩散: 零新增,这一轴上最优。

    C:维持现状

    • 实际业务需求: 每上线一个供应商都要管理员建岗位、分配;每条单据都要打标签。
    • 项目长远合理性: 特例永久化。
    • 防 AI 犯错: 一条漏打或打错就静默看错(卡上记录为越权)。
    • 创业阶段不扩散: 零新增。

    os-decision-facets
    ① 项目长远合理性:A 把主从继承已在内部做的"按元数据推导成员集合"开放成声明,收掉逐条标签特例;B 把标签换成用户列表,C 保留标签,两者都扩大特例。
    ② 实际业务拉动:今天撞上的只有 hotsrm 门户(cloud#3149 第 2 项已裁);HotCRM、仓内示例零使用,代码解析器零注册。
    ③ 防 AI 犯错:A 的声明在发布时逐项响亮拒绝,运行时失败一律收窄;B、C 依赖 AI 在每张单据或每个钩子上都不漏,漏了静默看错。
    ④ 创业阶段不扩散:A 新增一个对象可选键(无新元数据类型、无新 CI 门禁);B、C 零新增。
    Prior rulings read: rlsMembership, rls-membership-resolver, contextVariables, field recipient, EXTERNAL rung → 5 hits (#14103 ruling B, the cloud#3149 ruling as quoted in #25233, three ADRs); ADR-0105 D11, ADR-0055, ADR-0099 D3; thread: none

    Recommendation

    A, with the roster object as its home and the 1,000-value bound.

    Taken on long-term soundness alone. Two years out, portal and partner access on this platform should be a declared relationship scope: a person's rows in a roster object decide which records they reach. That is how Salesforce Sharing Sets (portal users reach records whose lookup matches their contact's account), Power Pages table permissions (Contact, Account and Parent scopes) and Odoo record rules (partner_id against the user's commercial partner) model it. A is that shape on the RLS seam the platform already has, and it generalises what controlled_by_parent already does internally. B and C keep a stamp.

    Not taken. B works only inside a master-detail tree, moves edit rights up to the supplier record and puts the stamp back on every other document. C keeps the stamp and an admin step per supplier.

    Workload. A costs at least 3 times B. The long-term readings are not close (B re-creates the stamp), so the gap does not move the letter.

    Confidence gap. hotsrm#1 and cloud's hosted document were not readable here. There is one consumer. The bound's value is chosen, not derived. Widening the runtime gate to object writes has no measured precedent.

    自检:只看①选 A;②③④ 是否翻转:否(② 单一具名拉动但已获裁定;③ 与①同向;④ 新增一个可选键,只影响分期,不翻字母)。

    After the ruling

    If A (one ruling settles the home and the bound):

    1. spec: MembershipKeySchema and ObjectSchema.membershipKeys, liveness planned until the reader lands, generated artefacts, and composeStacks refusing a duplicate key. In the same card: the row in skills/objectstack-data/rules/security.md (Tier H), and the rls.zod.ts examples that say "the runtime stages" keys nothing stages. Precedent feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398.
    2. plugin-security: per-key lazy staging beside the resolver, the system read with Layer 0, the bound, and explain's detail and via. Precedent fix(plugin-security)!: collectRLSPolicies applies an object's declared default row scope when no held set carries a row policy for it #24451.
    3. lint: the declaration rules (refusals 3 to 7) and the rls-predicate-* extensions (11 and 12), registered for object and permission writes at the runtime gate, with os explain entries. Precedent fix(lint): judge an object's defaultRowScope in the RLS predicate and org-axis rules #24511.
    4. ADR-0105 D11 dated note (the metadata twin): a docs-only PR, Tier H, the maintainer's merge.

    Order: 1, then 2, then 3, with liveness flipping to live in 2; 4 runs in parallel. cloud#3149 item 2 unblocks when 1 to 3 land, and hotsrm adopts then.

    If B: close #25234 as not planned; one docs card adds the portal recipe to content/docs/permissions/access-recipes.mdx; cloud#3149 item 2 proceeds in hotsrm.

    If C: close #25234 as not planned; cloud#3149 item 2 stays on positions, and its tracker drops the Blocked-by: line.

    Related

    #25233 (the external role), cloud#3149 (the tracker), hotsrm#1, ADR-0105 D11, ADR-0055, ADR-0099, #14103 and #15072 (the field recipient), and the defaultRowScope trio #24398, #24451 and #24511.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25234,
      "status": "needs_decision",
      "branch": "claude/project-thread-xnrc8p",
      "pr": null,
      "session": "session_01EaD2f12DArwjgwXWtzWJAh",
      "premise_still_valid": true,
      "summary": "Design round, reading only: no code, no branch push, no PR. The design comment is 6344270438 on this card (read back byte-identical, 30144 bytes). Premises measured on main 3e07389: the ADR-0105 D11 seam is one kernel service probed at start (security-plugin.ts:2013) with 0 production registrants and no metadata path; hosted running no installed package code is NOT MEASURED from cloud (403) but corroborated by ADR-0025's status line; pull = hotsrm only (repo README-only at d3ed2e5, hotsrm#1 unreadable), HotCRM c8a7a70 and in-repo examples 0 membership keys. Recommendation A: ObjectSchema.membershipKeys on the roster object (closed shape userField / valueField / filter / label / description), resolved lazily per key per request inside stageRlsMembership beside the code resolver, as a system read with the caller's Layer 0 and no Layer 1, bounded at 1,000 values with fail-closed overflow, 13 publish refusals (cycle, unbounded set and cross-package reach made unspellable by the shape), explain via detail and via with no ExplainDecision field. Dependency named: the design needs #25233's external role to stay on the MEMBER rung (ADR-0099 D3's EXTERNAL rung does not evaluate Layer 1). Deviation, declared: the four-axis section and the facets block are in Chinese (SKILL.md's 中文四通道 lists 四维分析; decision-analysis.md asks the 四棱 block in Chinese), where the dispatch said English except the 速读. The four-axis frame in SKILL.md is byte-identical to the dispatch's quoted text; the file's md5 is now 695c96023fb9da730d2f144593721d51 because other sections moved.",
      "tests": "n/a: a design round; nothing built, nothing tested. Readings, not runs: grep counts on main 3e07389 (resolver registrants, membershipKeys, current_user key census in examples and packages/qa), HotCRM c8a7a70 and hotsrm d3ed2e5 shallow clones, PR sizes for #24398 / #24451 / #24511 / #3559 read from the REST API.",
      "gates": "n/a: no diff, so no derived gate list and no local gate run.",
      "dogfood_battery": "n/a — no PR, no `!` title and no (narrowing) arm",
      "mcp_calls": "24 — all reads: issue_read 8 (#25234 get + 3 get_comments, #25233 get + get_comments, #24340 get, #14103 get), search_issues 5, search_pull_requests 5, pull_request_read 4 (#24398 get, #24451 get_files + get, #24511 get_status), get_commit 2 (31e7d0b, df9d07e). No MCP write.",
      "api_writes": "2 — POST /repos/objectstack-ai/objectstack/issues/25234/comments twice, both through scripts/pm/post-stamped.mjs and the fleet-write relay: the design comment 6344270438 and this report.",
      "open_questions": [
        {
          "question": "Which platform shape replaces hotsrm's per-supplier position stamp for portal contacts on hosted?",
          "options": [
            "A — declarative membership key: ObjectSchema.membershipKeys on the roster object, resolved per request beside the D11 resolver; 3 PRs (precedent #24398 +549/−83, #24451 +788/−102, #24511 +590/−133) plus a dated ADR-0105 D11 note (Tier H)",
            "B — no new protocol: a field-recipient sharing rule on a user list per supplier plus controlled_by_parent children; 0 platform PRs; child writes need edit on the supplier, and documents outside the tree need their own list",
            "C — status quo: one position per supplier and a stamped portal_position on every document; 0 PRs"
          ],
          "recommendation": "A, because it removes the stamp at its root, generalises what controlled_by_parent already derives internally, and refuses wrong declarations at publish; B re-creates the stamp as a user list, C keeps it."
        },
        {
          "question": "Sub-fork inside A: where does the declaration live?",
          "options": [
            "object — on the roster object X",
            "app — on the App",
            "type — its own metadata type"
          ],
          "recommendation": "object, because U and F are X's own fields (every check local), object-level security keys already live there (defaultRowScope, controlled_by_parent), and ObjectExtensionSchema cannot carry it, which closes cross-package reach; RLS is not app-scoped, and a new type is the largest surface."
        },
        {
          "question": "Sub-fork inside A: how large may one key's set be per request?",
          "options": [
            "cap — 1,000 values, overflow fails closed with one log line and an explain reason",
            "none — parity with controlled_by_parent master sets and org_user_ids"
          ],
          "recommendation": "cap, because a person's roster never meets it and an over-broad declaration then fails loudly instead of growing every query's IN list; it is a cost ceiling like CBP_MAX_CHAIN_DEPTH, not authorable, raised only by a ruling."
        }
      ],
      "out_of_scope_findings": [
        "carrier: the spec card that follows a ruling of A · `packages/spec/src/security/rls.zod.ts` examples say the runtime stages `territory_account_ids` and pre-resolves `team_member_ids`; nothing in this tree stages either (0 resolver registrants) · noted, not filed",
        "carrier: #25233's card thread · the design depends on the external role staying on the MEMBER posture rung; under ADR-0099 D3 an EXTERNAL-rung principal has Layer 1 unevaluated, so membership-key policies would not apply · noted, not filed"
      ]
    }

    Report taken 2026-10-22T15:19Z.


    Generated by Claude Code

  4. os-elon-musk commented on Oct 23, 2026

    @os-elon-musk
    Collaborator

    Ruling: batch #2 item 2 · letter A · maintainer 「同意」 2026-10-23T02:04:08Z

    Letter A as designed in 6344270438, both sub-forks on the recommendation: declarative membership keys live on the roster object (ObjectSchema.membershipKeys, record-keyed; userField, valueField, optional filter in the lowerable CEL subset, no current_user.* inside it); the size bound is a platform constant of 1,000 values per key per request, overflow fails closed, not authorable, raised only by a ruling. The resolver path is ADR-0105 D11's stageRlsMembership, gaining a declarative second source beside the registered code resolver; the 13-item publish refusal list and the explainer detail / via: 'membership:KEY' stand as written.

    Premise 5 is settled by #25233's ruling (6355206139, letter B, 2026-10-23T01:26Z): the EXTERNAL rung admits position and permission-set grants with their RLS evaluated, so a portal permission set's supplier in current_user.<key> reaches an external member exactly as it reaches a member. No extra sentence is owed here.

    Riders the maintainer ruled with the letter (thread 「待决策汇总」, 01:26–02:04Z):

    1. The per-key resolution read is the platform's own Layer 0 system read, independent of the caller's audience and posture: an external caller with no read on the roster object still resolves its key. State it in the implementation note and pin it, so a later edit cannot route the read through the EXTERNAL posture and silently empty every portal.
    2. Keys are audience-neutral: a key declared on a roster object resolves for internal callers too (account teams, "my accounts"). Do not scope membershipKeys to externals.
    3. Explainer, one more line in PR ②: name the declaring object X and the holders of write permission on X ("whoever can write X can grant the key"). No publish-time refusal for roster-object writes: which sets reach externals is not known at publish.
    4. Serial constraint: PR ② (plugin-security) and Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233's stage-2 PR (audience / derivePosture wiring, OWD seam, matrix column) touch the same package; the seat serialises the two card threads, whoever lands second merges main.

    Workload of A (the design's own estimate, precedent defaultRowScope trio):

    1. spec: MembershipKeySchema + ObjectSchema.membershipKeys, liveness planned, generated artefacts, composeStacks duplicate-key refusal, the rls.zod.ts example fix, the Tier H row in skills/objectstack-data/rules/security.md (precedent feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398, +549/−83).
    2. plugin-security: per-key lazy staging, the system read with Layer 0, the bound, explain detail / via, rider 3's line, liveness → live (precedent fix(plugin-security)!: collectRLSPolicies applies an object's declared default row scope when no held set carries a row policy for it #24451, +788/−102).
    3. lint: declaration rules (refusals 5–7) and the rls-predicate-* extensions (11–13), registered for object and permission writes at the runtime gate, os explain entries (precedent fix(lint): judge an object's defaultRowScope in the RLS predicate and org-axis rules #24511, +590/−133; the object-write registration is the one step without a measured precedent).
    4. ADR-0105 D11 dated note (metadata twin), docs-only, Tier H, the maintainer merges.

    Order 1 → 2 → 3, 4 in parallel; ≈ +1,950 / −320 in three size/l PRs. cloud#3149 item 2 unblocks when 1–3 land; hotsrm adopts then and must not launch on per-supplier positions in the meantime. The Studio object-form row is a separate objectui card.

    State: needs-user-decision → pm:queue. Seat domain:spec#2 dispatches the implementation card(s); ⛔ 裁不派.


    Generated by Claude Code

  5. 3 remaining items

  6. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2
    Session: session_01M4rJHRAZDFqLYQ9dHMH339 (the card thread; seat session session_013odhA26dvzy7pQmevdTTvy posts this claim)
    Account: os-tesla
    Branch: claude/project-thread-3kk0c4
    Worktree: objectstack-issue-25234
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    File surface: PR ① of ruling 6355640929 only — MembershipKeySchema + ObjectSchema.membershipKeys in packages/spec/src/**, the rls.zod.ts example fix, packages/spec/liveness/** (planned), the composeStacks duplicate-key refusal, generated references, tests, the changeset (stop on breach; explain in the report). ⛔ no skills/** edit in this PR (the Tier H row is reported as text and lands separately), no plugin-security / lint / ADR edit.
    Container & model: L, mode:thread, model: default tier (dispatch-gates --tier: "no path-derived mandate", floor sonnet · default opus · ceiling fable), effort: high
    Clause-②: yes (widening)
    Metadata-protocol: added
    Responsibility: n/a — not a defect card
    Thread-read: none
    Serial constraints cleared: seat 2's design-round claim 6343941655 released by this seat after seat 2's demotion on #18549. #25233 stage 1 is dispatched in parallel on non-spec files; PR ② (plugin-security) is serialised against #25233's stage 2 by the seat.

    The platform lets the card thread push only its designated thread branch, so Branch: records that branch, not claude/issue-25234-<slug>. Dispatch order: the project's shared folder, spec-seat-1/dispatch-25234.md. The seat runs a CONTRACT_REVIEW_TIER review before the queue.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25234,
      "status": "done",
      "branch": "claude/project-thread-3kk0c4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25513",
      "session": "session_01M4rJHRAZDFqLYQ9dHMH339",
      "premise_still_valid": true,
      "summary": "PR 1 of ruling A (6355640929), the spec half, as a draft that leaves the card open (Part of). ObjectSchema.membershipKeys on the roster object: MembershipKeySchema (userField, valueField, filter, label, description; strict, valueField equal to userField refused), the map refusing a non-snake_case name and every RESERVED_RLS_MEMBERSHIP_KEYS name with a per-key message, MEMBERSHIP_KEY_MAX_VALUES = 1000 as a constant, ObjectExtensionSchema guidance; defineStack and composeStacks refuse one key on two objects (STACK_CROSS_REFERENCE_INVALID / 422); liveness planned with 5 children and one author warning (probed: 1 finding, control 0); the rls.zod.ts examples no longer say the runtime stages territory_account_ids / team_member_ids; form ledger omit row; generated artefacts; changeset minor spec + patch metadata-core. Deviation from the design's sketch, declared in the PR: the schema lives in security/rls.zod.ts beside DefaultRowScopeSchema (the precedent's home), because a new security/ module made gen:skill-refs rewrite two skills/ index files the claim excludes; label/description are z.string() not I18nLabelSchema. Surface beyond packages/spec/src declared: metadata-core FLS classification + pin and the metadata-protocol /meta/types count test (both red otherwise, the #24398 precedent), and docs/audits strictness counts (generated). The Tier H skills row is in tier_h_skills_row, not in the diff.",
      "tests": "head b2b98c41 via os-verify-lock: spec test 713 files / 21684 passed / 1 todo (VERDICT command-exit 0) + spec typecheck exit 0; metadata-core 19 files / 796 passed + typecheck 0; metadata-protocol meta-types test 18 passed; lint 4 files / 694 passed; objectql 4 files / 160 passed (closure rebuilt from head). New suite object-membership-keys.test.ts 25 tests. Ablation (one): one-stack door push replaced via ablation-replace.mjs, blob 208905c1ae7c to 98fa82b5718f, red as expected (1 failed / 24 passed), restored blob == HEAD, git diff HEAD empty; no build (relative src import). Reverse .d.ts probe in platform-objects: key accepted exit 0, valueField: 5 TS2322 exit 2; inner unknown member typechecks (noted).",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on head b2b98c41: 116 derived, 116 run sequentially, 116 exit 0 (exit captured before any pipe); --ran reconciliation 116/116, 0 NOT-MEASURED. First pass on b595703e: check:spec-parsed-alias exit 1 (fixed in b2b98c41), 11 exit 3 PREREQUISITE NOT MET (stale dist / lint dist absent), all 0 after rebuild; check-plugin-teardown-shape --self-test needed pinned 621a4876 fetched --depth=1. check:dev-prereqs (local-only) exit 0. CI families: the PR's check-runs, in_progress at report time.",
      "line_budget": "26 files, +872 / -37 (909 changed lines), under the 3000 human-merge threshold; precedent #24398 +549/-83",
      "files_changed": [
        "packages/spec/src/security/rls.zod.ts",
        "packages/spec/src/data/object.zod.ts",
        "packages/spec/src/stack.zod.ts",
        "packages/spec/src/data/object-membership-keys.test.ts",
        "packages/spec/src/compose-stacks-merge-collection-refusal.test.ts",
        "packages/spec/src/compose-stacks-collection-pipe-arm.test.ts",
        "packages/spec/src/system/metadata-form-zod-reconciliation.test.ts",
        "packages/spec/liveness/object.json",
        "packages/spec/liveness/state-counts/object.md",
        "packages/spec/dropped-refinements.baseline.json",
        "packages/spec/{api-surface,authorable-surface,declaration-map,export-origins,json-schema.manifest} (generated)",
        "content/docs/references/** (generated, 5 pages)",
        "docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md (generated)",
        "packages/metadata-core/src/object-schema-fls-references.ts",
        "packages/metadata-core/src/object-schema-fls-references.test.ts",
        "packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts",
        ".changeset/25234-object-membership-keys.md"
      ],
      "deviations": [
        "schema home security/rls.zod.ts, not a new membership-key.zod.ts (precedent home; a new module rewrote skills/ reference indexes via gen:skill-refs)",
        "label/description typed z.string() (design sketch said I18nLabelSchema)",
        "file surface beyond packages/spec: metadata-core FLS classification + test, metadata-protocol count test, docs/audits generated counts — each red or stale otherwise, as on #24398"
      ],
      "tier_h_skills_row": {
        "file": "skills/objectstack-data/rules/security.md",
        "insert_after": "the `current_user.positions` row of the placeholder table (line 112 at ab510f42e7)",
        "text": "| `current_user.KEY` (a declared membership key) | the values of `valueField` on the rows of the roster object that declares `membershipKeys.KEY` whose `userField` is the caller (for `IN`; planned: no server stages it yet, so a policy reading it matches no rows) |",
        "line_delta": "+1 line, no deletion"
      },
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "0 — GitHub reads went through unauthenticated REST (curl) for #25234, its comments and PR #24398; no MCP GitHub tool called",
      "api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls via fleet-write relay (PR #25513, read-back byte-identical, assignee os-tesla); POST /repos/objectstack-ai/objectstack/issues/25234/comments via post-stamped (this report)",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: #25234 PR 2 · @objectstack/objectql SchemaRegistry.registerObject refuses `defaultRowScope` on an `extend` contribution (EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE) but has no refusal for `membershipKeys`; whether the extension merge carries the key onto the owner's object is NOT MEASURED; no effect until the reader lands · noted, not filed"
      ]
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing plan for the remaining PRs · domain:spec seat 1 · 2026-10-23T17:19Z

    Ruling 6355640929 orders PR ① → ② → ③, with ④ (the ADR-0105 D11 note) in parallel. PR ① is PR #25513 (Part of #25234, contract review PASS). Carried forward from that review:

    • Closing PR: PR ③ (lint refusals and runtime-gate registration) carries Closes #25234. PRs ① and ② say Part of #25234.
    • PR ② must also close the objectql registry extend door. SchemaRegistry.registerObject refuses defaultRowScope on an ownership: 'extend' contribution by name, but has no membershipKeys analogue, and mergeObjectDefinitions drops the key silently (ADR-0078). Add a by-name refusal beside the defaultRowScope one, or a merge that carries the key, before stageRlsMembership reads it.
    • PR ② is serialised against Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233's stage 2 (both touch plugin-security; whoever lands second merges main).
    • Tier H skills/objectstack-data/rules/security.md row: the text is in os-dev-report 6370454361 (tier_h_skills_row). It lands as its own governed PR together with, or re-worded by, PR ② when liveness flips to live, and the maintainer merges it.

    Generated by Claude Code

  9. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT on PR #25513 (PR ① of 3): comment 6371079096 · 2026-10-23T17:44Z. Queue landing enabled; card stays pm:dispatched for PR ② and ③.


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25234,
      "status": "done",
      "branch": "claude/project-thread-3kk0c4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25528",
      "head": "5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd",
      "session": "session_01M4rJHRAZDFqLYQ9dHMH339",
      "premise_still_valid": true,
      "summary": "PR 2 of ruling A (6355640929; landing plan 6370632236), the reader, as a draft with Part of #25234. plugin-security stageRlsMembership now stages ObjectSchema.membershipKeys beside the registered rls-membership-resolver: lazily, only keys a policy about to compile reads (current_user.* names parsed once per predicate via parseCelToAst after sqlPredicateToCel, cached), on the read door and the write check alike, memoised per key per request. The read is X where U == userId AND the compiled filter AND the caller's own Layer 0 filter for X (isPlatformAdmin false), projected to id + F, limit 1001, under { isSystem: true } with no tenantId so the where wall is the only org scope. Fail closed: no row stages []; over 1000 rows/values, read failure, filter refused by RLSCompiler, tenant wall deny, no user leave the key unresolved with a warn; a key declared on two objects, or one the resolver also owns (its keys or staged this request), is unresolved and logged as an error (resolver collision also at boot naming both owners), and the resolver's value is withdrawn. Explain: the object-level rls detail names each key read (label, description, X, F, U, filter, resolved size or miss reason) and the permission sets whose holders can insert/update X; a record-grained business_rls rule reads via membership:KEY and names the linking roster row. objectql: SchemaRegistry.registerObject refuses an extend contribution carrying membershipKeys (INVALID_METADATA / 422, ExtensionMembershipKeysRefusedError) beside the defaultRowScope refusal; measured first that mergeObjectDefinitions dropped it silently. Liveness membershipKeys + 5 children planned to live (authorWarn/authorHint dropped); spec wording no longer says not applied. Serial with #25233: PR #25515 landed first (9019af9f), so this head merges main 74496c64 cleanly (5f35aff3). The PR body's line naming the earlier merge of aa2dbab7 is stale on that point; the relay has no PR-edit op, so it is recorded here.",
      "riders": {
        "1_layer0_system_read": "pinned: the portal set has no grant on the roster and its own find is PERMISSION_DENIED while its PO read resolves the key; unit test asserts byte-identical find args (where with org term, fields, limit, context { isSystem: true }) for MEMBER, external audience, platform admin, tenant admin",
        "2_audience_neutral": "pinned: MEMBER, audience external and TENANT_ADMIN resolve the same rows",
        "3_explain_names_x_and_writers": "pinned: object-level detail names X and the holders of insert/update on X via the security catalog's permission sets and PermissionEvaluator.checkObjectPermission; record-level via membership:KEY",
        "4_serial_with_25233": "#25515 merged 2026-10-23T19:34Z; main merged here after it, re-verified, CI green"
      },
      "tests": "post-merge head 5f35aff3 via os-verify-lock (slot issue-25234): plugin-security closure build (dts) exit 0; plugin-security typecheck exit 0; plugin-security 220 files / 4607 passed / 45 skipped exit 0; objectql 466 files / 8700 passed exit 0. Pre-merge on e35ae5da: spec 792 files / 22953 passed / 1 todo; objectql tsc exit 0. New suites: object-membership-keys.test.ts 21 (real SecurityPlugin + ObjectQL + SqlDriver, isolated posture), registry-extension-membership-keys-refused.test.ts 6. Ablations, each restored byte-identically: declared staging removed 12 red / 9 green; write-side call reverted 1 red (insert check); roster read under caller context instead of isSystem 11 red incl. both rider-1 pins; Layer 0 term dropped 8 red; objectql refusal disabled 4 red / 2 controls green.",
      "gates": "dispatch-gates --commands: 124 derived, 124 run sequentially, exit captured before any pipe; --ran reconciliation on e35ae5da 124/124, 0 NOT-MEASURED, all exit 0. Real findings fixed: check:doc-authoring (tracker id in 3 runtime log strings, e35ae5da) and, caught by CI, check:optional-error-sink (MembershipKeyStagingHost.logger had both warn and error optional; warn now required, 3c309af1, OES exit 0 locally). Environment-only first-pass codes: check-engine-split-ratio exit 2 on shallow clone (fixed by fetch --shallow-since), check:skill-examples / check:dual-build-cjs-loads / check:i18n exit 3 until a full workspace build. CI on 5f35aff3: 42 check runs, 36 success, 6 skipped, 0 failed.",
      "line_budget": "17 files, +1493 / -79 (1572 changed lines), under the 3000 human-merge threshold; of which tests 591 lines (2 new suites)",
      "files_changed": [
        "packages/plugins/plugin-security/src/object-membership-keys.ts (new)",
        "packages/plugins/plugin-security/src/object-membership-keys.test.ts (new)",
        "packages/plugins/plugin-security/src/security-plugin-rls-filter.ts",
        "packages/plugins/plugin-security/src/security-plugin.ts",
        "packages/plugins/plugin-security/src/tenant-layer.ts",
        "packages/plugins/plugin-security/src/explain-engine.ts",
        "packages/objectql/src/registry.ts",
        "packages/objectql/src/registry-extension-membership-keys-refused.test.ts (new)",
        "packages/spec/liveness/object.json",
        "packages/spec/liveness/state-counts/object.md (generated)",
        "packages/spec/src/data/object.zod.ts",
        "packages/spec/src/security/rls.zod.ts",
        "content/docs/references/** (generated, 4 pages)",
        ".changeset/25234-membership-keys-staged.md"
      ],
      "deviations": [
        "tenantLayer0InputOf extracted in tenant-layer.ts so the read door and the roster read build the Layer 0 input from one literal (computeLayeredRlsFilter now calls it; behaviour unchanged, suites green)",
        "the roster system context carries no tenantId: with one, the engine scoped the driver to the active org on its own, which would narrow a group-posture key below the wall; the where wall is the only org scope (Layer 0 ablation 8 red)"
      ],
      "tier_h_skills_row": {
        "file": "skills/objectstack-data/rules/security.md",
        "replaces": "the planned row from report 6370454361",
        "text": "| `current_user.KEY` (a declared membership key) | the values of `valueField` on the rows of the roster object that declares `membershipKeys.KEY` whose `userField` is the caller and on which its `filter` holds, inside the caller's organization (for `IN`; read once per request even when the caller cannot read the roster; more than 1,000 values, a failed read, or a key a registered resolver also owns matches no rows) |",
        "line_delta": "1 line replaced, no addition"
      },
      "dogfood_battery": "n/a: no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "read-only GitHub MCP calls (check runs, job logs) for CI triage; no MCP write",
      "api_writes": "2: POST pulls via fleet-write relay (PR #25528, read-back identical); POST issues/25234/comments via post-stamped (this report). Pushes to claude/project-thread-3kk0c4 only: a718adcc, 21cace53, e35ae5da, 3c309af1, 5f35aff3",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  11. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT on PR #25528 (PR ② of 3): comment 6375208026 · 2026-10-23T21:36Z. Queue landing enabled; card stays pm:dispatched for PR ③.


    Generated by Claude Code

  12. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25234,
      "status": "done",
      "branch": "claude/project-thread-3kk0c4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25550",
      "head": "cf991db317782291a2a5be83eafd822638b766cc",
      "session": "session_01M4rJHRAZDFqLYQ9dHMH339",
      "premise_still_valid": true,
      "summary": "PR 3 of 3 of ruling A (6355640929; design 6344270438; landing plan 6370632236), the authoring refusals, as a draft with Closes #25234, on main after #25528 (e5eab065). New gating rule validateMembershipKeyDeclarations (CLI_AND_RUNTIME, runtimeTypes object): membership-key-user-field-invalid (refusal 5: U a single-valued user reference on the merged object), membership-key-value-field-invalid (6: F a lookup/master_detail/user), membership-key-declared-twice (3: at the save door reading the live objects the stack does not carry, outside the package closure), membership-key-resolver-collision (4: save door only). validateRlsPredicateEnforceability learns keys and now runs for object writes too (runtimeTypes permission, object): a key filter is judged as an RLS clause at objects[N].membershipKeys.KEY.filter with the roster as target, plus a caller-read check (7); new rls-predicate-membership-key-target for a declared key tested against a column of another target (11); a declared key under == is the existing list type fault (13); at the save door only, x in current_user.KEY that no kernel key, declaration or registered resolver owns is rls-predicate-unknown-user-variable (12). Host fact: protocol.ts rlsMembershipResolverKeys reads RLS_MEMBERSHIP_RESOLVER_SERVICE keys per publish ([] when none; undefined, so not judged, with no registry or unreadable keys) and the gate builds membershipKeyOwners from the un-narrowed context.objects, the written stack authoritative for the objects it carries. os explain entries for the five new ids and three new unknown-user-variable paragraphs.",
      "tests": "lint 158 files / 7237 passed, tsc exit 0, dts build 6/6; metadata-protocol 261 files / 21457 passed / 19 skipped plus 4 new host cases, tsc exit 0; cli rls-policy-authoring-admission 40 passed. New suites: validate-membership-key-declarations.test.ts 17, validate-rls-predicate-enforceability.membership-keys.test.ts 24, protocol.runtime-authoring-gate.test.ts +4. Ablations, each restored byte-identically: refusal 12 arm 3 red; 11 arm 7 red; 13 swap 2 red; 7 caller check 1 red; 7 judge pass 6 red; stack authority 1 red; the six findings.push sites of the declarations rule 1/6/2/1/4/3 red; host resolver read replaced by undefined 2 red / 2 green.",
      "gates": "dispatch-gates --commands on d1ddcd9f: 71 derived, all run sequentially under os-verify-lock (slot issue-25234) after a dts build of the cli closure, plus 58 more from the long listing; all exit 0 except one real finding, fixed in d16c4a71 (check:docs-transcript-drift: four docs transcripts said 51 author-time rules, the validate registry now derives 52), and four PR-context guards NOT WIRED locally (closing-target-claim, partof-closing-keyword, single-claim-paths, governed-queue-guard), all green on CI; partof also run locally with the PR body as PR_BODY, pass. CI on cf991db3: 42 check runs, 0 failed.",
      "line_budget": "20 files, +1868 / -122 (1990 changed lines), under the 3000 human-merge threshold; tests 731 lines",
      "files_changed": [
        "packages/lint/src/validate-membership-key-declarations.ts (new)",
        "packages/lint/src/validate-membership-key-declarations.test.ts (new)",
        "packages/lint/src/validate-rls-predicate-enforceability.ts",
        "packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts (new)",
        "packages/lint/src/authoring-rules.ts",
        "packages/lint/src/runtime-gate.ts",
        "packages/lint/src/index.ts",
        "packages/lint/src/rule-explanations.ts",
        "packages/lint/src/runtime-gate.object-writes.test.ts",
        "packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts",
        "packages/lint/src/validate-rls-predicate-enforceability.test.ts",
        "packages/metadata-protocol/src/protocol.ts",
        "packages/metadata-protocol/src/runtime-authoring-gate.ts",
        "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts",
        "packages/cli/test/rls-policy-authoring-admission.test.ts",
        "content/docs/deployment/validating-metadata.mdx",
        "content/docs/deployment/cli.mdx",
        "content/docs/getting-started/build-with-claude-code.mdx",
        "content/docs/ui/react-pages.mdx",
        ".changeset/25234-membership-key-authoring-refusals.md"
      ],
      "deviations": [
        "refusal 3 also runs at the save door (the design table names the runtime gate against the registry); defineStack already refused it within one stack",
        "registering the RLS rule for object writes also judges defaultRowScope at the object door and refuses an object write that drops a field or key a stored policy reads (differential: an already-failing stored policy is not blamed); stated in the changeset BREAKING section",
        "the cli real-engine admission test's app-staged current_user.holidays cases now register a resolver owning holidays; a new case pins refusal 12 end to end (CLI accepts, save door 422)",
        "validating-metadata.mdx door table: the RLS rule gets its own row (permission, object at the save door; the old shared row said not at the door, stale since the permission crossing) and the declarations rule a row"
      ],
      "dogfood_battery": "cf991db3 · os-verify-lock VERDICT command-exit 0 · Test Files 291 passed / 3 skipped (294) · Tests 2500 passed / 18 skipped (2518) · no failures",
      "mcp_calls": "read-only GitHub MCP calls (check runs, job logs) for CI triage; no MCP write",
      "api_writes": "2: POST pulls via fleet-write relay (PR #25550, read-back identical); POST issues/25234/comments via post-stamped (this report). Pushes to claude/project-thread-3kk0c4 only: d16c4a71 (with d1ddcd9f), cf991db3",
      "open_questions": [],
      "out_of_scope_findings": [],
      "next": "after this lands, the Tier H skills/objectstack-data/rules/security.md row as its own draft PR, text per report 6374979058"
    }

    Generated by Claude Code

  13. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT on PR #25550 (PR ③ of 3): comment 6377103312, contract review 6377122276 (PASS) · 2026-10-23T23:56Z. Queue landing enabled; on merge this card closes and the Tier H skills row follows as its own governed PR.


    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:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:specenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions