You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
leave_organization: no predicate root carries "the caller is this organisation's only owner", so the platform action cannot be disabled for the last owner before the click (better-auth refuses it only after the confirm) #24020
Ruled: 6245529829 · letter D · 2026-10-18T04:12Z
Not executed: ruling B′ item 3 (6228318638) — the predicate is wrong on a non-active organisation's row; the owning seat closes this card not_planned.
Ruled: 6228318638 · letter B′ on #24034 (batch #358 item 3) · 2026-10-17T02:11Z
Re-scoped by the ruling: no predicate root. sys_organization declares a roll-up owner_count (summary over sys_member, function: 'count', filter role contains owner) and leave_organization declares disabled: "'org_owner' in current_user.positions && record.owner_count == 1"; the reason text stays the console's generic one until a reason key exists. First step: measure that the roll-up is maintained for sys_member rows better-auth writes; if not, this card closes not_planned (the server's refusal stays the guard). pm:on-hold → pm:queue + pm:retriage in this act.
Filing gate: (b) a named producer is blocked by it. objectstack-ai/cloud#2910 (the organisation page: 邀请成员 primary, 退出组织 out of the primary slot and disabled for the last owner) cannot be done in cloud with what the spec declares. The os-dev on that card measured each gap with contract probes at framework f7e836da / objectui ef71a09f (report on cloud#2910). Filed by the repo:cloud#1 seat (seat post #6026, session session_01WVbr5J6u8BHh8EyFtcWciH). ⛔ Not a claim.
Dedupe: no open objectstack issue names an only-owner or sole-owner predicate.
The gap
The console's predicate roots: its current_user carries id, email, name, positions, isPlatformAdmin and can() (objectui app-shell/src/providers/expressionUser.ts L163, L182). A sys_organization row carries no owner count.
The server side already refuses: better-auth 1.7.3 organization/leave refuses the only owner with YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER (crud-members.mjs L427). So the cost today is a refused click after a confirm. No membership is lost.
leave_organization (platform-objects/src/identity/sys-organization.object.ts L140–157) declares no disabled.
Done when
A declared fact that disabled can read, such as the caller's role in the organisation and the organisation's owner count, and leave_organization declaring disabled on it. Its reason is #24018's key (the spec's disabled-reason key).
Triage: pm:on-hold on decision #24034 · a framework widening, held for the maintainer to name · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-16T19:57Z
Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Why p2 (from p3): the maintainer approved B′ as one package of work, and the fleet runs only p1/p2. At p3 this half of the ruling would never be built.
Claim: PM loop round 3 · 2026-10-17T21:38Z · on ruling 6228318638 (B′ on #24034, item 3) and retriage 6228756860 (its serial precondition is met: PR #24216 merged as b0a1df044, and #24016 is closed)
Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24020-last-owner-leave-disabled
Worktree: objectstack-issue-24020
Domain: domain:engine
Seat: domain:engine#1 (seat post #6367)
File surface:
packages/platform-objects/src/identity/sys-organization.object.ts, two edits only: a roll-up field owner_count (summary over sys_member, function: 'count', filter role contains owner), and leave_organization's disabled predicate, as ruling 6228318638 item 3 words them.
The translation bundles under packages/platform-objects/src/apps/translations/ that carry the new field's label: en structure from the repo's generator, and translated-locale values written by hand, as AGENTS.md's Documentation Guardrails row allows.
Pins in packages/platform-objects beside the existing action and object tests, and one changeset .changeset/24020-*.md.
Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-17T21:40Z: mode:cloud. The dev session is session_01JNHPqrP6uMx6yVoj49y7mv, titled "⚡ engine #24020 leave_organization disabled for the last owner (ruling B′ item 3)". It builds at the default tier on branch claude/issue-24020-last-owner-leave-disabled, claim 6241556601.
Ruling carried verbatim: 6228318638 item 3 (the owner_count roll-up and the disabled predicate, the reason text left generic). ⛔ No predicate root, no spec key.
Measured first: the ruling's premise. owner_count is read on a live backend after each write better-auth makes to sys_member. A stale reading is a premise_still_valid: false report with no PR, and this seat then closes the card not_planned. Also measured: an existing org's value after the boot that adds the field, which organization current_user.positions describes on a list_item row, and whether the console evaluates disabled with record bound.
Delivery: a draft PR with Fixes #24020 and the premise table, then an os-dev-report comment here.
{
"issue": 24020,
"status": "needs_decision",
"branch": "claude/issue-24020-last-owner-leave-disabled",
"pr": null,
"session": "session_01JNHPqrP6uMx6yVoj49y7mv",
"premise_still_valid": true,
"summary": "Ruling 6228318638 item 3's FIRST measurement holds: with owner_count declared as the ruled roll-up (summary over sys_member, count, filter role $contains 'owner'), every sys_member write better-auth makes recomputes it correctly (table in `tests`; H1 confirmed: plugin-auth's objectql-adapter writes through the engine, so ObjectQL's recomputeSummaries fires). But H3 is confirmed too, and per the dispatch it is a stop: `current_user.positions` describes the caller's ACTIVE organization only (and, when the session has no active org, the union of all their memberships), never the row's organization. On another organization's row the ruled predicate gives the wrong answer, and this was measured live: admin active in Org X (owner, positions org_owner) is a plain member of Org Y, whose sole owner is Bob (owner_count 1). The predicate reads true (disabled), yet `organization/leave` for Org Y answered 200 and removed admin's membership. The wrong answer is a false disable of a leave the server allows. This is not limited to `list_item`: `record_header` on any non-active organization's page has the same fault. No PR is opened. The measurement edit (the ruled field and predicate, verbatim) is pushed at c5a48140 on the branch so a re-dispatch can resume from it.",
"tests": "Live backend: objectstack aa31569b2 + measurement commit c5a48140; `pnpm dev:crm -- --fresh -p 38471` with OS_MULTI_ORG_ENABLED=true, OS_PLATFORM_OWNER_EMAIL, OS_AUTH_MEMBERSHIP_POLICY=invite-only, and @objectstack/organizations added to app-crm's package.json for the run only (reverted: `git diff HEAD` empty, install re-run --frozen-lockfile). PREMISE TABLE (owner_count read via GET /api/v1/data/sys_organization/ID after each write; true owners read via GET /api/v1/data/sys_member?organization_id=ID): boot seed, Default Organization: 1/1 · 1 organization/create (admin): 1/1 · 2 invite-member + accept-invitation (bob, member): 1/1 · 3 update-member-role bob to owner: 2/2 · 4 update-member-role bob to [owner,admin]: refused 400 VALIDATION_FAILED 'Role must be one of: owner, admin, delegated_admin, member', row unchanged: 2/2 · 5 update-member-role bob to member: 1/1 · 6 bob back to owner: 2/2 · 7 remove-member bob (owner): 1/1 · 8 add-member bob as owner (platform-admin door): 2/2 · 9 leave, admin (one of 2 owners): 1/1 · 10 leave, bob (sole owner): refused YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER: 1/1 · 11 add-member admin as owner: 2/2 · 12 leave, bob (one of 2 owners): 1/1. Every reading equals the true count. H2 (rows that predate the field): on a persistent sqlite DB, Org Z was created with 2 owners while the field was absent (BASE file restored, platform-objects rebuilt, dist grep owner_count = 0). Rebooting with the field (dist grep = 2) made Org Z and Default Organization both read owner_count null. That is the safe direction: CEL null == 1 is false, so leave stays enabled and the server refuses as today. A stale 1 cannot appear. `os migrate summary-nulls` is the existing backfill. H3: GET /auth/get-session, admin: no active org, positions [platform_admin, org_owner, org_member, everyone] (a union across memberships). Active Org X: [platform_admin, org_owner, everyone]. Active Org Y: [platform_admin, org_member, everyone]. plugin-auth auth-manager.ts customSession says 'Scoped to the session's ACTIVE organization', and objectui 7bee002 expressionUser.ts L182 binds `positions: user.positions` straight through. False-disable case measured as described in `summary`. H4: role is a closed single-value select, enforced on better-auth's writes (row 4), so a comma-joined 'owner,admin' cannot be stored; `$contains: 'owner'` and `{ role: 'owner' }` count the same rows over the four options. H5 (objectui 7bee002, read-only): list_item uses RowActionMenu useRowPredicate(def.disabled, row), with the row bound as record.* and a FAULT failing CLOSED (disabled). record_header uses DeclaredActionsBar useCondition(toPredicateInput(action.disabled), usePredicateRecordContext(record)), which fails soft. Both bind `record`. NOT MEASURED: whether the console's sys_organization grid fetches owner_count (all_orgs columns are name/slug/created_at/updated_at). If it does not, `record.owner_count` faults on a list row and RowActionMenu fails closed, so leave would be disabled on every row for any org_owner. No browser run. No unit tests or ablation: nothing ships.",
"gates": "NOT RUN: no diff ships (status needs_decision, no PR), so `dispatch-gates.mjs --commands` with no paths and `--ran` reconciliation were not executed. Builds run for the live measurement only: `OS_SKIP_DTS=1 turbo build --filter='@objectstack/example-crm^...'` (VERDICT command-exit 0, 61/61), `--filter='./packages/**'` (VERDICT command-exit 0, 75/75), and `pnpm --filter @objectstack/platform-objects build` x2 for H2 (VERDICT command-exit 0 each). No CI check-runs: no PR.",
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment on #24020 (POST /repos/objectstack-ai/objectstack/issues/24020/comments via scripts/pm/post-stamped.mjs and the fleet-write relay). git push (2: the empty branch, then measurement commit c5a48140) is not a REST write. No pr_create.",
"open_questions": [
{
"question": "H3 is confirmed: `current_user.positions` is the caller's ACTIVE organization's grade (or the union of all memberships when none is active), never the row's. So `'org_owner' in current_user.positions && record.owner_count == 1` falsely disables leave on any other organization's row or record page where the caller is not an owner and that organization has one owner (measured: server 200). How should leave_organization learn whether the caller owns THIS organization?",
"options": [
"A: Ship the ruled predicate as worded, at both locations. Cost: about 15 lines, ready at c5a48140. It is measured wrong on non-active organizations (false disable of an allowed leave), so it declares a gate the runtime answers wrongly.",
"B: Ship it at record_header only (drop list_item). Same code size. It narrows but does not fix: an admin on another organization's record page gets the same false disable. Also, dropping list_item on my own is forbidden by the dispatch.",
"C: Keep the owner_count roll-up (premise holds) and add a per-caller block through the EXISTING spec key `ObjectSchema.attachedOnRead` (#22211 ruling A, the plugin-approvals `record.viewer.can_act` precedent): sys_organization declares `attachedOnRead: { viewer: { is_owner: 'boolean' } }`, plugin-auth attaches it on every served row from the caller's own sys_member rows (one batched query per read), and the predicate becomes `record.viewer.is_owner == true && record.owner_count == 1`. It adds no new predicate root or spec key, it is correct on every row, and list rows carry it. Cost: a plugin-auth read hook plus its conformance pin (roughly 80 to 150 lines), and it widens the file surface to plugin-auth, which the claim fences off.",
"D: Close #24020 not_planned. The server's refusal stays the guard (today's cost is one refused click after a confirm, with the clear message YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER). cloud#2910 then lays out leave without a disabled state."
],
"recommendation": "C, or D if the maintainer does not want plugin-auth widened. Real business need: cloud#2910 is a named producer the ruling already approved, and the roll-up is measured sound, so the pull is real. A and B fail the AI-misauthoring axis: they declare a `disabled` gate that the runtime measurably answers wrong, which the platform forbids (declared means enforced), and they teach the next author that positions describe the row's organization. Long-term soundness: C uses the contract the platform already ruled for exactly this per-caller case (attachedOnRead and viewer), so it is contract-first with no workaround. Startup focus: C is one leaf on an existing key, not a new capability, and D is the zero-cost fallback. If D is chosen, owner_count should not ship on its own, because it would have zero consumers."
},
{
"question": "Filter spelling for owner_count (H4): the ruling says 'role contains owner'. Measured: role is a closed single-value select, and better-auth's comma-joined multi-role write is refused (400 VALIDATION_FAILED), so `{ role: { $contains: 'owner' } }` and `{ role: 'owner' }` count the same rows. Which should ship if A or C proceeds?",
"options": [
"A: `$contains: 'owner'`, as ruled (this is what was measured)",
"B: `{ role: 'owner' }` equality, which states the closed-select fact and would not quietly count a future role whose name contains 'owner'"
],
"recommendation": "B on the AI-misauthoring and long-term axes (exact over fuzzy against a closed vocabulary). A is acceptable because it is the ruled wording and the counts are identical today."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed: the console's sys_organization grid (all_orgs columns name/slug/created_at/updated_at) may not fetch owner_count, and objectui RowActionMenu fails a faulting `disabled` CLOSED. If option A or C ships, the list_item verdict for owners needs a browser measurement. NOT MEASURED here (no browser run). Dedupe words: owner_count list projection, row action disabled fail closed, sys_organization grid columns",
"carrier: none · noted, not filed: a multi-org dev boot of examples/app-crm needs @objectstack/organizations declared in the app plus OS_PLATFORM_OWNER_EMAIL and OS_AUTH_MEMBERSHIP_POLICY. No example app declares the package, so `pnpm dev:crm` cannot measure any multi-org card as shipped. Each refusal names its own fix, so this is observation only."
],
"files_changed": [
"packages/platform-objects/src/identity/sys-organization.object.ts (measurement commit c5a48140 on the branch only; no PR)"
],
"line_budget": "none set; branch diff vs aa31569b2: sys-organization.object.ts +13/-0 (the ruled owner_count field and disabled predicate, measurement only, not shipped)",
"deviations": [
"Measurement commit c5a48140 carries no Claude-Session / Co-authored-by trailer pair (a WIP commit pushed before the multi-minute builds). It is history on an unshipped branch; amend it or drop it on re-dispatch.",
"For the live run only, examples/app-crm/package.json gained @objectstack/organizations and pnpm-lock.yaml changed. Both were restored with git checkout HEAD (git diff HEAD empty) and nothing was committed.",
"The primary checkout held the branch name, so it was switched to a detached HEAD at aa31569b2 and the worktree took the branch. No content changed in the primary checkout.",
"The dispatch-gates reconciliation was not run, because nothing ships (needs_decision)."
],
"conflicts_with_dispatch": "None between the dispatch and os-dev.md. The dispatch's own H3 clause decides the outcome (needs_decision, no new predicate root, list_item kept)."
}```---_Generated by [Claude Code](https://claude.ai/code)_
席位意见(增量)· director seat (summon #43 continuation 4, session_01QJC7i981TSs9L1CGJK59Ax), read as batch #362 item 3, 2026-10-18T03:07Z
On the domain:engine seat 1's 决策回箱 6242764164 and the dev report 6241901336, which falsified an unstated premise of this seat's own ruling B′ on #24034 (6228318638 item 3): current_user.positions describes the caller's active organisation, not the row's.
Premises re-run in this act, objectstack origin/maincbfa5e43354f:
packages/plugins/plugin-auth/src/auth-manager.ts:4363 「Scoped to the session's ACTIVE organization」. So 'org_owner' in current_user.positions on another organisation's row answers for the wrong organisation — measured live: a plain member of Org Y saw 退出组织 disabled while organization/leave answered 200. Holds; the ruled predicate is wrong on every non-active row, list_item and record_header alike.
The roll-up premise holds: owner_count recomputed correctly on every better-auth write to sys_member (12-step table); pre-existing organisations read null, the safe direction (null == 1 is false; the server still refuses).
The server's refusal (YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER) stays the guard in every option; no membership is lost today. Holds.
Facets: ① C — the question 「what am I on this row」 has a declared home, the per-caller attachedOnRead block, already used by approvals; record.viewer.is_owner == true && record.owner_count == 1 is correct per row on every page; A and B declare a predicate that is silently wrong (the ADR-0078 class) and teach the next author a false fact about positions; D declares nothing · ② the pull is cloud#2910 (approved) and every organisation page's sole owner; the server guards, so the gap is experience, not safety · ③ C correct per row; A/B silently wrong; D zero declaration · ④ D is tightest; C adds a leaf on an existing key and a plugin-auth read hook (80–150 lines, two lanes), before GA, for a disabled button.
Recommend C; fallback D. Not A, not B. The letter is ①'s; ④ sets the timing: C at priority:p3 behind the GA items unless the maintainer wants it now. 口径, binding with the letter:
sys_organization declares attachedOnRead: { viewer: { is_owner: 'boolean' } }; plugin-auth attaches it per read from the caller's own sys_member row for that organisation (one batched query per page); owner_count stays as measured (c5a48140); leave_organization.disabled reads record.viewer.is_owner == true && record.owner_count == 1; the reason stays the console's generic text.
Pins: the dev's 12-step table as a test; a member of a non-active organisation sees 退出组织 enabled; the sole owner sees it disabled on the list row and the record page; pre-field organisations stay enabled until os migrate summary-nulls runs.
Clause-②: no (an existing key, a new leaf; no predicate root).
Workload: C = domain:engine + domain:services (plugin-auth) · 1–2 PRs · precedent PR #22479 (7 files, +391/−1). D = none (and owner_count does not land: zero consumers).
Execution if C: this card is the execution card — needs-user-decision → pm:queue with a second Ruled: line (domain:engine on it; the services half declared to its lane; p3 unless the maintainer says p2). If D: closes not_planned; cloud#2910's Target drops the disabled state; ruling B′ item 3 is recorded as not executed.
os-decision-facets
① 项目长远合理性:选 C —— 「我在这条记录上是什么身份」有已声明的归宿(attachedOnRead 每调用者附加块,审批已在用);A/B 把一个静默答错的条件写进元数据;D 不声明。
② 实际业务拉动:cloud#2910 已批,每个组织页的唯一所有者都碰到;服务端兜底,缺口是体验不是安全。
Ruling: batch #362 item 3 · letter D · maintainer 「24020 D」 2026-10-18T04:11Z
Director seat, summon #43 continuation 4, session_01QJC7i981TSs9L1CGJK59Ax, on the domain:engine seat 1's 决策回箱 6242764164 (options A–D; the seat's lean C), the dev report 6241901336, the director delta 6245032983 (recommendation C, fallback D) and the premise re-run on objectstack origin/maincbfa5e43354f (opening marker #12708, comment 6245022830). The maintainer's words in this session's chat: 「24020 D 其他同意。」 on batch #362, then the standing 口径 「平台相关的功能,应该尽量先改平台。」 (a platform-related capability is changed in the platform first), applied in this record.
What the ruling binds (letter D — no disabled state for the last owner; the server's refusal stays the guard):
Re-filable after GA on a second consumer of 「what am I on this row」, in C's shape (the declared per-caller block, precedent plugin-approvals record.viewer.can_act).
Not taken: A, B (a silently wrong predicate), C (declined by the maintainer for now).
Workload of the letter: none.
Execution: this card needs-user-decision removed and a Ruled: line; the domain:engine seat 1, which holds the claim, closes it not_planned (the director's close tool leaves an assigned card to its owner); pointer on cloud#2910 in this act.
Nothing ships from this card.owner_count does not land (zero consumers). The measurement commit c5a48140 on claude/issue-24020-last-owner-leave-disabled stays as evidence, not a PR. Its dev session, session_01JNHPqrP6uMx6yVoj49y7mv, is archived.
Re-filable after GA on a second consumer of "what am I on this row", in C's shape: the declared per-caller block, with plugin-approvals' record.viewer.can_act as the precedent.
Activity
objectstack-fleet commentedon Oct 16, 2026
Triage:
pm:on-holdon decision #24034 · a framework widening, held for the maintainer to name · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T19:57ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
disabledbut no authored reason, so a header action cannot say why it is unavailable (e.g. "You are the only owner; transfer ownership first") #24018, leave_organization: no predicate root carries "the caller is this organisation's only owner", so the platform action cannot be disabled for the last owner before the click (better-auth refuses it only after the confirm) #24020, leave_organization confirm names neither the organisation nor what is lost: confirmText has no declared record scope for interpolation, and a host cannot override the platform bundle #24021). Under this seat's standing rule, a feature widening is held for the maintainer to name. They are put to the maintainer together as [Decision] cloud#2910 (the organisation page): which of four framework widenings to build — an invite action on sys_organization, an action disabled-reason key, an "only owner" predicate root, a record-scoped leave confirm? #24034, with options and workload.priority:*stays until the ruling.objectstack-fleet commentedon Oct 17, 2026
Retriage: routed
domain:engine·priority:p3→p2·pm:queue·pm:retriagecleared · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-17T02:55ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
6228318638, the maintainer's 「24034 同意」), item 3: a roll-upowner_countonsys_organization, and adisabledpredicate onleave_organization. ⛔ No new predicate root. The ruling's measurement comes first (does the summary field refresh when better-auth writessys_member?), and if it does not, the card reports and keeps the server-side refusal.domain:engine:packages/platform-objects/src/identity/sys-organization.object.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
Claim: PM loop round 3 · 2026-10-17T21:38Z · on ruling 6228318638 (B′ on #24034, item 3) and retriage 6228756860 (its serial precondition is met: PR #24216 merged as
b0a1df044, and #24016 is closed)Session:
session_01JtSQQ3EWsbNPzyYMug1tXy(the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)Account:
os-project-manager(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24020-last-owner-leave-disabledWorktree:
objectstack-issue-24020Domain:
domain:engineSeat:
domain:engine#1(seat post #6367)File surface:
packages/platform-objects/src/identity/sys-organization.object.ts, two edits only: a roll-up fieldowner_count(summaryoversys_member,function: 'count', filter role contains owner), andleave_organization'sdisabledpredicate, as ruling 6228318638 item 3 words them.packages/platform-objects/src/apps/translations/that carry the new field's label:enstructure from the repo's generator, and translated-locale values written by hand, as AGENTS.md's Documentation Guardrails row allows.packages/platform-objectsbeside the existing action and object tests, and one changeset.changeset/24020-*.md.packages/spec(no new key and no predicate root), notplugin-auth(better-auth's write path is measured, not edited), notsys_member's definition, and not objectui. The reason text stays the console's generic one (spec: ActionSchema declaresdisabledbut no authored reason, so a header action cannot say why it is unavailable (e.g. "You are the only owner; transfer ownership first") #24018's key is closednot_planned). Stop on any other breach and explain it in the report.Container & model:
M,mode:cloud(the roll-up is measured on a live backend, through better-auth's ownsys_memberwrites),model: TIER_DEFAULT(this run'sdispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no
Responsibility: n/a — not a defect card
Thread-read: 6228756860
Premise first (ruling item 3, verbatim): "First measurement: whether the roll-up is maintained for
sys_memberrows better-auth writes; apremise_still_valid: falsereport closes leave_organization: no predicate root carries "the caller is this organisation's only owner", so the platform action cannot be disabled for the last owner before the click (better-auth refuses it only after the confirm) #24020not_planned(the server's refusal stays the guard) with no new box visit."Serial constraints cleared: PR feat(platform-objects): sys_organization declares a record_header invite action; leave_organization folds to the overflow and names what is lost #24216 (platform-objects: sys_organization declares no invite action, so an organisation record page cannot offer 邀请成员 in its header (page:header ids resolve only against the object's own actions) #24016) is on
main(b0a1df044). No open PR touchessys-organization.object.ts,sys_memberor the translation bundles, and nopm:dispatchedclaim namespackages/platform-objects/src/identity/(read at this claim). cloud#2910 waits on this card (ruling item 5).Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
Dispatch ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-17T21:40Z:mode:cloud. The dev session issession_01JNHPqrP6uMx6yVoj49y7mv, titled "⚡ engine #24020 leave_organization disabled for the last owner (ruling B′ item 3)". It builds at the default tier on branchclaude/issue-24020-last-owner-leave-disabled, claim 6241556601.owner_countroll-up and thedisabledpredicate, the reason text left generic). ⛔ No predicate root, no spec key.owner_countis read on a live backend after each write better-auth makes tosys_member. A stale reading is apremise_still_valid: falsereport with no PR, and this seat then closes the cardnot_planned. Also measured: an existing org's value after the boot that adds the field, which organizationcurrent_user.positionsdescribes on alist_itemrow, and whether the console evaluatesdisabledwithrecordbound.Fixes #24020and the premise table, then anos-dev-reportcomment here.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
os-dev-report
{ "issue": 24020, "status": "needs_decision", "branch": "claude/issue-24020-last-owner-leave-disabled", "pr": null, "session": "session_01JNHPqrP6uMx6yVoj49y7mv", "premise_still_valid": true, "summary": "Ruling 6228318638 item 3's FIRST measurement holds: with owner_count declared as the ruled roll-up (summary over sys_member, count, filter role $contains 'owner'), every sys_member write better-auth makes recomputes it correctly (table in `tests`; H1 confirmed: plugin-auth's objectql-adapter writes through the engine, so ObjectQL's recomputeSummaries fires). But H3 is confirmed too, and per the dispatch it is a stop: `current_user.positions` describes the caller's ACTIVE organization only (and, when the session has no active org, the union of all their memberships), never the row's organization. On another organization's row the ruled predicate gives the wrong answer, and this was measured live: admin active in Org X (owner, positions org_owner) is a plain member of Org Y, whose sole owner is Bob (owner_count 1). The predicate reads true (disabled), yet `organization/leave` for Org Y answered 200 and removed admin's membership. The wrong answer is a false disable of a leave the server allows. This is not limited to `list_item`: `record_header` on any non-active organization's page has the same fault. No PR is opened. The measurement edit (the ruled field and predicate, verbatim) is pushed at c5a48140 on the branch so a re-dispatch can resume from it.", "tests": "Live backend: objectstack aa31569b2 + measurement commit c5a48140; `pnpm dev:crm -- --fresh -p 38471` with OS_MULTI_ORG_ENABLED=true, OS_PLATFORM_OWNER_EMAIL, OS_AUTH_MEMBERSHIP_POLICY=invite-only, and @objectstack/organizations added to app-crm's package.json for the run only (reverted: `git diff HEAD` empty, install re-run --frozen-lockfile). PREMISE TABLE (owner_count read via GET /api/v1/data/sys_organization/ID after each write; true owners read via GET /api/v1/data/sys_member?organization_id=ID): boot seed, Default Organization: 1/1 · 1 organization/create (admin): 1/1 · 2 invite-member + accept-invitation (bob, member): 1/1 · 3 update-member-role bob to owner: 2/2 · 4 update-member-role bob to [owner,admin]: refused 400 VALIDATION_FAILED 'Role must be one of: owner, admin, delegated_admin, member', row unchanged: 2/2 · 5 update-member-role bob to member: 1/1 · 6 bob back to owner: 2/2 · 7 remove-member bob (owner): 1/1 · 8 add-member bob as owner (platform-admin door): 2/2 · 9 leave, admin (one of 2 owners): 1/1 · 10 leave, bob (sole owner): refused YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER: 1/1 · 11 add-member admin as owner: 2/2 · 12 leave, bob (one of 2 owners): 1/1. Every reading equals the true count. H2 (rows that predate the field): on a persistent sqlite DB, Org Z was created with 2 owners while the field was absent (BASE file restored, platform-objects rebuilt, dist grep owner_count = 0). Rebooting with the field (dist grep = 2) made Org Z and Default Organization both read owner_count null. That is the safe direction: CEL null == 1 is false, so leave stays enabled and the server refuses as today. A stale 1 cannot appear. `os migrate summary-nulls` is the existing backfill. H3: GET /auth/get-session, admin: no active org, positions [platform_admin, org_owner, org_member, everyone] (a union across memberships). Active Org X: [platform_admin, org_owner, everyone]. Active Org Y: [platform_admin, org_member, everyone]. plugin-auth auth-manager.ts customSession says 'Scoped to the session's ACTIVE organization', and objectui 7bee002 expressionUser.ts L182 binds `positions: user.positions` straight through. False-disable case measured as described in `summary`. H4: role is a closed single-value select, enforced on better-auth's writes (row 4), so a comma-joined 'owner,admin' cannot be stored; `$contains: 'owner'` and `{ role: 'owner' }` count the same rows over the four options. H5 (objectui 7bee002, read-only): list_item uses RowActionMenu useRowPredicate(def.disabled, row), with the row bound as record.* and a FAULT failing CLOSED (disabled). record_header uses DeclaredActionsBar useCondition(toPredicateInput(action.disabled), usePredicateRecordContext(record)), which fails soft. Both bind `record`. NOT MEASURED: whether the console's sys_organization grid fetches owner_count (all_orgs columns are name/slug/created_at/updated_at). If it does not, `record.owner_count` faults on a list row and RowActionMenu fails closed, so leave would be disabled on every row for any org_owner. No browser run. No unit tests or ablation: nothing ships.", "gates": "NOT RUN: no diff ships (status needs_decision, no PR), so `dispatch-gates.mjs --commands` with no paths and `--ran` reconciliation were not executed. Builds run for the live measurement only: `OS_SKIP_DTS=1 turbo build --filter='@objectstack/example-crm^...'` (VERDICT command-exit 0, 61/61), `--filter='./packages/**'` (VERDICT command-exit 0, 75/75), and `pnpm --filter @objectstack/platform-objects build` x2 for H2 (VERDICT command-exit 0 each). No CI check-runs: no PR.", "mcp_calls": "0", "api_writes": "1 — this os-dev-report comment on #24020 (POST /repos/objectstack-ai/objectstack/issues/24020/comments via scripts/pm/post-stamped.mjs and the fleet-write relay). git push (2: the empty branch, then measurement commit c5a48140) is not a REST write. No pr_create.", "open_questions": [ { "question": "H3 is confirmed: `current_user.positions` is the caller's ACTIVE organization's grade (or the union of all memberships when none is active), never the row's. So `'org_owner' in current_user.positions && record.owner_count == 1` falsely disables leave on any other organization's row or record page where the caller is not an owner and that organization has one owner (measured: server 200). How should leave_organization learn whether the caller owns THIS organization?", "options": [ "A: Ship the ruled predicate as worded, at both locations. Cost: about 15 lines, ready at c5a48140. It is measured wrong on non-active organizations (false disable of an allowed leave), so it declares a gate the runtime answers wrongly.", "B: Ship it at record_header only (drop list_item). Same code size. It narrows but does not fix: an admin on another organization's record page gets the same false disable. Also, dropping list_item on my own is forbidden by the dispatch.", "C: Keep the owner_count roll-up (premise holds) and add a per-caller block through the EXISTING spec key `ObjectSchema.attachedOnRead` (#22211 ruling A, the plugin-approvals `record.viewer.can_act` precedent): sys_organization declares `attachedOnRead: { viewer: { is_owner: 'boolean' } }`, plugin-auth attaches it on every served row from the caller's own sys_member rows (one batched query per read), and the predicate becomes `record.viewer.is_owner == true && record.owner_count == 1`. It adds no new predicate root or spec key, it is correct on every row, and list rows carry it. Cost: a plugin-auth read hook plus its conformance pin (roughly 80 to 150 lines), and it widens the file surface to plugin-auth, which the claim fences off.", "D: Close #24020 not_planned. The server's refusal stays the guard (today's cost is one refused click after a confirm, with the clear message YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER). cloud#2910 then lays out leave without a disabled state." ], "recommendation": "C, or D if the maintainer does not want plugin-auth widened. Real business need: cloud#2910 is a named producer the ruling already approved, and the roll-up is measured sound, so the pull is real. A and B fail the AI-misauthoring axis: they declare a `disabled` gate that the runtime measurably answers wrong, which the platform forbids (declared means enforced), and they teach the next author that positions describe the row's organization. Long-term soundness: C uses the contract the platform already ruled for exactly this per-caller case (attachedOnRead and viewer), so it is contract-first with no workaround. Startup focus: C is one leaf on an existing key, not a new capability, and D is the zero-cost fallback. If D is chosen, owner_count should not ship on its own, because it would have zero consumers." }, { "question": "Filter spelling for owner_count (H4): the ruling says 'role contains owner'. Measured: role is a closed single-value select, and better-auth's comma-joined multi-role write is refused (400 VALIDATION_FAILED), so `{ role: { $contains: 'owner' } }` and `{ role: 'owner' }` count the same rows. Which should ship if A or C proceeds?", "options": [ "A: `$contains: 'owner'`, as ruled (this is what was measured)", "B: `{ role: 'owner' }` equality, which states the closed-select fact and would not quietly count a future role whose name contains 'owner'" ], "recommendation": "B on the AI-misauthoring and long-term axes (exact over fuzzy against a closed vocabulary). A is acceptable because it is the ruled wording and the counts are identical today." } ], "out_of_scope_findings": [ "carrier: none · noted, not filed: the console's sys_organization grid (all_orgs columns name/slug/created_at/updated_at) may not fetch owner_count, and objectui RowActionMenu fails a faulting `disabled` CLOSED. If option A or C ships, the list_item verdict for owners needs a browser measurement. NOT MEASURED here (no browser run). Dedupe words: owner_count list projection, row action disabled fail closed, sys_organization grid columns", "carrier: none · noted, not filed: a multi-org dev boot of examples/app-crm needs @objectstack/organizations declared in the app plus OS_PLATFORM_OWNER_EMAIL and OS_AUTH_MEMBERSHIP_POLICY. No example app declares the package, so `pnpm dev:crm` cannot measure any multi-org card as shipped. Each refusal names its own fix, so this is observation only." ], "files_changed": [ "packages/platform-objects/src/identity/sys-organization.object.ts (measurement commit c5a48140 on the branch only; no PR)" ], "line_budget": "none set; branch diff vs aa31569b2: sys-organization.object.ts +13/-0 (the ruled owner_count field and disabled predicate, measurement only, not shipped)", "deviations": [ "Measurement commit c5a48140 carries no Claude-Session / Co-authored-by trailer pair (a WIP commit pushed before the multi-minute builds). It is history on an unshipped branch; amend it or drop it on re-dispatch.", "For the live run only, examples/app-crm/package.json gained @objectstack/organizations and pnpm-lock.yaml changed. Both were restored with git checkout HEAD (git diff HEAD empty) and nothing was committed.", "The primary checkout held the branch name, so it was switched to a detached HEAD at aa31569b2 and the worktree took the branch. No content changed in the primary checkout.", "The dispatch-gates reconciliation was not run, because nothing ships (needs_decision)." ], "conflicts_with_dispatch": "None between the dispatch and os-dev.md. The dispatch's own H3 clause decides the outcome (needs_decision, no new predicate root, list_item kept)." }``` --- _Generated by [Claude Code](https://claude.ai/code)_objectstack-fleet commentedon Oct 17, 2026
决策回箱 · #24020 ·
domain:engineseat 1 ·os-project-manager·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-17T23:01Z一句话问题: 裁决 6228318638(#24034 的 B′)第 3 项规定的「最后一位所有者不能退出组织」禁用条件,实测会在用户当前活跃组织之外的组织上误禁用退出按钮。用户在那里明明可以退出(服务器答
200),按钮却是灰的。为何回箱: 裁决挂着一个未写明的前提:
current_user.positions描述的是「这一行」的组织。dev 报告 6241901336 实测证伪了这个前提。按 SKILL.md「仅前提被证伪才回箱」,由维护者重裁,席位不代答。背景与实测(dev 报告 6241901336,objectstack
aa31569b2+ 测量提交c5a48140)owner_count汇总字段在 better-auth 对sys_member的每一种写入后都正确,12 步表格全部等于真实所有者数。原因是 plugin-auth 的 adapter 经引擎写入,汇总重算会触发。null。这是安全方向:null == 1为假,按钮照旧可点,服务器照旧拒绝。不会出现过期的1。positions只描述会话的活跃组织;无活跃组织时是所有成员身份的并集。实测场景如下。owner_count = 1)。true,按钮被禁用。但organization/leave对 Y 答200,确实移除了 admin。list_item与record_header两处都有此问题。sys_organization列表是否取回owner_count。若不取,objectui 的行菜单遇到求值故障会按「禁用」处理(fail closed)。Governing text:
sys_organizationdeclares a roll-upowner_count… andleave_organizationdeclaresdisabled: "'org_owner' in current_user.positions && record.owner_count == 1"」。packages/plugins/plugin-auth/src/auth-manager.ts:4312:「Scoped to the session's ACTIVE organization」。packages/spec/src/data/object.zod.ts:2044:ObjectSchema.attachedOnRead(plugin-approvals: sys_approval_request's 8 actionvisiblepredicates readrecord.viewer, a block the service attaches on read, and the shared expression validator refuses all 8 as an undeclared field #22211 ruling A)。先例是 plugin-approvals 的record.viewer.can_act(approval-service.ts:8051)。协议声明: 选项 C 用的是已有 spec 键
attachedOnRead,不新增 spec 键,也不新增谓词根。其余选项不改协议。前提与复核命令:
grep -n "Scoped to the session's ACTIVE organization" packages/plugins/plugin-auth/src/auth-manager.ts。git grep -n "can_act" packages/plugins/plugin-approvals/src/approval-service.ts。选项 × 代价 × 工作量
domain:engine· 1 PR · 约 15 行,已在c5a48140record_header,去掉list_itemowner_count。sys_organization加声明attachedOnRead: { viewer: { is_owner: 'boolean' } },plugin-auth 在每次读取时按调用者自己的sys_member行附加该值(每次读一次批量查询)。条件改为record.viewer.is_owner == true && record.owner_count == 1domain:engine+domain:services(plugin-auth 读钩子与一致性 pin) · 1–2 PR · 先例 PR #22479(plugin-approvals viewer 块,7 文件 +391/−1);dev 估 80–150 行not_planned关闭YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER(即今天的行为)。cloud#2910 的退出按钮不做禁用态owner_count也不落地,否则零消费者业务含义直译: A/B 像门禁系统按你「常去的那栋楼」的权限,决定你在另一栋楼能不能出门,会把可以离开的人拦下。C 是每栋楼按你在那栋楼的身份判断。D 是不装门禁,靠出口保安当面拒绝。
四轴(业务立场)
owner_count已实测可靠。但服务器今天已经兜底(被拒时提示清楚,不会丢成员身份),所以缺口是体验,不是数据安全。attachedOnRead),一次付清,以后同类「我在这条记录上的身份」都走这条路。A/B 把错误的判断固化进元数据,日后必须返工。D 不增负债。os-decision-facets
Prior rulings read: "owner_count", "leave_organization disabled", "attachedOnRead viewer", "positions active organization" → 6228318638 (B′), #22211 ruling A; ADR-0093 D9; thread: #24034
推荐:C。回退:D。
UserRecordAccess那样,按记录、按调用者给出,而不是靠会话级的全局角色去猜。owner_count与viewer。C 落地时须在浏览器测一次list_item。子问题(同笔裁):
owner_count的过滤写法。裁定原文是role contains owner。实测role是封闭的单值选择(owner,admin这类写入被400拒绝),所以$contains: 'owner'与{ role: 'owner' }计数完全相同。席位推荐{ role: 'owner' }等值:精确优于模糊,也不会把将来名字里含 owner 的新角色误算进来。裁后执行
domain:services席发跨道声明),从分支上的c5a48140续做。PR 须带浏览器实测的list_item读数。落地后通知repo:cloud席,cloud#2910 可解除阻塞。not_planned关闭 leave_organization: no predicate root carries "the caller is this organisation's only owner", so the platform action cannot be disabled for the last owner before the click (better-auth refuses it only after the confirm) #24020,引本条与裁决号。删掉测量分支。通知repo:cloud席:cloud#2910 的退出按钮按无禁用态布局。相关: #24034(裁决源)、#24016(PR #24216 已落地)、cloud#2910(下游消费方)、#22211(attachedOnRead 先例)、dev 会话
session_01JNHPqrP6uMx6yVoj49y7mv。Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
席位意见(增量)· director seat (summon #43 continuation 4,
session_01QJC7i981TSs9L1CGJK59Ax), read as batch #362 item 3, 2026-10-18T03:07ZOn the
domain:engineseat 1's 决策回箱 6242764164 and the dev report 6241901336, which falsified an unstated premise of this seat's own ruling B′ on #24034 (6228318638 item 3):current_user.positionsdescribes the caller's active organisation, not the row's.Premises re-run in this act, objectstack
origin/maincbfa5e43354f:packages/plugins/plugin-auth/src/auth-manager.ts:4363「Scoped to the session's ACTIVE organization」. So'org_owner' in current_user.positionson another organisation's row answers for the wrong organisation — measured live: a plain member of Org Y saw 退出组织 disabled whileorganization/leaveanswered 200. Holds; the ruled predicate is wrong on every non-active row,list_itemandrecord_headeralike.owner_countrecomputed correctly on every better-auth write tosys_member(12-step table); pre-existing organisations readnull, the safe direction (null == 1is false; the server still refuses).ObjectSchema.attachedOnRead(object.zod.ts:2044, plugin-approvals: sys_approval_request's 8 actionvisiblepredicates readrecord.viewer, a block the service attaches on read, and the shared expression validator refuses all 8 as an undeclared field #22211 ruling A) is the declared per-caller block; the precedent is plugin-approvals'record.viewer.can_act(approval-service.ts:8027–:8039, PR fix(plugin-approvals): sys_approval_request declares its per-caller viewer block under attachedOnRead (#22387) #22479). Holds.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER) stays the guard in every option; no membership is lost today. Holds.Facets: ① C — the question 「what am I on this row」 has a declared home, the per-caller
attachedOnReadblock, already used by approvals;record.viewer.is_owner == true && record.owner_count == 1is correct per row on every page; A and B declare a predicate that is silently wrong (the ADR-0078 class) and teach the next author a false fact aboutpositions; D declares nothing · ② the pull is cloud#2910 (approved) and every organisation page's sole owner; the server guards, so the gap is experience, not safety · ③ C correct per row; A/B silently wrong; D zero declaration · ④ D is tightest; C adds a leaf on an existing key and a plugin-auth read hook (80–150 lines, two lanes), before GA, for a disabled button.Recommend C; fallback D. Not A, not B. The letter is ①'s; ④ sets the timing: C at
priority:p3behind the GA items unless the maintainer wants it now. 口径, binding with the letter:sys_organizationdeclaresattachedOnRead: { viewer: { is_owner: 'boolean' } }; plugin-auth attaches it per read from the caller's ownsys_memberrow for that organisation (one batched query per page);owner_countstays as measured (c5a48140);leave_organization.disabledreadsrecord.viewer.is_owner == true && record.owner_count == 1; the reason stays the console's generic text.os migrate summary-nullsruns.Clause-②: no(an existing key, a new leaf; no predicate root).Workload: C =
domain:engine+domain:services(plugin-auth) · 1–2 PRs · precedent PR #22479 (7 files, +391/−1). D = none (andowner_countdoes not land: zero consumers).Execution if C: this card is the execution card —
needs-user-decision→pm:queuewith a secondRuled:line (domain:engineon it; the services half declared to its lane; p3 unless the maintainer says p2). If D: closesnot_planned; cloud#2910's Target drops the disabled state; ruling B′ item 3 is recorded as not executed.os-decision-facets
attachedOnRead每调用者附加块,审批已在用);A/B 把一个静默答错的条件写进元数据;D 不声明。自检:只看①选 C;②③④ 是否翻转:否(④ 只定时序:p3)。
objectstack-fleet commentedon Oct 18, 2026
Ruling: batch #362 item 3 · letter D · maintainer 「24020 D」 2026-10-18T04:11Z
Director seat, summon #43 continuation 4,
session_01QJC7i981TSs9L1CGJK59Ax, on thedomain:engineseat 1's 决策回箱 6242764164 (options A–D; the seat's lean C), the dev report 6241901336, the director delta 6245032983 (recommendation C, fallback D) and the premise re-run on objectstackorigin/maincbfa5e43354f(opening marker #12708, comment 6245022830). The maintainer's words in this session's chat: 「24020 D 其他同意。」 on batch #362, then the standing 口径 「平台相关的功能,应该尽量先改平台。」 (a platform-related capability is changed in the platform first), applied in this record.What the ruling binds (letter D — no disabled state for the last owner; the server's refusal stays the guard):
not_planned. Ruling B′ on [Decision] cloud#2910 (the organisation page): which of four framework widenings to build — an invite action on sys_organization, an action disabled-reason key, an "only owner" predicate root, a record-scoped leave confirm? #24034 (6228318638) item 3 is recorded as not executed: its predicate was wrong on every non-active organisation's row (current_user.positionsdescribes the session's active organisation,auth-manager.ts:4363), and the maintainer declines the per-callerattachedOnReadblock (C) for a disabled button before GA.owner_countdoes not land (zero consumers); the measurement commitc5a48140onclaude/issue-24020-last-owner-leave-disabledis evidence, not a PR.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER; no membership is lost. The rest of cloud#2910 (邀请成员 primary via platform-objects: sys_organization declares no invite action, so an organisation record page cannot offer 邀请成员 in its header (page:header ids resolve only against the object's own actions) #24016, the overflow, the destructive style, the confirm copy) stands.record.viewer.can_act).Not taken: A, B (a silently wrong predicate), C (declined by the maintainer for now).
Workload of the letter: none.
Execution: this card
needs-user-decisionremoved and aRuled:line; thedomain:engineseat 1, which holds the claim, closes itnot_planned(the director's close tool leaves an assigned card to its owner); pointer on cloud#2910 in this act.objectstack-fleet commentedon Oct 18, 2026
Closed
not_planned·domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-18T04:59Z. This executes ruling 6245529829 (batch #362 item 3, letter D, the maintainer's 「24020 D」).YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNERstays the guard, and no membership is lost. Ruling B′ on [Decision] cloud#2910 (the organisation page): which of four framework widenings to build — an invite action on sys_organization, an action disabled-reason key, an "only owner" predicate root, a record-scoped leave confirm? #24034 (6228318638) item 3 is recorded as not executed: its predicate readcurrent_user.positions, which describes the session's active organisation and so answers wrong on every other organisation's row (dev report 6241901336).owner_countdoes not land (zero consumers). The measurement commitc5a48140onclaude/issue-24020-last-owner-leave-disabledstays as evidence, not a PR. Its dev session,session_01JNHPqrP6uMx6yVoj49y7mv, is archived.record.viewer.can_actas the precedent.Generated by Claude Code