Skip to content

feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants - #25515

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-cmvqwf
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-cmvqwf

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Part of #25233 (stage 1 of the ruling of record 6355206139, letter B; stage 2 follows in its own PR).

Clause-②: yes (widening)
Metadata-protocol: added

What this does

A supplier's or customer's portal user can now be added to an organization with a new external role. Before this, such a user had to be a plain member. The member baseline's sys_user_org_members scope (id in current_user.org_user_ids) is OR-combined with any application policy. So the portal user read every employee and every other supplier's users through GET /data/sys_user, and better-auth's list-members and get-full-organization doors showed them too.

Clause Where What
role config plugin-auth auth-manager.ts better-auth registers external beside owner/admin/member, with an empty statement set.
E1 stamp core resolve-authz-context.ts sys_member.role = external in the active organization projects to the new reserved identity org_external. Its holder does not get the everyone anchor, and countPositionHolders agrees. The stamp is per organization, and with no active organization an external membership withholds the anchor (fail closed). There is no sys_user column.
E4 baseline plugin-security New shipped set external_default, bound by the org_external built-in position. It holds member_default's own-row carve-outs without sys_user_org_members, plus a sys_member own-row policy. The plugin's additive human baseline ([appDefault, member_default]) is swapped for [external_default] on an external context. So neither an app's isDefault set nor an accepted package default reaches it, by either channel. It is not in MANAGED_DENY_TARGET_SETS, so no registry-driven read injection reaches it either.
E2 doors plugin-auth external-directory-door.ts After-hook on /organization/list-members and /organization/get-full-organization. When the caller's membership in the answered organization is external, it gets its own member row only (total: 1) and only invitations addressed to its own email. Every other caller is untouched.
E3 spec invitation-role-grant.ts [external]: 'any', so the invite dialog offers it to every issuer.
E5 plugin-auth external-membership.ts A role list carrying external with another role is refused at write, on sys_member and sys_invitation, for every context. The refusal is 400 VALIDATION_FAILED, with invalid_value on role.
E6 same module An external is never placed in the organization's business units. Refused: a sys_business_unit_member row, a sys_user_position anchored at one of that organization's units, an invitation conferring external with a business_unit_id, and a role change to external while a placement stands. An organization-wide position or a permission set is how an external gets access.

Spec additions: MEMBERSHIP_ROLE_EXTERNAL and BUILTIN_IDENTITY_ORG_EXTERNAL (with metadata), in the closed vocabulary and the reserved identity names. The i18n bundles carry the External label (zh-CN 外部成员, ja-JP 外部メンバー, es-ES Externo). The api-surface and export-origins files are regenerated.

Acceptance notes

The app-side acceptance (hotsrm 6366488714) is pinned on a real showcase boot in packages/qa/dogfood/test/external-member-directory.dogfood.test.ts (6/6). It boots with the showcase's own isDefault set wired, as objectstack serve does:

  • An external holder reads only its own sys_user and sys_member rows. Two suppliers' externals each get exactly their own id from GET /data/sys_user and GET /data/sys_member. list-members and get-full-organization answer each one its own member row.
  • With a permission set granting one object, it reads that object and nothing else. A supplier with a one-object grant reads showcase_announcement (200) and is refused showcase_product (403). The showcase's isDefault set opens showcase_product to every member. The supplier without a grant is refused both.
  • An ordinary member is unchanged. It still sees all four users at the data door and at list-members, and still reads the app-default object.
  • E5 end to end. An owner's update-member-role with ['external','member'] answers 400, and the stored role stays member.

Armed controls guard each negative case. The member really reads its peers, the app default really opens the object to members, and the fixture roles really landed.

Unit pins:

  • core resolve-authz-context.external-population.test.ts (5). Four of them were red before the resolver change.
  • plugin-security external-population.test.ts (11). These cover the set's shape, own-row filters, no directory scope, the structure tables closed, one grant only, no managed-deny injection, the position binding, and resolution through the service locator with an app default configured.
  • plugin-auth external-membership.test.ts (16) and external-directory-door.test.ts (8).
  • spec membership-role-external.test.ts.

Existing pins moved deliberately:

  • The built-in position count goes from six to seven, and the reserved names from four to five.
  • org_external is the one built-in identity that names a set. A delegate whose allowlist lacks external_default therefore meets the delegated-admin gate's 403 before the reserved-name 400. Either refusal stores nothing, and the posture test says so.
  • external_default joins the field-withholding sets and the canonical set list.
  • The delegated_admin and org_admin invite offers now include external.
  • The closed better-auth role map and the dogfood vocabulary pin now include external.

Notes for review:

  • Clause-②: reserving org_external refuses a new position by that name. No narrowing was measured for an existing holder. better-auth's doors refused external before this change, and no shipped set changed for an existing population. One case is unmeasured: a sys_member row written directly (not through better-auth) with the token external used to project to a position named external, and now projects to org_external. No such row exists in this repo's fixtures or seeds.
  • Out of scope, reported here for stage 2 or follow-up:
    • better-auth's list-teams and the teams member of get-full-organization still check organization membership only, so an external sees the organization's team names (not rosters: list-team-members also requires the caller to be on that team).
    • member_default reads sys_team, sys_sso_provider and the OAuth resource tables with no row scope. That is not an external issue now, since externals no longer hold it.
    • The explain/describe surfaces still name baselinePermissionSets. This is the stage 2 explainer work.
    • Externals have no owner_only OWD floor yet. That belongs to the OWD seam in stage 2.

Verification: the plugin-security (4478), plugin-auth (3607) and core security (1092) suites pass after merging main. The role and identity pin tests in thirteen other packages pass. typecheck is clean for spec, core, plugin-security, plugin-auth, platform-objects and dogfood. All 109 derived gates were run and reconciled with dispatch-gates --ran (0 not measured).

🤖 Generated with Claude Code

https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN

The external member's own anchor-safe set: member_default's own-row
carve-outs without the fellow-member directory scope, plus its own
sys_member row. The org_external identity position names it, and the
additive human baseline is swapped for it on an external context, so
neither member_default nor an app isDefault set reaches an external.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…doors

Registers `external` with better-auth beside owner/admin/member, holding
no organization statement. Engine hooks refuse a role list carrying
external with another role (sys_member, sys_invitation) and keep an
external out of the organization's business units (BU membership, a
BU-anchored position, an invitation's business_unit_id, a role change
while a placement stands). list-members and get-full-organization
answer an external caller its own member row and own invitations only.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The External option label on the membership role pickers (zh-CN, ja-JP
and es-ES translated) and org_external in the reserved-identity message;
the spec api-surface and export-origins entries for the two new members.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…only

On a real showcase boot: an external holder reads only its own sys_user
and sys_member rows at the data door and at better-auth's list-members
and get-full-organization; with one permission set it reads that object
and not the showcase isDefault objects; an ordinary member is unchanged;
a role list mixing external with member is refused. Adds the changeset.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The two new in-memory find doubles throw on a combinator key instead of
reading it as a field name (check:where-matcher), and the engine-double
ledger records the new external-population findOne double.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 23, 2026
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/core, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/spec, touching 97 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/identity.json, packages/spec/api-surface/root.json, packages/spec/export-origins/identity.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

63 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json dc3e5b9d7b0d978afebd8f12f4155744bc53e32a.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/identity.json, packages/spec/api-surface/root.json, packages/spec/export-origins/identity.json, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 43 pages), sys_user (literal, 43 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 161 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dc3e5b9d7b0d978afebd8f12f4155744bc53e32a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e9418d9715ac36987622d49476ce6893f7e3717f — the merge of head 1576d4a1d5e12ecc0db8dacb67845b7b7f3eb13c into base dc3e5b9d7b0d978afebd8f12f4155744bc53e32a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e9418d9715ac36987622d49476ce6893f7e3717f && git checkout e9418d9715ac36987622d49476ce6893f7e3717f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc3e5b9d7b0d978afebd8f12f4155744bc53e32a 1576d4a1d5e12ecc0db8dacb67845b7b7f3eb13c && git checkout -B drift-repro dc3e5b9d7b0d978afebd8f12f4155744bc53e32a && git merge --no-ff 1576d4a1d5e12ecc0db8dacb67845b7b7f3eb13c

node scripts/docs-audit/affected-docs.mjs --json dc3e5b9d7b0d978afebd8f12f4155744bc53e32a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dc3e5b9d7b0d978afebd8f12f4155744bc53e32a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7ed9b5a8e2b74cb6995d34f1fc98bfdb339c2030
Local-runs: none
Scope: packages/spec/src/identity/eval-user.zod.ts, packages/spec/src/identity/invitation-role-grant.ts, packages/spec/src/identity/membership-role.ts, packages/spec/src/index.ts + packages/spec/api-surface/identity.json, packages/spec/api-surface/root.json, packages/spec/export-origins/identity.json, packages/spec/export-origins/root.json, packages/spec/src/identity/membership-role-external.test.ts, packages/core/src/security/resolve-authz-context.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/builtin-positions.ts, packages/plugins/plugin-security/src/security-plugin-permission-sets.ts, packages/plugins/plugin-security/src/objects/reserved-identity-names.ts (unchanged, cited), packages/plugins/plugin-security/src/object-default-row-scope.ts (unchanged, cited), packages/plugins/plugin-security/src/managed-object-write-denies.ts (unchanged, cited), packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/external-directory-door.ts, packages/plugins/plugin-auth/src/external-membership.ts, .changeset/25233-external-org-role.md

Inputs read at 2026-10-23T17:41Z: card #25233 body and all 8 comments (ruling 6355206139 letter B of 2026-10-23T01:30Z; os-dev-report 6344121515 with E1–E5; app-side measurement 6366488714; os-dev-report 6370873115), PR #25515 body, its 45-file list, the net diff origin/main...7ed9b5a8, and the 47 check-runs on the head. The rest of the diff (platform-objects and plugin-security generated translations, test files, the engine-double ledger pin) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

Security change, stage 1 only of ruling B. Each accept-set / public-surface change the diff implies, judged against the ruling's E1–E6 and the card's acceptance:

  1. Closed vocabulary widened (membership-role.ts) — right. MEMBERSHIP_ROLE_EXTERNAL = 'external' joins BUILTIN_MEMBERSHIP_ROLES (last, display order) and BUILTIN_MEMBERSHIP_ROLE_OPTIONS (External). BuiltinMembershipRole widens by one literal; the satisfies Record on INVITATION_ROLE_GRANTORS forces the E3 row, so the vocabulary and the grant table cannot drift. The docblock states exactly what the code elsewhere does (no endpoint, own rows only, exclusive, outside the BU tree).
  2. Reserved identity added (eval-user.zod.ts) — right. BUILTIN_IDENTITY_ORG_EXTERNAL = 'org_external' joins BUILTIN_IDENTITY_NAMES and the BUILTIN_IDENTITY_METADATA record (typed over BuiltinIdentityName, so a missing entry would not compile). mapMembershipRole('external') projects to it, trim/lower-cased like the others. Consequences derived from the constant, all right: RESERVED_IDENTITY_NAMES IS the constant (reserved-identity-names.ts:68), so sys_position / sys_user_position refuse org_external from every writer (translation of the validation message regenerated accordingly); the ObjectQL security-catalog namespace reserves it; securityBuiltinPositions registers it (6 → 7).
  3. E3 grant row (invitation-role-grant.ts) — right. [external]: INVITATION_ROLE_ANY_ISSUER, same as member; it confers less than member (no statement, no anchor, own-row set), so the table's own rule admits any issuer. The invite offer pins move accordingly (delegated_admin now offered member, external).
  4. Public surface (index.ts, api-surface, export-origins) — right. Both new consts exported from root and identity; the four JSON ledgers carry exactly those two entries each, regenerated, nothing else moved. Spec property liveness is green.
  5. E1 stamp (resolve-authz-context.ts) — right. holdsExternalIdentity reads the projected name off heldPositionNames, never the raw role, and org_external cannot be spelled by a sys_user_position row (item 2). Both anchor-injection sites in the resolver are guarded (countPositionHolders L1373, resolveUserAuthzGrants L1819); a git grep of EVERYONE_POSITION / 'everyone' over core, plugin-security and plugin-auth non-test source finds no third site that pushes the anchor. Per organization: heldPositionNames filters sys_member by tenantId, so external in A / member in B is a member in B. No active org: fail closed — every membership projects, one external membership withholds the anchor (pinned by the core test "with no active organization … withholds the anchor"). org_user_ids is still computed for an external (step 5) but no policy it holds references it.
  6. E4 baseline (default-permission-sets.ts, builtin-positions.ts, security-plugin-permission-sets.ts) — right. external_default: no '*', no allowDelete, every granted object under an own-row _self policy or the object's own _self default; sys_user_org_members is the one carve-out omitted; sys_member_self (select) added; structure tables, sys_team, SSO and OAuth resource tables not named; identityAdminFieldSecurity('withhold') on the holder's own row. Not in MANAGED_DENY_TARGET_SETS (L123–129), so the kernel:ready read injection never reaches it. Bound statically to org_external in the registry declaration (the only built-in naming a set), and the plugin's additive baseline is swapped [appDefault, member_default] → [external_default] when positions includes org_external; the post-resolution fail-open fallback (L203+) also re-resolves baseline, which is the external set on that branch, so an external with nothing of its own never falls to member_default. The sys_user.defaultRowScope (id in current_user.org_user_ids) cannot re-open the directory: object-default-row-scope.ts applies it only when the held collection for that object+operation is EMPTY, and sys_user_self is operation: 'all'.
    Residual paths to member_default / viewer_readonly / an app isDefault set / the directory for an external holder, enumerated: (a) everyone anchor — withheld (item 5); (b) plugin baseline list — swapped; (c) position-name fold into the request — org_external names no shipped set; (d) a sys_user_permission_set row or a tenant position whose definition binds member_default / viewer_readonly — reachable, and correctly so: that is an explicit grant, which the ruling admits (an administrator who hands an external the directory set has chosen to); (e) accepted package defaults bound to everyone in the catalog — read only through held positions, and everyone is not held. None of (a)–(c)/(e) leaks. Judged: no unintended path remains in stage 1's data plane.
    Ordinary member unchanged — right: the non-external branch of every edit is byte-equivalent to main (everyone pushed, baseline list untouched, member_default body untouched); CONTROL cases pinned in core, plugin-security, plugin-auth and the dogfood boot.
  7. better-auth role (auth-manager.ts L3479) — right. external registered with an empty action list on every resource, so every permission-gated organization endpoint refuses it (the auth-manager.test.ts pin asserts [] per resource). The closed role map pin moves 4 → 5.
  8. E2 doors (external-directory-door.ts, after-hook at auth-manager.ts L2975) — right. The after-hook's earlier returns are path-gated to sign-in / password / change-email, so the two directory paths reach the narrowing. The organization judged is the one the door ANSWERED for (full-org id; list-members members[0].organizationId, then slug/id query, then active org), which is what the test "a member elsewhere is unchanged there" pins. total forced to 1, invitations filtered to the caller's own email, and a role read that throws withholds members and invitations (fail closed). No-engine (mock mode) = no narrowing = status quo, acceptable since no data plane exists there either.
  9. E5 / E6 (external-membership.ts, registered at priority 6 in auth-plugin.ts) — right. Hooks on beforeInsert + beforeUpdate for sys_member, sys_invitation, sys_business_unit_member, sys_user_position, for every context (isSystem included), judging the merged row (previous + data), after the role canonicalisation (5) and before the identity write guard (10). Mixed list → 400 VALIDATION_FAILED / invalid_value on role; BU placement of an external → refused on the placing row; invitation external + business_unit_id → refused; change to external while placed → refused; an organization-wide position (no business_unit_id) admitted. Matches the maintainer's E6 wording.
  10. Stage 2 not smuggled — right. The diff touches no ExecutionContext.audience stamping, no derivePosture, no OWD seam / owner_only_* floor for externals, no access-matrix column; explain/describe still read baselinePermissionSets (dev-noted for stage 2). Grep of the added lines for audience, derivePosture, OWD, owner_only finds only comments saying so.
  11. One consistency note, not a defect: the resolver treats a role list that CONTAINS external as external (held.includes), while the directory door's isExternalMembershipRole requires the list to be EXACTLY external. The two agree on every row the engine admits (E5 refuses a mixed list for every context); they diverge only for a row written beneath the engine, where the resolver fails closed and the door does not narrow. Acceptable in stage 1; worth one line in the door's docblock.

② Semver level

Changeset .changeset/25233-external-org-role.md: minor on @objectstack/spec, @objectstack/core, @objectstack/plugin-security, @objectstack/plugin-auth, @objectstack/platform-objects — exactly the five published packages with source changes (qa/dogfood is private; scripts ledger is repo tooling). Check Changeset is green.

Clause-②: yes (widening) — judged right against the diff. Everything published is additive: one vocabulary value, one reserved identity with metadata, one shipped set, one position binding, two exported consts. The two narrowings present are not of an existing holder: (i) org_external becomes a refused position name — disclosed in the changeset's last line, no shipped fixture or seed uses it; (ii) a sys_member.role written beneath better-auth with the raw token external used to project to a position named external (which bound nothing) and now projects to org_external and loses the everyone anchor — the dev's declared unmeasured case; no such row exists in the repo's fixtures or seeds, and better-auth refused the role at its door before this change, so no measured holder narrows. Metadata-protocol: added — right: new closed-vocabulary value and new reserved name, nothing removed or renamed; the changeset's package bullets match the diff file by file.

③ Boundary flags

Dev flags (PR body + report 6370873115), each answered:

  • File surface beyond the order (resolve-authz-context.ts, spec eval-user.zod.ts / index.ts, generated i18n, surface ledgers, engine-double pin, dogfood): accepted — E1 names the resolver's provisioning boundary as the one place for the stamp, and the rest are the derived regenerations of the spec change.
  • Clause-② unmeasured raw-row case: accepted as declared (② above); nothing to measure in-repo.
  • Pins moved (6→7 built-ins, 4→5 reserved names, invite offers, posture test 403-before-400 for org_external on the delegated branch): each read in the diff; each is the derived consequence of items 2, 3 and 6, and the posture test still asserts nothing is stored.
  • Out of scope: team names via list-teams and get-full-organization.teams. Judged: a follow-up, not a stage-1 defect. E2 as ruled names the two doors (list-members, get-full-organization) and the rung forbids "the member directory"; team NAMES are not member identity rows, and list-team-members requires the caller on that team, so no member row leaks through teams. It is the same leak shape one door over, and the teams member sits on a response this PR already rewrites, so it is cheap to close; the seat should file it as its own class-b card (or fold it into the domain:services directory-doors card from triage 6345105238). Likewise unverified here, for the same card: whether better-auth's /organization/list-invitations is membership-only (better-auth's dist is not present in this container, so I could not read crud-invites.mjs); if it is, an external reads pending invitees' emails there. Not blocking stage 1.
  • Out of scope: member_default unscoped reads on sys_team / SSO / OAuth resource tables: follow-up; externals no longer hold that set, so not this card's leak.
  • Out of scope: explain/describe baseline naming, no owner_only OWD floor for externals: stage 2 by the ruling's own split.
  • open_questions: none in the done report. Docs-drift bot comment on the PR is informational.
  • Check-runs on the head (read at 2026-10-23T17:41Z): 47; none failed. Still in progress: Test Core (1/6 … 6/6), Build Core, Type Check · consumer gates, Type Check · workspace. Merge waits on those families finishing green; a failure in any of them re-opens this verdict.

Implemented-by: claude/project-thread-cmvqwf
Reviewed-by: contract-review subagent of session_013odhA26dvzy7pQmevdTTvy

VERDICT: PASS


Generated by Claude Code

The two hand-written pages that state the vocabulary, and the spec
describe strings the organization reference page is generated from.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The ownership inventory holds SHIPPED_PERMISSION_SET_NAMES equal to the
security plugin's shipped sets, which gained external_default.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (seat post #6017) · os-tesla · session session_013odhA26dvzy7pQmevdTTvy · 2026-10-23T18:59Z

Head 1576d4a1d5 · card #25233 (stage 1 of ruling B) · claim 6368207082 · report 6370873115 · ruling 6355206139 (letter B) · contract review 6371057486 (PASS, on 7ed9b5a8e2) · stage 2 scope note 6371125645.

  • Shape: draft, base main, body line Part of #25233 (stage 2 follows on the same card). 50 files, +1667 / −74. check-governed-merges re-run on the final file list: not governed, 1741 lines ≤ 3000 ⇒ ordinary queue landing.
  • Delta since the reviewed head, judged at seat tier: ce50f44c is docs only. It corrects the closed-vocabulary statements the bot flagged ("exactly four values"), in authentication.mdx and positions.mdx, the two .describe() strings in identity/organization.zod.ts, and the regenerated references/identity/organization.mdx. 1576d4a1 adds external_default to the CLI's SHIPPED_PERMISSION_SET_NAMES, which a cli test pins equal to securityDefaultPermissionSets. No install seeded that set before the platform stamp, so the legacy-adoption predicate never meets an existing row. Neither commit changes the security data plane the review judged.
  • Changeset: .changeset/25233-external-org-role.md, minor on the five source packages; @objectstack/cli is in the same fixed group as @objectstack/spec, so it is versioned with it.
  • Gates pinned to the head: 42 check-runs on 1576d4a1d5, 36 success + 6 skipped, 0 red. Test Core (4/6) was red on ce50f44c (the cli pin above), fixed by 1576d4a1.
  • Carried to stage 2, not this PR: team names via list-teams / get-full-organization.teams; whether /organization/list-invitations is membership-only; the resolver vs door docblock line.
  • Landing: pr_ready + automerge_enable through the relay now. Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233 stays pm:dispatched for stage 2, serialised with Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234 PR ② (both touch plugin-security; whichever lands second merges main).

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 23, 2026 19:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 23, 2026 19:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 9019af9 Oct 23, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-cmvqwf branch October 23, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants