Repository navigation
delegated_admin can issue invitations but can no longer read them via the data API — the #8095 narrowing lands on owner/admin only #8240
Description
Activity
Triage: lands in
plugin-security/ default permission sets if B or C is taken, checklist prose only if A ⇒domain:identity; type Feature (B/C widen a read admission beyond what #8095 ruled — accept-set expansion routes to the human floor). ⇒needs-user-decision: even taking A decides a permission boundary ("delegated_adminshall not read the ledger"), which is not auto-adjudicable.Recommendation: A now, with C as the named reopen trigger.
- Platform long-term coherence: A keeps [finding]
org_membercan read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095's ruling as the single statement ofsys_invitationread admission; B widens the ledger's audience by a role the ruling deliberately did not include; C is the most D8-faithful widening but adds a third policy for one persona. - Measured business pull: zero today — the only thing that noticed is the QA checklist wording; no user workflow reads the ledger as
delegated_admin. (docs/qa/platform-checklist/areas/identity-auth.jsonsteps get re-phrased to read as owner/admin under A.) - AI-agent error-resistance: A changes prose only. C's
inviter_id == current_user.idrow scope is a new predicate shape on the policy surface agents must reason about — cheap, but not free (see [finding] adding an RLS narrowing tomember_defaultsilently narrows ORG ADMINS on the defaultsingleposture — both of their widening mechanisms are absent there #8241 for how policy composition on this exact object already bit once). - Startup scope discipline: remove/defer over declare-and-maintain. Reopen trigger for C: a real delegated-admin workflow (UI page or API consumer) that needs to track issued invitations.
If A is taken, the residue is a small Task (checklist re-phrase) that can go straight to
pm:queue.
Generated by Claude Code
- Platform long-term coherence: A keeps [finding]
Maintainer ruling — Option C
Ruled by the maintainer in a live PM session, 2026-08-13 (session
session_015XyMgCSMGGn9oSwbQEeWYg), verbatim: 「其他全部接受你的建议。」 — accepting option C as recommended.Ruling: C — a
delegated_adminsees the invitations they themselves issued (inviter_id == current_user.id), not the whole ledger. This matches ADR-0105 D8's scope-bounded issuance shape: the issuing principal can confirm and review its own work without widening the ledger's audience beyond what #8095 ruled (B rejected for exactly that widening; A rejected because an issuer that cannot even confirm its own issuance is a broken shape).Implementation notes for dispatch:
- One additional row-scope policy on
sys_invitation, same declarative pattern as the addressee carve-out that [finding]org_membercan read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095 already shipped. Owner/admin visibility unchanged. - Pleasant consequence, verify it in coverage: the ADR-0105 D8 checklist steps ("as the delegated_admin ... capture the created sys_invitation row via the data API") become correct again as written — no checklist rewording needed. Pin the four-persona matrix from this card's measurement with
delegated_adminnow seeing exactly their own rows.
Label swap:
needs-user-decision→pm:queue.
Generated by Claude Code
- One additional row-scope policy on
Claim — implementing the ruled Option C
- Session:
session_01PEVB6w7D7uCszR9Mw1BL73 - Branch:
claude/issue-8240-delegated-admin-own-invitations - Base:
origin/main@884091c19
Comments re-read before starting; no prior claim comment from another session on this issue.
Declared file surface (plugin-security only — the row-scope policy layer):
packages/plugins/plugin-security/src/objects/default-permission-sets.tspackages/plugins/plugin-security/src/objects/default-permission-sets.test.ts(or a sibling test file).changeset/*.md
Disjointness verified against the three in-flight cards (measured from their live worktrees,
git diff --name-only origin/main...HEAD, not assumed):Card Its surface Overlap with mine #8317 ( sys_memberrole normalisation)plugin-auth/src/{auth-plugin,index,member-role-canonical}.tsnone #8243 (impersonation bearer rotation) plugin-authhooks +runtime/src/security/resolve-session-principal.tsnone #8119 ( plugin-sharingphantom-anchor mint)plugin-sharing/**none Note on #8317 specifically, since it is the nearest neighbour: its canonicalisation is per-token trim + lower-case against the ADR-0108 closed vocabulary, and
delegated_adminis already in that vocabulary — so it normalises todelegated_adminunchanged, and the position name this card's policy domains on is stable under that card landing either before or after mine.
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 13, 2026 { "issue": 8240, "status": "done", "branch": "claude/issue-8240-delegated-admin-own-invitations", "pr": "https://github.com/objectstack-ai/objectstack/pull/8425", "pr_number": 8425, "pushed_head_sha": "bde9e4c587e0e10186811f0acc4d90d18ab3a41b", "premise_still_valid": true, "summary": "Implemented the ruled option C: one row-scope policy `sys_invitation_issuer` (`inviter_id == current_user.id`, domained to the `delegated_admin` grade) added to `member_default`, beside the addressee carve-out #8095 shipped, using the same declarative mechanism. Owner/admin visibility is untouched and pinned as untouched. The premise was re-verified by measurement rather than taken on trust: ablation M1 removes the policy and the delegate reads 0 rows over real HTTP, reproducing the card's original probe exactly. The ADR-0105 D8 checklist steps read correctly without editing them, and one step turned out to have been vacuously passing before this card.", "premise_verification": "Confirmed. With the policy removed (ablation M1) the delegated_admin reads `200, total: 0` on GET /api/v1/data/sys_invitation while owner/member/invitee are unaffected — the card's measured table reproduced on origin/main behaviour, not recalled from the issue body.", "four_persona_matrix": { "measurement": "Real GET /api/v1/data/sys_invitation responses, stock showcase boot, single posture, active organization stamped. Ledger = 3 invitations from TWO distinct inviters: invitee@ and outsider@ issued by the org-owner grade, invitee.8240@ issued by the delegate.", "owner": { "before": "200, 3 rows", "after": "200, 3 rows", "note": "unchanged; also asserted that the owner issued NONE of them, so their full read is not the issuer carve-out in disguise" }, "member": { "before": "200, 0 rows", "after": "200, 0 rows", "note": "unchanged; raw response asserted not to contain any of the three addresses" }, "invitee": { "before": "200, 1 row", "after": "200, 1 row", "note": "unchanged; their own addressed row only" }, "delegated_admin": { "before": "200, 0 rows", "after": "200, 1 row", "note": "exactly the row it issued, keyed on inviter_id == its own user id" }, "negative_case": "REQUIRED and measured. The two invitations issued by a different principal (invitee@, outsider@) stay invisible to the delegate — by row count, by absence from the parsed records, by substring on the raw response body, and by a by-id GET of outsider@'s row returning 403/404. The same by-id route returns 200 for the row the delegate DID issue, so the refusal is the row scope talking and not the object being closed to that persona.", "fixture_anti_vacuity_guard": "The fixture asserts the ledger really holds 2 distinct inviter_ids and that exactly one row carries the delegate's id — otherwise the positive assertion could go green on an empty intersection and the negative one on nothing at all." }, "adr_0105_d8_checklist": { "reads_correctly_without_editing": true, "checklist_edited": false, "file": "docs/qa/platform-checklist/areas/identity-auth.json (item identity-auth.invitation-scope-gates)", "verified_how": "A test drives the exact step sequence: the delegate issues a member-role invitation and captures the created row via the data API (visible), then the refused admin-role attempt (403, #3697 role cap) is followed by a re-read as the delegate.", "finding_beyond_the_prediction": "The item's second step ('read sys_invitation via the API and verify NO row was left behind') was not merely broken before — it was VACUOUSLY PASSING. The delegate read zero rows unconditionally, so the step reported 'no row' whether or not a row had actually been left behind. With the issuer scope in place a leaked row would carry the delegate's own inviter_id and therefore land inside the granted scope, so the step is load-bearing for the first time. Reported rather than silently absorbed, per the ruling's instruction." }, "ablation_prediction_vs_reality": { "method": "Predictions written to a scratchpad file BEFORE the first edit, then each mutation applied to source, plugin-security rebuilt (the dogfood suite consumes dist, so a rebuild is required for the mutation to take effect — verified by grepping dist), and the HTTP fixture re-run.", "M1_delete_the_policy": { "predicted": "RED", "observed": "RED", "evidence": "AssertionError: expected [] to have a length of 1 but got +0; the delegate's by-id fetch of its own row also went 404 RECORD_NOT_FOUND" }, "M2_widen_predicate_to_option_B_id_not_null": { "predicted": "RED", "observed": "RED", "evidence": "AssertionError: expected [ {…}, {…}, {…} ] to have a length of 1 but got 3 — the delegate read the WHOLE ledger; the by-id probe also flipped to 200 on another principal's row. This is the mutation a single-inviter fixture cannot detect." }, "M3_drop_the_positions_domain": { "predicted": "GREEN (known-indistinguishable)", "observed": "GREEN", "evidence": "all 7 HTTP assertions passed — prediction held. Only owner/admin/delegated_admin can ever be an inviter_id and the first two already read everything, so the persona matrix is blind to this. Caught instead by the structural pin: with M3 applied, default-permission-sets.test.ts goes red with 'expected undefined to deeply equal [ delegated_admin ]'. The two layers were measured to be complementary rather than assumed to be." }, "M4_bogus_position_name_in_the_domain": { "predicted": "RED", "observed": "RED", "evidence": "delegate drops to 0 rows — proves the positions domain is actually read by getApplicablePolicies and that the delegate's visibility comes from THIS policy, not from some other admission in play" } }, "design_note_positions_domain": "The domain is deliberate despite M3 showing it changes no persona's result today. Without it a principal DEMOTED out of an administrative grade keeps a permanent window onto the invitations it issued while it held one — wider than 'a delegated_admin sees the invitations they themselves issued'. It matches the shipped precedent on this same object (sys_invitation_org_admin is positions-domained) and only ever widens: a principal it does not match keeps the addressee scope and fails closed.", "file_surface": [ "packages/plugins/plugin-security/src/objects/default-permission-sets.ts", "packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts", "packages/plugins/plugin-security/src/objects/rbac-objects.test.ts", "packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts", ".changeset/delegated-admin-own-invitations.md" ], "disjointness_confirmed": "Yes — verified by measurement before the first edit, not assumed. Read each in-flight worktree's `git diff --name-only origin/main...HEAD`: #8317 = plugin-auth/src/{auth-plugin,index,member-role-canonical}.ts; #8243 = plugin-auth + runtime/src/security; #8119 = plugin-sharing. Zero overlap with my five files. Nearest neighbour #8317 was read in detail because it normalises sys_member.role: its canonicalisation is per-token trim + lower-case against the ADR-0108 closed vocabulary, and `delegated_admin` is already in that vocabulary, so it normalises unchanged and the position name my policy domains on is stable whichever card lands first. No file another card is editing was touched.", "rbac_objects_note": "packages/plugins/plugin-security/src/objects/rbac-objects.test.ts enumerates member_default's RLS policy names exhaustively and had to name the new policy. That is an in-scope consequence of adding one, not a ratchet change — no baseline, ledger or debt file was touched by this PR (verified: the only .json in the diff is a test file path match).", "tests": "ALL FOREGROUND, serialised under flock /tmp/os-heavy-verify.lock. (1) Build closures first: `pnpm --filter '@objectstack/plugin-security^...' build` and `pnpm --filter '@objectstack/dogfood^...' build` — the dogfood typecheck initially failed with ~30 TS2307 'Cannot find module @objectstack/verify' errors that were purely the unbuilt-closure symptom, clean after the build. (2) `pnpm --filter @objectstack/plugin-security test` → Test Files 51 passed (51), Tests 1033 passed (1033). (3) `pnpm --filter @objectstack/dogfood exec vitest run test/invitation-ledger-row-scope.dogfood.test.ts` → 7 passed (7), all four personas plus the checklist-step and by-id probes. (4) Full dogfood suite → Test Files 107 passed | 1 skipped (108), Tests 750 passed | 2 expected fail | 3 skipped (755). (5) `pnpm --filter @objectstack/dogfood exec vitest run test/invitation-ledger-row-scope.dogfood.test.ts test/delegated-admin-invite.dogfood.test.ts` → 10 passed (10), confirming the ADR-0105 D8 issuance lane still passes alongside the new read scope. (6) typecheck clean on both packages. Dist was grepped for `sys_invitation_issuer` before judging any dogfood result, since that suite consumes built output.", "gates_run_locally": { "check:nul-bytes": "OK — 7603 text files, no raw control bytes; 75 self-test assertions passed. Also self-scanned my five files with grep -naP for the full control range: clean.", "check:engine-double-contract": "OK — 193 pinned, 133 DEBT, 2 exempt. This PR declares NO engine double; the baseline JSON was not touched.", "check:error-code-casing": "OK — 17 self-test cases, no lowercase error codes in 3888 files. (No new error code in this PR.)", "check:agent-model-declared": "OK — 18 self-test cases; the one agent definition declares a model.", "changeset": "A changeset is present (.changeset/delegated-admin-own-invitations.md, plugin-security patch) — this is a user-visible permission change, so `skip-changeset` is NOT applicable and was not applied." }, "gates_skipped": "Everything else in the CI lint battery — the ~50 remaining check:* scripts (authorable-surface, type-check-coverage/debt, adr-anchors, spec liveness, translation bundles, docs link checks, and the rest) — was NOT run locally, per the dispatch contract's local-verification scope. They ran once in CI. Also not run locally: the repo-wide `pnpm test` / `pnpm build` farm, and every package outside plugin-security and qa/dogfood.", "could_not_verify": [ "CI gate conclusions. Reporting at draft-PR time per the maintainer decision of 2026-08-10; no idle-polling of CI was done. Gate status at hand-off: in_progress. mergeable_state was 'blocked', which is the expected shape for a draft.", "Wall-enforcing (multi-org) posture. Every measurement is the stock `single` posture, matching the card's own probe. Layer 0 AND-composes ahead of Layer 1 there, so the issuer predicate can only narrow further and cannot reach outside the caller's organization — but I did not boot a wall-enforcing posture to measure that, so it is reasoning, not measurement.", "The browser lane of the ADR-0105 D8 checklist item. I verified the two data-API steps over real HTTP; I did not drive the invite affordance through the UI.", "Co-landing with #8317. I read its worktree and its canonicalisation leaves `delegated_admin` verbatim, but the two branches were never merged together and tested as one tree." ], "open_questions": [], "out_of_scope_findings": [] }Nothing was filed as an out-of-scope issue: the run turned up no defect outside this card's scope. One non-defect observation, recorded here rather than as an issue because it is standard pnpm semantics and not a repo bug —
pnpm --filter PKG test -- PATTERNforwards the--literally, so the pattern reaches vitest as an argument after--and is ignored; the whole suite runs while appearing to be filtered.pnpm --filter PKG exec vitest run PATHis the form that actually targets one file.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Recording a consequence of #8095's narrowing that the ruling did not decide either way. Measured, not inferred.
What changed and what it left behind
#8095 ruled that
sys_invitationread goes toowner/admin, plus a row-scope carve-out for the addressee. The implementation states the admin admission as an RLS policy inorganization_admindomained bypositionsto the two built-in org-administration identities:delegated_adminnormalizes to neither.mapMembershipRole(packages/spec/src/identity/eval-user.zod.ts) maps onlyowner/admin/member, and unknown values pass through unchanged — so the role reachescurrent_user.positionsas the literaldelegated_admin, matching no listed identity. It also does not receiveorganization_adminfrom the auto-grant:auto-org-admin-grant.tstestsroles.includes('owner') || roles.includes('admin')against the comma-split list, and['delegated_admin']contains neither as an element.Net effect: a
delegated_adminreads only invitations addressed to their own email.Measured
Stock
showcaseboot,singleposture, four personas againstGET /api/v1/data/sys_invitationwith two invitations in the org:Why it may matter
delegated_adminexists precisely as the invitation-issuing principal that is not an org admin (ADR-0105 D8 / #3697: better-auth grantsinvitation: ["create"]to owner/admin only, and under a wall-enforcing posture those two are auto-elevated to tenant admins, so the delegated grade is the one caller the scope-bounded issuance path has). It can create invitations it then cannot list. better-auth's ownlist-invitationsroute is owner/admin-gated too, so there is no second path back.Also note
docs/qa/platform-checklist/areas/identity-auth.json(the ADR-0105 D8 item) has steps phrased as "as the delegated_admin ... capture the created sys_invitation row via the data API" and "immediately read sys_invitation via the API and verify NO row was left behind". Those reads now return nothing for that persona. The item's preferred lane — the pinnedpackages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts— is unaffected, because it reads throughql.findunder a system context. Left alone rather than edited: whether the checklist wording or the permission changes depends on the decision below.The decision
Either:
delegated_adminissues invitations without reviewing the ledger; the checklist steps get re-phrased to read as owner/admin. Cheapest, and consistent with a literal reading of the [finding]org_membercan read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095 ruling.delegated_adminto the admin-side admission (one entry in thepositionsdomain ofsys_invitation_org_admin). Gives the issuing grade visibility of what it can issue. Widens the ledger's audience by one role beyond what [finding]org_membercan read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095 ruled, which is why it is not being done unilaterally.delegated_adminsee the invitations they themselves issued (inviter_id == current_user.id), not the whole ledger. Matches the "scope-bounded issuance" shape of ADR-0105 D8 more closely than B, at the cost of a third policy.No option is being taken here; #8095's PR ships A by default (the status quo of the ruling as written).
Filed from the #8095 implementation. Not assigned.