Repository navigation
Description
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.
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
- Enumerate the removal paths by measurement, not by memory — every writer that can delete or invalidate a
sys_memberrow, in this repo and in cloud'spackages/organizations. Attach the census with its firing control. - Revoke the affected user's sessions for that organization's claim when a membership ends, through the existing mechanism, with a new
revoke_reasonnaming the cause. - ⛔ Do not weaken, bypass or "optimise away" [decision · p0] a SESSION whose
activeOrganizationIdpoints 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. - 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
activeOrganizationIdinstead 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
os-warren commentedon Sep 5, 2026
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.needs-user-decisionat 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):
activeOrganizationIdpoints 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.sys_session.revoked_at/revoke_reasonmechanism 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.activeOrganizationIdpoints 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
sys_memberrow — this repo and cloud'spackages/organizations— by measurement with a firing control, attached to the card before the trigger is wired. A path the census misses fails open only to a stale claim, which [decision · p0] a SESSION whoseactiveOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 already neutralises per request.domain:services,pm:queue. The spentBlocked-by: #15409line is removed from the body ([decision · p0] a SESSION whoseactiveOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 closedcompletedvia PR fix(core): drop a session's unbacked organization claim under a wall-enforcing posture #15794, merged 09:25Z).yes— a newrevoke_reasonvalue on a published system object is an accept-set widening; re-judge from the diff.@objectstack/specminor(the new reason value) + the implementing packagepatch; the dev derives the exact set from the diff.activeOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 pins still hold.Labels:
needs-user-decision→pm:queue;domain:services,security,priority:p2stay.Generated by Claude Code
hotlong commentedon Sep 5, 2026
The record-keeping gap named in the ruling above is mine, and it is correctly named.
I flipped this card to
needs-user-decisionat 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:
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.
hotlong commentedon Sep 5, 2026
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-0e0409d76793Branch:
claude/issue-15784-revoke-session-on-membership-endWorktree:
objectstack-issue-15784Domain:
domain:services(per the ruling).File surface: the membership-removal writers named by the census, plus
sys_session'srevoke_reasonvocabulary inpackages/specand the implementing package. ⛔packages/core/src/security/resolve-authz-context.tsis out of surface — #15409's per-request check is not touched.Container & model: M,
mode:subagent,model: opus.Clause-②: yes — a new
revoke_reasonvalue 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 touchessys-session.object.tsor the membership-removal paths (REST search 2026-09-05).wip(qa): #15784 membership-removal census probe (temporary, removed b…
hotlong commentedon Sep 5, 2026
Scope item 1 — the membership-removal census, with its firing controls
Posted before the trigger is wired, as the ruling requires. Measured against
objectstack59953d5a3(this branch's base) andcloud3856fbf7c.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— acrossdelete/deleteMany/bulkDelete/deleteOne/remove/destroy(ending the row) andupdate/updateMany/bulkUpdate/upsert/replace(invalidating it), plus raw-SQLDELETE FROM/TRUNCATE/DROP TABLEshapes and the declarativedeleteBehavior: 'cascade'shape.Firing control: a synthetic file carrying one instance of every shape.
Results:
objectstack/packagescloud/packagesThe three, in full:
platform-objects/src/identity/sys-member.object.ts:263—deleteBehavior: 'cascade'on
user_id. Asys_userdelete takes its memberships (line 255 is the same tokeninside 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— sothe 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.deleteon a cache). The first run of this detectorfailed its own control by over-firing on
membershipCache.delete(cacheKey); theleaf-name test was tightened and re-run.
The doors that can carry a membership row:
plugin-auth/objectql-adapter.ts:1023,1039,1056,1180,1188— every better-auth delete,/organization/remove-memberincludedplugin-auth/objectql-adapter.ts:707— the attributed-system engine wrapperservice-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 ungatedservice-cloud/control-plane-proxy-driver.ts:124,136Doors examined and excluded, with the reason:
plugin-sharing/record-orphan-cleanup.tsand
plugin-security/auto-org-admin-grant.tsare generic over a table name their callerspass as a literal (share / grant tables); cloud's
service-tenant/environment-provisioning.ts:1578,1584andservice-cloud/routes/environment-crud.ts:262cascade over a closed literal list offour environment tables that does not contain
sys_member.Reading C — the runtime probe (what actually reaches the seam)
A probe registered on
sys_memberforbeforeDelete/afterDelete/beforeUpdate/afterUpdate, on a real booted stack (bootStack(showcaseStack), real better-auth, realHTTP), 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.
Test Files 1 passed (1) · Tests 6 passed (6). Path 3 needed its own correction: thefirst run reported
row gone=false; NOTHING; refused: This User is still referenced by 1 Session record(s)—sys_session.user_idandsys_account.user_idare required lookupsthat 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.tsdocuments.cloud's
packages/organizations— measured, and it is genuinely emptypackages/organizations/srccontains zero references tosys_member/SystemObjectName.MEMBER, and its only writes at all are twoql.updatecalls onowner_id(claim-orphan-org-rows.ts:106,claim-org-seed-ownership.ts:93). The samegrep fires 34 times elsewhere in the same repo and 3 times in
objectstack, so this zerois a reading and not a dead detector. Membership creation in cloud lives in
service-cloud/personal-org-hook.tsandobjectos-runtime/environment-org-seed.ts— bothcreate only. Removal in cloud is delegated to
@objectstack/plugin-auth, i.e. toReading 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-memberendpoint. Rows 1, 2 and paths 0/1/2/3/5 all reach it; anendpoint hook would reach exactly one of them. This is also the precedent already in the
file:
last-admin-guard.tsenforces its invariant withbeforeUpdate/beforeDeleteonsys_memberfor the same stated reason — an HTTP guard protects only the endpoint it isattached to.
The one measured gap, stated plainly
A raw driver delete bypasses the trigger completely (path 4,
NOTHING), and there isa 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.deletewithno allow-list, so a package declaring a dataset over
sys_memberremoves membership rowswith 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.
feat(plugin-auth): revoke or re-point a session whose membership ended (
test(plugin-auth,dogfood): pin the membership-ended session trigger; …
fix(plugin-auth,docs): strip tracker ids from runtime prose, make the…
hotlong commentedon Sep 5, 2026
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/objectstackderived its own changeset (14 paths, three-dot against merge base
59953d5a3) at24536a537. It was re-derivedafter 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 runtimelog strings — moved to comments);
check:objectql-double-limitandcheck:where-matcher(the new test double ignored
limitand read a combinator as a field name — it now appliesthe 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-censusandcheck:tenant-audit-census(an anchor rotted on a lineshift; 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:
check:partof-closing-keywordPR_BODY. Re-run with the body supplied: PASSED.check:single-claim-pathsPR_NUMBER; CI supplies itcheck:test-completenesscheck:dual-build-cjs-loadsdist/check:published-readme-exportscheck:skill-examplesclient-react/distholds no.d.ts; the gate refuses rather than greencheck:react-declaration-paritypr-labels.mjscheck:merge-drivergit diff 59953d5a3 -- scripts/ .gitattributesis empty, so the verdict cannot be a function of this diffcheck:closing-keyword-paritywas also run with the real PR body and passed, so the oneclosing keyword in it (
Fixes #15784) is the only one, by measurement.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould nottake the shared verify lock on this host: no usable
flock(it is util-linux; a stock macOSdoes 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
5551085989at 10:10:05Z and5551088281at10: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.
hotlong commentedon Sep 5, 2026
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 literalsys_memberhonestly 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'sbeforeDeleteand 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.tsis not in the diff.Two divergences, both declared rather than taken quietly
packages/speccarrier forrevoke_reason— the column is a freeField.textwith no Zod enum, so the accept-set widening lands on@objectstack/platform-objects, whose published field description is the vocabulary. Changeset accordingly:platform-objectsminor +plugin-authminor (three new public exports, so not thepatchthe dispatch assumed). The dispatch's 「@objectstack/specminor」 was wrong about the carrier; the dev measured the tree and said so.Reviewing for landing now.
fix(platform-objects): regenerate the i18n source-hash companions for…
hotlong commentedon Sep 5, 2026
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.tsfiles, nothingelse.
pnpm i18n:extractproduced 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
61bc592f3rather than left pointing at the previous head.The lesson worth keeping, since it cost a CI round
dispatch-gatesprints gate bullets at two indent levels — path-derived families at twospaces, 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 —
--commandsemits the pasteable list, with the flags CIactually uses (
--self-test,--base origin/main,--advisory), and its output explicitlysays 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.hotlong commentedon Sep 5, 2026
Landed. PR #15879 merged 2026-09-05T14:02:55Z; merge commit
4ca358dea, confirmed onorigin/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 itsFixesline.Shipped as ruled (B): an
afterDelete+afterUpdatetrigger onsys_memberre-points the session'sactive_organization_idwhen the user still holds a membership (cleared when the remaining one carries no organization id), and revokes through the existingsys_session.revoked_at/revoke_reasonmechanism with the new valueorganization_membership_endedwhen none remains. Verified:resolve-authz-context.tsis 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@59953d5a3andcloud@3856fbf7c. Two findings shaped the work: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.last-admin-guard'sbeforeDeleteand 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
5551086286; that id 404s. The real one is5551140528. The dev checked the citation rather than working around it — a dispatch whose provenance cannot be opened is a dispatch that cannot be audited.@objectstack/specminor. There is no spec carrier forrevoke_reason— the column is a freeField.textwith no Zod enum, so the vocabulary is the published field description on@objectstack/platform-objects. Shipped asplatform-objectsminor +plugin-authminor (three new public exports, not thepatchI assumed). The dev measured the tree and said so.The red CI, and what it exposed
The first head went red on
check:i18n— therevoke_reasondescription change left the three*.source-hashes.generated.tscompanions stale. Fixed bypnpm i18n:extractin one commit, nothing hand-written, withcheck:i18n-coverageshowing no growth, so no rebaseline was needed or taken.dispatch-gates.mjsprints aConvention-triggered gatessection that the dev's scraping regex dropped silently at exit 0 — five gates,check:i18namong them — and the tool has a--commandsmode 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--commandswith 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/mainwith aSTALE TREEwarning 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:dispatchedstripped in the same stroke as the close;domain:services,security,priority:p2retained.feat(plugin-auth): a membership that ends takes the session's claim o…
feat(plugin-auth,objectql,metadata-protocol,runtime)!: under `single`…