Skip to content

revoke a session whose membership ended, so "Remove member" actually signs the person out — the courtesy half of #15409, ⛔ never the enforcement #15784

Closed

Description

@hotlong

The courtesy half of the ruling on #15409 (maintainer, 2026-09-05, verbatim 「同意」 adopting "B as the boundary, C later as the courtesy"). ⛔ This is never the enforcement. #15409 closes the hole per-request; this card only makes the product behave the way an admin expects. (The former Blocked-by: #15409 line was removed after #15409 closed completed via PR #15794.)

What an admin expects, and does not get

When someone clicks Remove member, they expect that person to be signed out. Today the membership row is deleted and the session lives on — up to 7 days by default (auth-manager.ts:1626, expiresIn default 60*60*24*7). After #15409 lands, the removed member can no longer read or write that organization's data; they are simply still signed in, with no active organization. That is safe, and it is still not what the admin was told happened.

Why the machinery is already there

sys_session.revoked_at / revoke_reason are declared and system-managed. Revoking expires the session in place (expires_at set to the past plus the reason stamped), so better-auth returns no session on the next request and the Console's existing 401 → login redirect handles it — no client change. engine.ts:8041 already writes revoked_at.

⚠️ What is new here, and why it is filed separately rather than folded into #15409: every existing reason is a timer — the field's own description enumerates idle_timeout, absolute_max, concurrent_cap, …. None is event-driven. "Membership ended" would be the first authorization-event trigger on this mechanism, and an event trigger covers exactly the paths someone remembered to wire: better-auth's /organization/remove-member, a direct sys_member delete, a seed replay, a bulk operation, an admin tool, a control-plane operation. A missed path fails open, silently.

⇒ That is precisely why it is not the wall. #15409's per-request check covers every removal path by construction. This card is layered on top of a hole that is already closed, so a missed trigger costs a stale login session — not access.

Scope

  1. Enumerate the removal paths by measurement, not by memory — every writer that can delete or invalidate a sys_member row, in this repo and in cloud's packages/organizations. Attach the census with its firing control.
  2. Revoke the affected user's sessions for that organization's claim when a membership ends, through the existing mechanism, with a new revoke_reason naming the cause.
  3. ⛔ Do not weaken, bypass or "optimise away" [decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409's per-request check on the strength of this trigger existing. If a future reader is tempted, the comment beside the check should say why: a trigger can be missed, an evaluation cannot.
  4. Decide and record what happens to a user who holds other memberships: revoking every session logs them out of organizations they are still legitimately in. Re-pointing activeOrganizationId instead may be kinder — that is a product call and belongs on this card before implementation.

Acceptance

An admin removes a member through better-auth's own endpoint while that member has a live session; their next request is unauthenticated and the Console redirects to login. Controls: a member of a different organization is unaffected; an intact member's session is untouched; and the #15409 pins still hold — ⛔ the per-request check must not have been removed in the process.

Refs: #15409 (the boundary, and the ruling) · #15256 (the API-key arm) · cloud#1982 / #15396 (the measurements) · sys-session.object.ts (the existing mechanism).

Activity

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Sep 5, 2026
  2. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    Maintainer ruling — decision batch #49, item 4: B — act on the organization's claim, never on the user

    Recorded by the director seat (session session_01LsEjuNMPitCHwEfYftZ1um) from the maintainer's live reply to batch #49, verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」 — this card is covered by 「其他同意」, i.e. approved as presented: option B.

    ⚠️ Record-keeping gap, named rather than hidden: this card was moved to needs-user-decision at 09:26Z with no recording comment (the comment thread was empty when it was presented). It was presented anyway because scope item 4 is a genuine product call; whoever flipped it should leave a one-line note next time.

    The ruling

    Option B. The unit of action is the organization's claim, not the user — the same principle #15409 landed (ruling B there: drop the unbacked claim, do not refuse the principal):

    • A session whose activeOrganizationId points at the organization the membership just ended in loses that claim: it is cleared, or re-pointed to a membership the user still holds. A user who still holds other memberships is not signed out of them; they switch to an organization they still belong to.
    • A user with no remaining membership has nothing left to be signed into; for that user the session is revoked through the existing sys_session.revoked_at / revoke_reason mechanism with a new event-driven reason naming the cause, and the card's acceptance (next request unauthenticated, Console redirects to login) applies to exactly this case. ⚠️ This is the seat's application of the approved option where the option text is silent; the maintainer can flip it in one line.
    • ⛔ A (revoke every session of the user) is rejected — it logs people out of organizations they legitimately belong to. ⛔ C (do nothing) is rejected — the admin was told the person was removed.
    • Scope item 3 stands verbatim: ⛔ never weaken, bypass or optimise away [decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409's per-request check on the strength of this trigger; the comment beside the check says why (a trigger can be missed, an evaluation cannot).

    Execution

    Labels: needs-user-decision → pm:queue; domain:services, security, priority:p2 stay.


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 5, 2026
  4. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    The record-keeping gap named in the ruling above is mine, and it is correctly named.

    I flipped this card to needs-user-decision at 09:26Z inside a batch of label writes and left no comment on this card at all — verified: zero. My reasoning was in the session chat and in #15409's landing readback, and neither is visible to a seat opening this card. That is exactly the "结论只在 chat" half-state this loop has spent the day correcting in other people's cards, and the director seat had to reconstruct the intent to present it. ⇒ It was presented anyway and ruled, so nothing was lost this time; the discipline was.

    The note I should have left, recorded now for the thread's own history:

    Moved to needs-user-decision because scope item 4 is a product call the implementer must not make for the maintainer: what happens to a user who holds other memberships. Revoking every session logs them out of organizations they are still legitimately in; re-pointing activeOrganizationId is kinder but has to say to which. The card is otherwise fully specified and its enforcement half (#15409) had just landed, so this was the only thing standing between it and dispatch.

    Acknowledged and unchanged: the ruling stands as recorded — B, act on the organization's claim, never on the user, with the seat's application where the option text is silent (re-point when another membership remains, revoke when none does) flagged there as flippable in one line. Dispatching on that basis now.

  5. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop — maintainer direct-dispatch channel (2026-08-10 standing authorization); this card is the courtesy half of the #15409 family this session has carried end to end.
    Provenance: maintainer, decision batch #49 item 4, verbatim 「其他同意」 adopting B as presented (recorded by the director seat, comment 5551086286); and the maintainer's instruction to this session, 2026-09-05, 「同意」 to "B as the boundary, C later as the courtesy".
    Session: 6679d191-11f4-465b-b322-0e0409d76793
    Branch: claude/issue-15784-revoke-session-on-membership-end
    Worktree: objectstack-issue-15784
    Domain: domain:services (per the ruling).
    File surface: the membership-removal writers named by the census, plus sys_session's revoke_reason vocabulary in packages/spec and the implementing package. ⛔ packages/core/src/security/resolve-authz-context.ts is out of surface — #15409's per-request check is not touched.
    Container & model: M, mode:subagent, model: opus.
    Clause-②: yes — a new revoke_reason value on a published system object is an accept-set widening; re-judged from the diff at review.
    Serial constraints cleared: #15409 closed completed (PR #15794 merged 09:25Z); no open PR touches sys-session.object.ts or the membership-removal paths (REST search 2026-09-05).

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 5, 2026
  7. self-assigned this
    on Sep 5, 2026
  8. added a commit that references this issue on Sep 5, 2026
  9. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    Scope item 1 — the membership-removal census, with its firing controls

    Posted before the trigger is wired, as the ruling requires. Measured against
    objectstack 59953d5a3 (this branch's base) and cloud 3856fbf7c.

    Three readings, each with its own control. Where a control is dead or over-firing the
    run reports nothing — that is enforced in the detector, not promised in prose.

    Reading A — writers that NAME the table

    Detector: a windowed scan (4 lines) for a write verb whose argument names the member
    object — sys_member, SystemObjectName.MEMBER, MEMBERSHIP_OBJECT,
    AUTH_MEMBER_SCHEMA — across delete / deleteMany / bulkDelete / deleteOne /
    remove / destroy (ending the row) and update / updateMany / bulkUpdate /
    upsert / replace (invalidating it), plus raw-SQL DELETE FROM / TRUNCATE /
    DROP TABLE shapes and the declarative deleteBehavior: 'cascade' shape.

    Firing control: a synthetic file carrying one instance of every shape.

    CONTROL shapes lit: cascade:deleteBehavior, delete:bulkDelete, delete:delete,
                        delete:deleteMany, invalidate:update, invalidate:updateMany, raw-sql:sql
    CONTROL VERDICT: FIRING — all 7 shapes lit
    

    Results:

    root files production hits
    objectstack/packages 5517 3
    cloud/packages 755 0

    The three, in full:

    • platform-objects/src/identity/sys-member.object.ts:263 — deleteBehavior: 'cascade'
      on user_id. A sys_user delete takes its memberships (line 255 is the same token
      inside the comment above it).
    • plugins/plugin-auth/src/adopt-membership.ts:230 — engine.update(SystemObjectName.MEMBER, …),
      invitation acceptance adopting an existing row. It re-points nothing that ends a
      membership; recorded for completeness.

    Zero delete call sites that name the table. That is a true answer to a misleading
    question, and Reading B exists because of it.

    Reading B — the GENERIC doors (the ones that never spell sys_member)

    better-auth's ObjectQL adapter deletes through a variable object name —
    dataEngine.delete(objectName, …), objectql-adapter.ts:1023/1039/1056/1180/1188 — so
    the dominant removal path is invisible to Reading A by construction. This reading
    enumerates deletes whose object argument is a runtime expression, then classifies each by
    which abstraction the receiver holds: the ObjectQL engine (every registered hook
    fires) or a raw driver (hooks do not).

    Firing control: four positive shapes plus two negatives that must NOT be reported (a
    literal-named delete, and a Map.delete on a cache). The first run of this detector
    failed its own control by over-firing on membershipCache.delete(cacheKey); the
    leaf-name test was tightened and re-run.

    CONTROL positives lit: bulkDelete/driver-or-raw, delete/engine,
                           deleteMany/driver-or-raw, deleteMany/engine  (n=4)
    CONTROL negatives reported (must be 0): 0
    CONTROL VERDICT: FIRING and not over-firing
    

    The doors that can carry a membership row:

    # site abstraction reaches an engine hook?
    1 plugin-auth/objectql-adapter.ts:1023,1039,1056,1180,1188 — every better-auth delete, /organization/remove-member included engine yes
    2 plugin-auth/objectql-adapter.ts:707 — the attributed-system engine wrapper engine yes
    3 cloud service-cloud/routes/package-install.ts:1210 — envDriver.delete(objectName, id), the package-uninstall sample-data purge, object name taken from the package's own manifest datasets and ungated raw driver NO
    4 cloud service-cloud/control-plane-proxy-driver.ts:124,136 raw driver NO (control-plane tables only)

    Doors examined and excluded, with the reason: plugin-sharing/record-orphan-cleanup.ts
    and plugin-security/auto-org-admin-grant.ts are generic over a table name their callers
    pass as a literal (share / grant tables); cloud's
    service-tenant/environment-provisioning.ts:1578,1584 and
    service-cloud/routes/environment-crud.ts:262 cascade over a closed literal list of
    four environment tables that does not contain sys_member.

    Reading C — the runtime probe (what actually reaches the seam)

    A probe registered on sys_member for beforeDelete / afterDelete / beforeUpdate /
    afterUpdate, on a real booted stack (bootStack(showcaseStack), real better-auth, real
    HTTP), then each enumerated path driven for real. Path 0 is the firing control: if a
    direct engine delete does not light the probe, nothing else in the table is a reading.

    PATH 0  engine.delete (direct)                 -> beforeDelete,afterDelete
    PATH 1  better-auth /organization/remove-member -> HTTP 200; row gone=true; beforeDelete,afterDelete
    PATH 2  engine.delete multi:true               -> beforeDelete,afterDelete
    PATH 3  cascade via sys_user delete            -> row gone=true; beforeDelete,afterDelete
    PATH 4  driver.delete (raw, hooks bypassed)    -> row gone=true; NOTHING
    PATH 5  engine.update re-point organization_id -> beforeUpdate,afterUpdate
    

    Test Files 1 passed (1) · Tests 6 passed (6). Path 3 needed its own correction: the
    first run reported row gone=false; NOTHING; refused: This User is still referenced by 1 Session record(s) — sys_session.user_id and sys_account.user_id are required lookups
    that restrict, so the user delete never happened and the cascade was never measured. With
    those cleared first, the cascade runs and the membership row's own hooks fire, one row
    deeper, exactly as sys-member.object.ts documents.

    cloud's packages/organizations — measured, and it is genuinely empty

    packages/organizations/src contains zero references to sys_member /
    SystemObjectName.MEMBER, and its only writes at all are two ql.update calls on
    owner_id (claim-orphan-org-rows.ts:106, claim-org-seed-ownership.ts:93). The same
    grep fires 34 times elsewhere in the same repo and 3 times in objectstack, so this zero
    is a reading and not a dead detector. Membership creation in cloud lives in
    service-cloud/personal-org-hook.ts and objectos-runtime/environment-org-seed.ts — both
    create only. Removal in cloud is delegated to @objectstack/plugin-auth, i.e. to
    Reading B row 1.

    What the census decides

    The seam is an engine hook on sys_member, not a hook on better-auth's
    /organization/remove-member endpoint. Rows 1, 2 and paths 0/1/2/3/5 all reach it; an
    endpoint hook would reach exactly one of them. This is also the precedent already in the
    file: last-admin-guard.ts enforces its invariant with beforeUpdate / beforeDelete on
    sys_member for the same stated reason — an HTTP guard protects only the endpoint it is
    attached to.

    The one measured gap, stated plainly

    A raw driver delete bypasses the trigger completely (path 4, NOTHING), and there is
    a real production caller of that shape: cloud's package-uninstall sample-data purge takes
    its object name from the installed package's manifest and calls envDriver.delete with
    no allow-list, so a package declaring a dataset over sys_member removes membership rows
    with no hook, no audit row and no revocation. That is the card's own "a seed replay, a
    bulk operation, a control-plane operation" made concrete.

    It is also exactly why this card is the courtesy and not the wall: the same removal leaves
    a stale claim that per-request enforcement already neutralises. Filed as cloud#2003
    rather than fixed here (this card must not grow a cloud arm), and the trigger's own
    documentation names this path as the known-open one.

  10. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 15784,
      "status": "done",
      "branch": "claude/issue-15784-revoke-session-on-membership-end",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15879",
      "premise_still_valid": true,
      "summary": "Census first (scope item 1), posted before anything was wired: three readings with their own firing controls over objectstack@59953d5a3 and cloud@3856fbf7c. Two findings shaped the work — the dominant removal path never names sys_member (better-auth's adapter deletes through a variable object name, so a name-scanning detector honestly reports ZERO deletes), and a raw driver delete bypasses every engine hook, with a real production caller in cloud (filed as cloud#2003). cloud's packages/organizations was measured and is genuinely empty of membership writers; its only writes are two ql.update calls on owner_id. Implemented option B as ruled: an afterDelete + afterUpdate trigger on sys_member re-points the session's active_organization_id when the user still holds a membership (cleared when the remaining one carries no org id), and revokes through the existing revoked_at / revoke_reason mechanism with the new value organization_membership_ended when none remains. resolve-authz-context.ts is byte-identical to base. TWO DIVERGENCES FROM THE DISPATCH, both declared in the PR body rather than taken quietly: (1) there is NO packages/spec carrier for revoke_reason — the column is free Field.text with no Zod enum, so the accept-set widening lands on @objectstack/platform-objects (minor), whose published field description IS the vocabulary, and the changeset is platform-objects minor + plugin-auth MINOR (not patch: three new public exports). (2) the dispatch cited the ruling as comment 5551086286, which 404s on every endpoint tried and is absent from this repo's comment stream; the actual ruling is 5551140528 on #15784 and its content matches what the dispatch restated, so nothing was lost — worth correcting in the PM's records.",
      "tests": "All at 24536a537 (the head commit the PR points at). SUITES: @objectstack/plugin-auth 97 files / 2050 tests passed; @objectstack/platform-objects 33 files / 518 tests passed; @objectstack/dogfood membership-ended-session-revoke 1 file / 3 tests passed; typecheck plugin-auth + platform-objects Done. NEW: membership-ended-session.test.ts 15/15 (decision table, no-op shapes incl. a ROLE change, bounded scan, both degradation paths). CONTROLS, all three: (1) a member of a different organization unaffected and (2) an intact member's session untouched are asserted IN THE SAME TEST as the acceptance case, not in a separate run; (3) the boundary card's pins hold — core/security/resolve-authz-context.test.ts 86 passed, rest/single-kernel-isolated-session-org-claim-matrix.test.ts 13 passed, and `git diff 59953d5a3 -- packages/core/src/security/resolve-authz-context.ts` is empty. CENSUS PROBE (real booted stack, hooks registered on sys_member, firing control PATH 0): PATH 0 engine.delete -> beforeDelete,afterDelete; PATH 1 better-auth /organization/remove-member -> HTTP 200, row gone, beforeDelete,afterDelete; PATH 2 multi delete -> fires; PATH 3 cascade via sys_user delete -> fires; PATH 4 raw driver.delete -> row gone, NOTHING (the measured gap); PATH 5 organization re-point -> beforeUpdate,afterUpdate. The probe's cascade leg first read 'row gone=false; NOTHING' because a required lookup restricted the user delete — it had never run, so it had never been measured; corrected and re-run. ABLATION, both legs proven on disk, never by an editor's exit code: MUTATE — occurrence counts 2 injected / 0 unguarded registrations, HEAD blob 2a247ce41b5a4d6b6b4ff5b7c6eea16afbcb77e1 vs on-disk 2eb579f6e71e331543da117bac13ede29f9b4ccf, then REBUILT (pnpm --filter @objectstack/plugin-auth build) and ablation-dist-preflight confirmed the marker present in 2 built dist files — the dogfood suite consumes dist, so an unrebuilt ablation would have been a false green. Ablated run: 3 failed (3), the first on exactly the right assertion, revoked.revoked_at 'expected null to be truthy' (the session survived the removal). RESTORE — git checkout HEAD -- <abs path>, whole-tree `git status --porcelain` empty, `git diff HEAD` empty, occurrence counts back to 0/1, on-disk hash equals the HEAD blob, rebuilt, ablation-dist-preflight --absent green on all 12 built files. The whole script carried a `trap restore EXIT INT TERM` with an absolute REPO_ROOT-anchored path.",
      "mcp_calls": "0 — every GitHub read and write went through the gh REST/CLI channel, which was probed working at the start of the run; no MCP GitHub tool was called.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as cloud#2003: package uninstall's sample-data purge (service-cloud/src/routes/package-install.ts:1210) deletes ANY object the package manifest names, through the raw env driver with no allow-list — identity tables included, so every engine hook is bypassed (last-admin-guard's beforeDelete and this card's trigger both fail open). Searched cloud's open issues for duplicates first (purgeSampleDataRows / package-install / sys_member); none. No assignee, no labels — left for PM triage."
      ]
    }

    Gates — 108 derived, 99 green, 9 NOT MEASURED

    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived its own change
    set (14 paths, three-dot against merge base 59953d5a3) at 24536a537. It was re-derived
    after the docs edits, which pulled in 21 gates the first derivation did not name.

    Four were genuinely red and are fixed: check:doc-authoring (six tracker ids in runtime
    log strings — moved to comments); check:objectql-double-limit and check:where-matcher
    (the new test double ignored limit and read a combinator as a field name — it now applies
    the bound by presence after the filter, refuses what it does not implement, and its matcher
    was moved to module scope so both gates can lift and judge it);
    check:system-context-census and check:tenant-audit-census (an anchor rotted on a line
    shift; the write-call-site census moved 219 to 221, regenerated with its 23 prose figures).

    The nine non-zero results are all NOT MEASURED, with reasons:

    gate exit why it is not a verdict
    check:partof-closing-keyword 2 NOT WIRED — needs PR_BODY. Re-run with the body supplied: PASSED.
    check:single-claim-paths 2 NOT WIRED — needs PR_NUMBER; CI supplies it
    check:test-completeness 3 exited before parsing a summary line; its own text says it is not a finding
    check:dual-build-cjs-loads 3 PREREQUISITE NOT MET — 7 packages have no dist/
    check:published-readme-exports 3 PREREQUISITE NOT MET — reads built type surface
    check:skill-examples 1 client-react/dist holds no .d.ts; the gate refuses rather than green
    check:react-declaration-parity 1 needs an objectui manifest and a browser dump
    pr-labels.mjs 1 usage error — the runner invoked it with no mode flag
    check:merge-driver 1 pre-existing and environmental: two self-test cases about "a gate whose RUNNER is not installed" fail on this host, and git diff 59953d5a3 -- scripts/ .gitattributes is empty, so the verdict cannot be a function of this diff

    check:closing-keyword-parity was also run with the real PR body and passed, so the one
    closing keyword in it (Fixes #15784) is the only one, by measurement.

    Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could not
    take the shared verify lock on this host: no usable flock (it is util-linux; a stock macOS
    does not ship it). Every command was run directly, without the lock — a declared narrowing,
    not a silent one. No serialization guarantee held for these runs.

    For the PM's records

    The dispatch cited the maintainer's ruling as comment 5551086286. That id 404s on the
    issue-comment, PR-review-comment and cloud endpoints and does not appear in this repo's
    comment stream (it would sit between 5551085989 at 10:10:05Z and 5551088281 at
    10:10:36Z). The ruling actually recorded on this card is 5551140528, and its content
    matches what the dispatch restated word for word — so nothing was lost, and the work was
    done against the real ruling.

  11. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    Correction to my dispatch, and the census finding that should outlive this card.

    My error: the dispatch cited the ruling as comment 5551086286. That id 404s — I mistyped it. The real ruling is 5551140528 on this card, by the director seat, and its content is what the dispatch restated, so nothing was lost. ⭐ The dev checked the citation instead of working around it and reported the mismatch — that is the right reflex, and it is worth saying so: a dispatch that cites a comment nobody can open is a dispatch whose provenance cannot be audited.

    ⭐ The census found two things that change how this fix should be read

    1. The dominant removal path never names sys_member. better-auth's adapter deletes through a variable object name, so a detector that scans for the literal sys_member honestly reports zero deletes on the path that does most of the removing. A census run that way would have come back clean and been wrong. The probe measured through a real booted stack with hooks registered and a firing control instead — six paths driven, five fire.

    2. A raw driver delete bypasses every engine hook, and there is a real production caller. PATH 4 in the probe: the row is gone and nothing fires. That is not hypothetical — it is filed as cloud#2003: package uninstall's sample-data purge deletes any object the manifest names through the raw environment driver with no allow-list, identity tables included. Both last-admin-guard's beforeDelete and this card's trigger fail open on it.

    ⇒ This is precisely why the ruling put the enforcement on #15409 and not here. A trigger covers the paths that go through hooks; PATH 4 does not. The per-request check does not care which path deleted the row. ⛔ The scope item 3 prohibition — never weaken #15409's check on the strength of this trigger — is now backed by a measured hole, not a principle. Verified on the PR: resolve-authz-context.ts is not in the diff.

    Two divergences, both declared rather than taken quietly

    1. There is no packages/spec carrier for revoke_reason — the column is a free Field.text with no Zod enum, so the accept-set widening lands on @objectstack/platform-objects, whose published field description is the vocabulary. Changeset accordingly: platform-objects minor + plugin-auth minor (three new public exports, so not the patch the dispatch assumed). The dispatch's 「@objectstack/spec minor」 was wrong about the carrier; the dev measured the tree and said so.
    2. The ruling's second bullet — revoke when no membership remains — was applied as the director seat wrote it, including the clearing case when the remaining membership carries no organization id.

    Reviewing for landing now.

  12. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 15784,
      "status": "done",
      "branch": "claude/issue-15784-revoke-session-on-membership-end",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15879",
      "premise_still_valid": true,
      "summary": "Patch round on the red CI check. Your diagnosis was CORRECT and my first-glance reading of the reproduction was wrong — I record both because the second nearly sent me at the wrong file. Reproduced before acting: the first local run answered exit 3, PREREQUISITE NOT MET (the workspace CLI was not built) — a refusal, not a verdict, and I did not read it as one. With the closure the gate names built, the real verdict was 'platform-objects DRIFTED (3) … 3 bundle(s) drifted from the schema'. I first read that '3' as the three *.objects.generated.ts locale bundles I had hand-refreshed last round; the measurement refuted it — pnpm i18n:extract changed ONLY the three *.source-hashes.generated.ts companions and nothing else, and the gate then went green, so the drift was the source hashes exactly as you said. Nothing was hand-edited to reach green. THE REAL ROOT CAUSE IS MINE AND IT IS NOT THE HASHES: dispatch-gates DID name check:i18n, under 'Convention-triggered gates' (edits a file in a package that owns an i18n-extract.config.ts) at a four-space indent, and the regex I scraped its output with anchored on a two-space bullet and silently dropped that entire section — five gates, check:i18n among them. Worse, the tool has a machine-readable --commands mode that I never used and that the output itself points at; scraping human-readable output was the wrong approach from the start. All five dropped gates now run green, and the whole family has been re-run through --commands with the exact flags CI uses (--self-test, --base origin/main, --advisory), which is a different and stricter invocation than the bare runs I reported last round. One thing I am NOT acting on unasked: dispatch-gates now warns STALE TREE — the branch is 8 commits behind origin/main and 15 files the derivation reads have changed in that range. Merging main would widen the diff, so I am reporting it for you to scope rather than doing it.",
      "tests": "All re-run at the patch head 61bc592f3, not at the previous head. I18N, the three the fill demands, verdict lines quoted: check:i18n 'OK (9 package(s) — all bundles in sync, no undeclared authoring keys)'; check:i18n-coverage 'OK (13 config(s), 621 baselined untranslated string(s), none new)' — NO GROWTH, so no rebaseline was needed and none was taken; check:i18n-stale-fill 'OK (10 bundle set(s) — no new stale fills, 0 baselined)'. Both coverage and check:i18n first answered exit 3 PREREQUISITE NOT MET and were re-run after building the closure each names — the exit-3 runs are reported as NOT MEASURED, never as passes. SUITES at 61bc592f3: @objectstack/plugin-auth 97 files / 2050 tests passed; @objectstack/platform-objects 33 files / 518 tests passed; @objectstack/dogfood membership-ended-session-revoke 1 file / 3 tests passed. TYPECHECKS at 61bc592f3: both Done, plugin-auth's test layer OK. GATES: re-derived at 61bc592f3 (17 paths, three-dot against merge base 59953d5a3) and run through the authoritative --commands list — 88 commands, 87 green, 1 non-zero. The one non-zero is check:merge-driver, pre-existing and environmental rather than this diff's: two self-test cases about 'a gate whose RUNNER is not installed' fail on this host, and `git diff 59953d5a3 -- scripts/ .gitattributes` is empty, so the verdict cannot be a function of this change set. The five gates my earlier extraction dropped are all green, including check:type-check-debt, which needed the whole workspace built first and then reported '12 ledger entr(ies) re-measured in 36.0s, 140 raw tsc error(s) total, none above its recorded number'. The gates absent from --commands are the ones CI runs with context it will not paste (PR_BODY / PR_NUMBER); two of those I verified by supplying the real PR body — check:partof-closing-keyword and check:closing-keyword-parity both PASS. PUSH: append-only, verified rather than asserted — `git merge-base --is-ancestor 24536a537 origin/BRANCH` returns true, so no rebase, no force-push, no squash. One push attempt died on an SSL_ERROR_SYSCALL network error; note my own first readback of it printed EXIT=0 because I had piped it to tail — the pipe trap, caught and re-run with the exit code captured before any pipe.",
      "mcp_calls": "0 — every GitHub read and write went through the gh REST/CLI channel; no MCP GitHub tool was called this round or last.",
      "open_questions": [],
      "out_of_scope_findings": [
        "not filed, reported for scoping: the branch is 8 commits behind origin/main and dispatch-gates warns STALE TREE — 15 files its families derive from changed across that range, so this run read their old copies. Merging main would widen the diff mid-review, so I left it; CI derives against the merge result anyway.",
        "already filed last round as cloud#2003: package uninstall's sample-data purge deletes ANY object the package manifest names, through the raw env driver with no allow-list — identity tables included, every engine hook bypassed."
      ]
    }

    What changed on the PR

    One commit, 61bc592f3 — three regenerated *.source-hashes.generated.ts files, nothing
    else. pnpm i18n:extract produced them; nothing was hand-written.

    The PR body now carries a Patch round section with the reproduction, the three i18n
    verdict lines, and the corrected gate accounting, and its Tests and Gates sections have been
    re-stated at 61bc592f3 rather than left pointing at the previous head.

    The lesson worth keeping, since it cost a CI round

    dispatch-gates prints gate bullets at two indent levels — path-derived families at two
    spaces, and Convention-triggered / Always runs families at four. Scraping its
    human-readable output with a two-space anchor drops the deeper section silently and with
    exit 0
    , which is the exact failure shape the repo's own gate-reading discipline warns
    about: a well-formed green answer to a question nobody asked.

    The tool already solves this — --commands emits the pasteable list, with the flags CI
    actually uses (--self-test, --base origin/main, --advisory), and its output explicitly
    says a bare run of one of those scripts "is an invocation CI never makes". Last round's
    bare-form runs were therefore the wrong invocation for roughly two dozen gates, even where
    they happened to agree. Use --commands; do not scrape the prose.

  13. hotlong commented on Sep 5, 2026

    @hotlong
    ContributorAuthor

    Landed. PR #15879 merged 2026-09-05T14:02:55Z; merge commit 4ca358dea, confirmed on origin/main — "a membership that ends takes the session's claim on that organization with it — re-pointed if the user belongs elsewhere, revoked if not". This card closed as completed by its Fixes line.

    Shipped as ruled (B): an afterDelete + afterUpdate trigger on sys_member re-points the session's active_organization_id when the user still holds a membership (cleared when the remaining one carries no organization id), and revokes through the existing sys_session.revoked_at / revoke_reason mechanism with the new value organization_membership_ended when none remains. Verified: resolve-authz-context.ts is not in the diff — #15409's per-request check is untouched.

    The census is the part worth keeping

    Run first and posted before anything was wired, with its own firing controls, over objectstack@59953d5a3 and cloud@3856fbf7c. Two findings shaped the work:

    1. ⭐ The dominant removal path never names sys_member. better-auth's adapter deletes through a variable object name, so a detector scanning for the literal name honestly reports zero deletes on the path that does most of the removing. A census run that way comes back clean and is wrong. The probe drove six paths through a real booted stack with hooks registered; five fire.
    2. ⭐ PATH 4 — a raw driver delete — bypasses every engine hook, and there is a real production caller. Filed as cloud#2003: package uninstall's sample-data purge deletes any object the manifest names through the raw environment driver with no allow-list, identity tables included. last-admin-guard's beforeDelete and this card's trigger both fail open on it.

    ⇒ This is why the ruling put the enforcement on #15409 and the courtesy here. A trigger covers the paths that go through hooks; PATH 4 does not. The per-request check does not care which path deleted the row. Scope item 3's prohibition — never weaken that check on the strength of this trigger — is now backed by a measured hole rather than a principle.

    Two corrections that belong on the record, both mine

    1. I cited the ruling as comment 5551086286; that id 404s. The real one is 5551140528. The dev checked the citation rather than working around it — a dispatch whose provenance cannot be opened is a dispatch that cannot be audited.
    2. The changeset carrier: my dispatch said @objectstack/spec minor. There is no spec carrier for revoke_reason — the column is a free Field.text with no Zod enum, so the vocabulary is the published field description on @objectstack/platform-objects. Shipped as platform-objects minor + plugin-auth minor (three new public exports, not the patch I assumed). The dev measured the tree and said so.

    The red CI, and what it exposed

    The first head went red on check:i18n — the revoke_reason description change left the three *.source-hashes.generated.ts companions stale. Fixed by pnpm i18n:extract in one commit, nothing hand-written, with check:i18n-coverage showing no growth, so no rebaseline was needed or taken.

    ⚠️ The root cause was not the hashes and not this dev. dispatch-gates.mjs prints a Convention-triggered gates section that the dev's scraping regex dropped silently at exit 0 — five gates, check:i18n among them — and the tool has a --commands mode that makes scraping unnecessary. My dispatch template asked for the human-readable form all session, so this could have under-run the farm on every card here. Filed as #15897; the re-run through --commands with CI's own flags came back 87/88, the one non-zero shown to be environmental.

    Left open deliberately: the branch was 8 commits behind origin/main with a STALE TREE warning naming 15 changed gate sources. The dev did not merge main — widening a diff mid-review — and the queue re-tested against the merge result anyway. Correct call.

    pm:dispatched stripped in the same stroke as the close; domain:services, security, priority:p2 retained.

  14. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 5, 2026
  15. removed their assignment
    on Sep 5, 2026
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

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions