Skip to content

plugin-approvals expandPositionUsers filters organization_id strictly, while plugin-security's holdingTakesEffectIn admits an organization-less position holding in every organization: an upgraded 17.x holder never reaches an approval slate #23256

Closed

Description

@objectstack-fleet

Blocked-by: #15212

Filing gate ①: a product defect with a named producer. Two implementations of one rule disagree.
reach: named producer.

  • Every 17.x install upgraded to 18: its sys_user_position rows carry no organization_id.
  • Any system or seed write of sys_user_position. Measured on 18.0.0-next.0: a system seed stores organization_id: null, while an admin's POST /api/v1/data/sys_user_position stores the session organization.

Filed by the repo:hotcrm seat (session_01BCDqr1DqnQjEh5jA9gGkUG) from the os-dev report on objectstack-ai/hotcrm#2031 (comment 6145479122). ⛔ Not a claim. File-at-destination, for triage.
Reader: the objectstack triage seat routes and grades it.
Dedupe: semantic issue search on objectstack-ai/objectstack, all states, "expandPositionUsers organization filter strict equality excludes position holders with null organization, plugin-security holdingTakesEffectIn admits them" → 7 hits, none this disagreement. Related: #15212 (open, ADR-0131 C8: organization_id NOT NULL per cleared table), which would end organization-less rows. Until it lands, both readings are live on 18.0.0-next.0.

The disagreement (installed @objectstack/* 18.0.0-next.0 dists)

  • plugin-security: holdingTakesEffectIn(row, org) is !org || org === organizationId, so an organization-less holding takes effect in every organization. activeHoldings and assignmentAnchorsOfPosition use it, and their doc calls organization_id = :org OR organization_id IS NULL "the governed spelling of exactly this rule".
  • plugin-approvals: ApprovalService.expandPositionUsers(positionName, organizationId) sets filter.organization_id = organizationId, strict equality. The position-approver expansion calls it with directoryOrg.

Minimal reproduction (measured by the HotCRM dev on 18.0.0-next.0)

  1. Boot the HotCRM app (SQL, single) and sign up user U.
  2. System-seed sys_user_position { user_id: U, position: 'sales_manager' } with no organization_id.
  3. A rep creates an account, so account_approval opens in the Default Organization.

Result: the slate omits U (it opens on administrators), and U's approve answers 403 "not a pending approver". Meanwhile plugin-security treats U as holding sales_manager in that organization.

Customer-visible: on an upgraded install, managers who hold an approver position never see the approval, which falls through to the administrators.

Ask

One rule for who holds a position in an organization, read the same way by the approval slate and the security layer. Which way it goes (admit or refuse organization-less holdings, or close them by C8) is the owning lane's call.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock scan: pm:blocked → pm:queue. Blocked-by: #22937 is satisfied: C7b closed completed (its last PR, #23329, landed as ed0383419d)

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T23:55Z. ⛔ Not a claim, ⛔ not a dispatch.

  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Oct 13, 2026
  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 14 · 2026-10-14T01:00Z · the restart verification the ruling names (6147914816; triage's unlock 6157716744)
    Session: session_01FzQy26KznSa5j2gnf3AzsE
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-23256-verify-upgrade-attribution
    Worktree: objectstack-issue-23256
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: none committed. This is a measurement on main with a scratch harness that is not committed, under the maintainer's ruling 「所有有问题也应该是优化升级方案」:

    1. On a fixture holding an organization-less sys_user_position row, os migrate organization-ownership --apply (C7b build-ahead: os migrate organization-ownership --apply and E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937) stamps it, and the approval slate (expandPositionUsers) then reaches the holder.
    2. A system-context insert of sys_user_position on a fresh single install stores the Default Organization.
      Both are read under single. Under a walled posture a holding with no business unit stays NULL and is reported by id: that reading is recorded as the ceremony's documented limit (6151166158), not a slate fix. ⛔ No change to plugin-approvals: no compatibility reading, as ruled. If either check fails, the dev stops and reports the reading, and the seat files the gap to the upgrade plan's or the producer's lane (stop on breach; explain in the report).
      Container & model: S (two measurements on main, readings only), mode:subagent, model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"
      Clause-②: no — nothing is changed. The card closes on the readings, or the gap is filed.
      Responsibility: n/a — a ruled verification, not a fix (the fix is C7b build-ahead: os migrate organization-ownership --apply and E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937's attribution)
      Thread-read: 6157716744
      Serial constraints cleared: nothing is edited, so there is no file overlap. C7b build-ahead: os migrate organization-ownership --apply and E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937 closed, and its last PR feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3 of 3) #23329 landed as ed0383419d.

    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 14, 2026
  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 23256,
    "status": "done",
    "branch": "claude/issue-23256-verify-upgrade-attribution",
    "pr": null,
    "session": "session_01FzQy26KznSa5j2gnf3AzsE (subagent; the parent PM's session)",
    "premise_still_valid": true,
    "files_changed": [],
    "summary": "Measurement only, on main 228357a (worktree objectstack-issue-23256, local branch, nothing committed or pushed), with a scratch app under /tmp/claude-0/seat/issue-23256/ (one object vx_claim, position vx_approver, a record-created approval flow routed to position vx_approver, SQLite, OS_TENANCY_POSTURE=single, booted with os serve --dev). VERIFICATION 1 (upgrade path) HOLDS: before the ceremony the organization-less holding keeps U off the slate (slate opens on the admin, U's approve is 403 'not a pending approver', the card's symptom reproduced on main); os migrate organization-ownership plans the row for attribution, --apply stamps it with the Default Organization, and the next approval opens on U and U's approve is 200 (request approved). VERIFICATION 2 (fresh-install producer) FAILS: on a fresh single install, a system-context insert of sys_user_position with no organization_id stores organization_id NULL; the control insert on an application object in the same boot stores the Default Organization. The gap sits at the PRODUCER, not the upgrade plan: sys_user_position has no entry in the D9 per-object inventory PLATFORM_OBJECT_TENANCY (packages/objectql/src/tenancy/platform-object-tenancy.ts:124), so it is 'unclassified', isPlatformObjectOutOfTenantAuditScope answers true, and resolveSystemInsertOrganization returns at packages/objectql/src/engine.ts:6680 before any posture is read. The census reach: in (scripts/platform-object-tenancy-census.json:668) says only that resolveTenantFieldName finds the tenant column; it is not D9 admission, so the ruling's read-from-code premise for check 2 is false on main. Consequence: any holding minted by a system write or seed after the ceremony is NULL again, and the strict slate drops that holder (the before reading). Walled limit recorded as documented: under isolated, the business-unit-less holding stays NULL and is reported by id. Nothing was fixed; the seat files the gap.",
    "tests": "No repo diff (worktree git status --porcelain empty at 228357a), so no gate family is owed. Build: OS_VERIFY_LOCK_SLOT=issue-23256 bash scripts/pm/os-verify-lock.sh -c \"pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2\" → 'Tasks: 61 successful, 61 total', 'VERDICT command-exit 0'. FIXTURE: first boot of the scratch app on db/fixture.db (fresh 18 single install: one organization org_mv7f11c8h6a8ek23, slug default); admin creates U through POST /api/v1/auth/admin/create-user (U = tH13Rw9yBBRJzsVGuv1HjVPVphujcXMO, sys_member of the Default Organization); server stopped; one 17.x-shaped holding written by SQL: sys_user_position {id usp_vx_17x_u, user_id U, position vx_approver, business_unit_id NULL, organization_id NULL}; copies taken for the before reading and the walled reading (sha256 d01c4f17... identical). V1 BEFORE (copy before.db): the row is still NULL after boot (no boot-time backfill touches it). Admin creates vx_claim → 201; request areq_a7ce9ecf... status pending, organization_id org_mv7f11c8h6a8ek23, pending_approvers [admin aHqmcB0M...] (the admin_rescue slate, U absent); U's POST /approvals/requests/:id/approve → 403 {code FORBIDDEN, error: actor 'tH13Rw9y...' is not a pending approver}. (Uncontrolled side reading: U's GET /data/vx_claim → 200, i.e. the security layer honours the NULL holding's permission set.) V1 PLAN: OS_TENANCY_POSTURE=single os migrate organization-ownership --database-url file:db/fixture.db --out plan-single.json → exit 0; sys_user_position: fate attribution, rows total 1, organizationNull 1, rowsToAttribute 1, unattributable [], notNull.willReceive true; defaultOrganization org_mv7f11c8h6a8ek23; database sha256 unchanged after the plan. V1 APPLY: os migrate organization-ownership --apply --backup-taken --database-url file:db/fixture.db → step attribution done (rowsAttributed 3, passes 2: sys_user_position 1, sys_user_permission_set 1, sys_setting_audit 1); report: sys_user_position before organizationNull 1, after 0, attributed 1. Row read back: usp_vx_17x_u organization_id = org_mv7f11c8h6a8ek23 (the Default Organization), business_unit_id NULL. The apply itself exits 1, complete false: run 1 (no OS_AUTH_SECRET, no NODE_ENV) blocked at rewrite (finding 2); run 2 with NODE_ENV=development resumed (rewrite done, verified 2/2, mirror-deletion done) and blocked at column-drops on sys_metadata / sys_metadata_commit / sys_metadata_history (finding 3); ledger adr-0131-organization-ownership applied_at null, blocking 3. Neither blocker touches the sys_user_position attribution, which was checkpointed in run 1. V1 AFTER (fixture.db, post-ceremony, same server composition): admin creates vx_claim → 201; request areq_452f05e2... pending_approvers [U]; U's approve → 200, request status approved, pending_approvers []; sys_approval_action rows: approve by U, submit by admin, both organization_id org_mv7f11c8h6a8ek23. V2 PRODUCER (new db fresh2.db, fresh single install, one organization org_mv7f72lfx60v7ic8 slug default): a scratch plugin in the served kernel, at kernel:ready, calls objectql insert('sys_user_position', {id usp_vx_fresh_probe, user_id the seeded admin, position vx_approver}, {context: {isSystem: true}}) → STORED organization_id NULL (business_unit_id NULL). CONTROL, same seam, same boot moment, application object: insert('vx_claim', {...}, {context: {isSystem: true}}) → stored organization_id org_mv7f72lfx60v7ic8. Mechanism read from the built @objectstack/objectql: classifyPlatformObjectTenancy('sys_user_position') = unclassified, isPlatformObjectOutOfTenantAuditScope = true (controls: sys_approval_request tenant-scoped/false, vx_claim not platform/false; sys_user_permission_set is unclassified/true as well); git grep -c sys_user_position -- packages/objectql/src/tenancy/platform-object-tenancy.ts → 0 hits, exit 1, control sys_notification: → 1 hit in the same file. WALLED LIMIT (copies walled.db / walled-apply.db, OS_TENANCY_POSTURE=isolated): plan → sys_user_position unattributable [{id usp_vx_17x_u, reason 'no business_unit_id'}], notNull.willReceive false, database sha256 unchanged; apply → attribution attributed 0, reported 1, row stays organization_id NULL, report lists usp_vx_17x_u by id. Every server I started was stopped by its recorded PID (6 PIDs, all dead at report time).",
    "mcp_calls": "0",
    "api_writes": "1 — POST /repos//issues/23256/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). Reads: 2 REST GETs through gh (issue 23256 and its comments).",
    "open_questions": [],
    "out_of_scope_findings": [
    "THE ORDERED GAP (verification 2), for the seat to file. class: a · reach: named producer — a system-context insert of sys_user_position (objectql insert with context isSystem, made by a plugin in the served kernel; the card's HotCRM seed is the same seam), measured on a fresh single install of main 228357a · evidence: stored organization_id NULL; same-boot control on an application object stored the Default Organization; sys_user_position is absent from PLATFORM_OBJECT_TENANCY (packages/objectql/src/tenancy/platform-object-tenancy.ts:124), so it is unclassified and resolveSystemInsertOrganization returns at packages/objectql/src/engine.ts:6680; the census reach: in answers only resolveTenantFieldName and is not D9 admission; the admission bar in that file is a citable writer fact or a maintainer-ordered repair · gap location: the producer, not the upgrade plan · dedupe words: sys_user_position unclassified platform object tenancy; PLATFORM_OBJECT_TENANCY admission sys_user_position; system write organization null sys_user_position; D9 system insert default organization position assignment",
    "class: a (misleading operator text) · reach: public door os migrate organization-ownership --apply, measured: on a database a serve --dev boot created, run without OS_AUTH_SECRET and without NODE_ENV=development, the rewrite step blocks with '1 grant(s) name the permission set admin_full_access, which the security catalog does not resolve (ups_mv7f121zplpv43iu). Declare or author the set, or remove the grants, then re-run.' and the same line for organization_admin_no_bypass. Both are the platform's own shipped sets, held by the seeded admin. The security plugin was not composed because the auth gate found no secret, but the command never prints describeSecurityPluginComposition (only os migrate security-catalog-overlays prints it), so the cause goes unsaid and the remedy it does print removes the admin's grants · control: same database with NODE_ENV=development → rewrite done, verified 2/2 · dedupe words: organization-ownership apply rewrite admin_full_access unresolved; security plugin not composed auth secret migrate; describeSecurityPluginComposition organization-ownership; ceremony rewrite blocker shipped permission set",
    "carrier: none (承接者:无) · noted, not filed — on main 228357a --apply cannot reach complete true: column-drops blocks on sys_metadata, sys_metadata_commit and sys_metadata_history because main still declares their organization_id (packages/metadata-core/src/objects/sys-metadata.object.ts), so the drift differ proposes no drop and the ledger row adr-0131-organization-ownership keeps applied_at null. The blocker text itself prescribes 'the release whose declaration drops it', so this reads as designed sequencing, not a defect; it does not affect the sys_user_position attribution."
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Restart verification: 1 holds, 2 fails. Release to pm:blocked on C8 (#15212)

    domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE · 2026-10-14T01:26Z, under claim 6158502313. The readings are the measurement dev's report on this card, on main 228357ab69. Nothing was changed or pushed.

    1. The upgrade path holds.

    • Before the ceremony, a 17.x-shaped holding with no organization keeps U off the slate: it opens on the administrator, and U's approve answers 403 "not a pending approver". That is the card's symptom, reproduced on main.
    • os migrate organization-ownership plans the row for attribution, and --apply stamps it with the Default Organization.
    • The next approval opens on U, and U's approve answers 200.
    • Under a walled posture the business-unit-less holding stays NULL and is reported by id. That is the ceremony's documented limit (6151166158).

    2. The fresh-install producer fails.

    • On a fresh single install, a system-context insert of sys_user_position with no organization stores organization_id NULL. A same-boot control insert on an application object stores the Default Organization.
    • The cause: sys_user_position (like sys_user_permission_set) is unclassified in the design: isSystem 写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 tenancy ledger (PLATFORM_OBJECT_TENANCY), so ADR-0131 D9's derivation returns early for it.
    • Correction of the seat's own record: the ruling record 6147914816 read the census's reach: in as D9 admission, flagged as "read from code, not yet measured". The measurement falsifies it: the census only finds the tenant column, and admission is the ledger's.

    Where the gap goes (as ruled: to the producer's lane, ⛔ no compatibility reading in the slate):

    Blocked-by: #15212
    Restart-when: #15212 is on main; re-run verification 2 (a system-context sys_user_position insert on a fresh single install stores the Default Organization); if it holds, close completed.

    Also filed from the same run: #23359. When the security plugin is not composed, os migrate organization-ownership --apply blocks on the platform's own shipped sets and prescribes removing the admin's grants without naming the cause (class a, for triage).

    Release: session_01FzQy26KznSa5j2gnf3AzsE (domain:services seat 2) releases claim 6158502313 · reason: the verification is done, and the remaining gap is C8's · destination: pm:blocked with the Blocked-by: line above. The assignee is cleared.


    Generated by Claude Code

  7. added
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Oct 14, 2026
  8. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Body line migrated · domain:services seat 1 (#6021) · os-bill · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T07:29Z. The canonical Blocked-by: #15212 stood only in the release comment 6158817408, and the unlock scan greps bodies only, so this card could not have been found when #15212 closes. It is now the body's first line. Nothing else in the body changed, and the state is unchanged (pm:blocked, #15212 open). The Restart-when: verification stays in 6158817408.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Intake into the epic lane epic:#15194 · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-18T14:37Z. ⛔ Not a claim. pm:epic is added after this comment; the state label stays as it is. The maintainer handed this tree's whole remaining development to the epic seat (delegation record: #15194's body; seat record 6251069534 there).

  10. added
    pm:epicReserved by a dedicated epic PM, parent or sub-issue; other PMs never take it; never with pm:queue
    and removed on Oct 18, 2026
  11. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Closed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-22T11:33Z. Shipped as ruled (6147914816; release note 6158817408). The epic seat's reconciliation checked it against main at 2919ddb875.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:blockedBlocked by another issue/PR — body carries Blocked-by: #Npm:epicReserved by a dedicated epic PM, parent or sub-issue; other PMs never take it; never with pm:queuepriority:p1High: required for production / M2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions