Skip to content

feat(security): the external organization role is the EXTERNAL principal — owner-only org-wide default, explicit grants only - #25541

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

objectstack-fleet[bot] merged 6 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

Closes #25233

Stage 2 of the ruling of record 6355206139, letter B (item 2, with the scope note 6371125645). Stage 1 landed in #25515. The Tier H ADR text follows in its own docs PR once this lands.

Clause-②: yes (widening: @objectstack/core gains owdForAudience, isExternalAudienceContext, externalGrantSpeaks and the two grant-stamp keys; every narrowing below applies only to the external role, which no release has shipped yet)
Metadata-protocol: semantic

What this does

A holder of the external organization role is now the EXTERNAL principal. It reaches rows through explicit grants only: shares (share links included), sharing rules, and the position and permission-set grants it holds, with their RLS evaluated. The internal org-wide default never widens it.

Item Where What
audience and posture core resolve-authz-context.ts, posture-ladder.ts, assemble-execution-context.ts The resolver stamps audience: 'external' on a holder and derivePosture returns EXTERNAL. Precedence is PLATFORM_ADMIN, then EXTERNAL, then TENANT_ADMIN, then MEMBER. The assembled context carries the audience, on agent envelopes too. POSTURE_INJECTION_RULE.EXTERNAL states the channel list.
OWD seam core external-owd-floor.ts; plugin-sharing sharing-service.ts owdForAudience floors an external's effective model to private (owner plus shares) in buildReadFilter, buildWriteFilter, checkEdit and checkDelete. The floor lifts for one object and one operation class when plugin-security has stamped that a held grant's RLS speaks for it.
grant stamp plugin-security external-owd-grant-stamp.ts, security-plugin.ts step 2.6, security-plugin-sharing-verdicts.ts For an external, __externalReadGrant / __externalWriteGrant hold the object name when a held set's using policy applies (the write class follows Layer 1's select-derives-write rule). The object's declared default row scope and the platform ownership floor are not grants. The keys are operation-private, and a stamp naming another object reads as no grant.
write floor plugin-security default-permission-sets.ts external_default carries owner_only_writes and owner_only_deletes, bound to org_external. They share the member floor's provenance key, so every relief of a member's floor relieves an external's. Without it an external's write reach on a public object was org-wide, wider than a member's (I2).
access matrix plugin-security authz-matrix-gate.test.ts The EXTERNAL column, at Layers 0 and 1 (the sharing floor is pinned in plugin-sharing): on task the external reads in-org rows its grant opens and writes only rows it created; private_obj and platform_global are denied; on better_auth it reads and writes its own row only. The ladder order includes it.
explain and describe plugin-security explain-engine.ts, security-plugin-explain-access.ts, security-plugin.ts Explain and describe resolve the baseline the principal actually gets (humanBaselineFor), so an external's is external_default, attributed as "external baseline (org_external)". The OWD layer reports "owner_only for an external principal" when no held grant speaks, using the same predicate as the stamp.
directory doors plugin-auth external-directory-door.ts, auth-manager.ts list-teams and get-full-organization's teams answer an external only the teams it sits on (read from sys_team_member). list-invitations answers it only invitations to its own email. The door docblock notes that the resolver uses includes('external') while the door requires exactly 'external', which E5 makes the same population.
spec prose spec execution-context.zod.ts, explain.zod.ts The audience docblock and the EXTERNAL posture description name the channels. Generated reference docs are regenerated.

Measured for the scope note: better-auth 1.7.12's listInvitations (crud-invites.mjs) checks membership only and returns every invitation of the organization, so it is narrowed. listOrganizationTeams and get-full-organization's teams (with teams.enabled, set in auth-manager.ts) check membership only too.

Acceptance notes

On a real showcase boot, packages/qa/dogfood/test/external-member-directory.dogfood.test.ts adds five stage 2 cases (11/11 pass):

  • The OWD baseline never widens an external. On showcase_announcement (public_read), the external with a plain read grant gets exactly its own row and the row shared to it, out of four.
  • A held grant's RLS lifts the floor and bounds the rows. A second external holds a read grant with title == '<portal title>' as its select policy. It gets exactly the one matching row, which it neither owns nor was shared.
  • Teams. list-teams and get-full-organization's teams answer the external only the team it sits on. An external on no team gets [].
  • Invitations. list-invitations answers the external only the invitation to its own email. Another external gets [].

Armed controls: the member reads all four fixture rows, and the member is answered both teams and both invitations.

Unit pins:

  • core posture-ladder.test.ts (EXTERNAL ⊆ MEMBER with the new grant row, the injection rule, and derivation precedence), external-owd-floor.test.ts (6), the resolver's external-population audience and posture cells, and assembly audience carriage.
  • plugin-sharing external-owd-floor.test.ts (15): reads and writes on every public model, stamp scoping by object and class, ownerless objects, and member controls.
  • plugin-security external-owd-grant-stamp.test.ts (9), explain-external-principal.test.ts (6), and the matrix's EXTERNAL column with an end-to-end stamp check.
  • plugin-auth external-directory-door.test.ts (teams, full-org teams and invitations, with member controls).

Existing pins moved deliberately:

  • platform-ownership-policies.test.ts now deduplicates the floor by its identity key. The floor has two carriers, member_default and external_default, and a new case pins exactly those two.

Notes for review:

  • Clause-②: for an external holder this is a narrowing (no OWD widening, the write floor, and the narrowed doors), but stage 1's external role has not shipped in a release yet: its changeset is still pending in .changeset/. No principal without the role changes.
  • Fix after the first review (225d5bc): the floor no longer applies to a controlled_by_parent detail. Its rows follow the master (ADR-0055), and the master already meets the floor. Before the fix, an external who was shared a master read none of its lines. plugin-sharing's external-owd-floor.test.ts had pinned that wrong answer, and the first contract review (6375883728) passed it. The fix is owdForPrincipal in sharing-service.ts and the same exemption in explain's OWD layer. A showcase dogfood case pins it: showcase_invoice is shared to an external, who reads its two showcase_invoice_line rows and not the other invoice's. The case is red with the exemption removed (measured) and green with it. The merge of main (c70863f) also resolved the conflict with refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts (#25224 cut 12) #25536, which moved the after-hook.
  • Reading of "access matrix": I took it as the EXTERNAL role column of authz-matrix-gate. ADR-0090 D11 also mentions a "D6 access matrix external-audience column", which is the explain matrix. If that was meant, it is a follow-up.
  • Out of scope, found while measuring: a share on an object whose effective model is public (public_read_write, controlled_by_parent, or a model-less system object) is refused at create as "inert" (inertGrantReason; public_read is not refused). Now that such an object is owner-only for an external, a share to an external there is no longer inert, so the create gate, which also runs for sharing-rule materialisation, closes those two channels on that set of objects. Existing rows are honoured, and the direction is fail-closed. This needs its own card and a ruling (contract review 6375883728).
  • Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234 PR ② also touches plugin-security. Whichever lands second merges main.

Verification: the plugin-security (4602), core (2806), plugin-sharing (1224), plugin-auth (3620), plugin-approvals (1233) and rest (6608) suites pass. They ran before the merge of main, which touched only plugin-auth among these packages; after it, plugin-auth's tsc and its external door and membership tests (37) re-pass. tsc is clean for core, plugin-security (including the test program), plugin-sharing, plugin-auth and dogfood. All 121 derived gates were run and reconciled with dispatch-gates --ran (0 not measured). The diff is 1543 insertions and 142 deletions across 35 files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN

…pal — owner-only org-wide default, explicit grants only (#25233 stage 2)

Ruling of record 6355206139, letter B, item 2.

- The resolver stamps audience 'external' on a holder of the external
  role and derivePosture returns EXTERNAL (platform admin still outranks
  it); the assembled context carries the audience to agents.
- plugin-sharing floors an external's org-wide default to owner-only on
  every object. plugin-security lifts that floor per object and
  operation class when a held grant's RLS speaks for it, by an
  operation-private stamp naming the object.
- external_default carries the platform's owner-only write floor,
  bound to org_external, so EXTERNAL never writes wider than MEMBER.
- The authz matrix gains the EXTERNAL column; explain and describe name
  the external baseline and report the owner-only floor.
- list-teams and get-full-organization teams answer an external the
  teams it sits on; list-invitations, membership-only in better-auth
  1.7.12, answers it its own email's invitations.

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 5 package(s): @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/spec, touching 83 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/core/src/security/index.ts, packages/plugins/plugin-security/src/platform-ownership-policies.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

33 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 4df174bcc48d694e654f90105ebd9e68d3545136.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/index.ts, packages/plugins/plugin-security/src/platform-ownership-policies.ts) — pages documenting those are invisible to this run
  • 2 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 — 156 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 4df174bcc48d694e654f90105ebd9e68d3545136 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 16cf9bd1f14b8d30d5fb3516f1692be47d454130 — the merge of head 225d5bc02c4dcb441fd812288de8209d98192a48 into base 4df174bcc48d694e654f90105ebd9e68d3545136, 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 16cf9bd1f14b8d30d5fb3516f1692be47d454130 && git checkout 16cf9bd1f14b8d30d5fb3516f1692be47d454130
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4df174bcc48d694e654f90105ebd9e68d3545136 225d5bc02c4dcb441fd812288de8209d98192a48 && git checkout -B drift-repro 4df174bcc48d694e654f90105ebd9e68d3545136 && git merge --no-ff 225d5bc02c4dcb441fd812288de8209d98192a48

node scripts/docs-audit/affected-docs.mjs --json 4df174bcc48d694e654f90105ebd9e68d3545136

⚠️ 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 4df174bcc48d694e654f90105ebd9e68d3545136 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…hannels (#25233 stage 2)

An external principal is an organization member holding the external role;
its org-wide default is owner-only, and shares, sharing rules, share links
and held grant RLS widen it.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…word (#25233 stage 2)

check:role-word ratchets the word in hand-written docs (ADR-0090 D3).

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9176322153840c3c0a9315fd7db0dc6567bfdde8
Local-runs: none
Scope: packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/security/explain.zod.ts + packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/posture-ladder.ts, packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/external-owd-floor.ts, packages/core/src/security/index.ts, packages/core/src/security/posture-ladder.test.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/external-owd-floor.test.ts, packages/plugins/plugin-security/src/external-owd-grant-stamp.ts, packages/plugins/plugin-security/src/external-baseline.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts, packages/plugins/plugin-security/src/security-plugin-permission-sets.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/platform-ownership-policies.ts, packages/plugins/plugin-security/src/authz-matrix-gate.test.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts (unchanged, cited), packages/plugins/plugin-security/src/object-default-row-scope.ts (unchanged, cited), packages/plugins/plugin-auth/src/external-directory-door.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/external-directory-door.test.ts, packages/platform-objects/src/identity/sys-team-member.object.ts, packages/platform-objects/src/identity/sys-account.object.ts, packages/platform-objects/src/identity/sys-oauth-access-token.object.ts, packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts (all four unchanged, cited), packages/qa/dogfood/test/external-member-directory.dogfood.test.ts, content/docs/permissions/sharing-rules.mdx, .changeset/25233-external-posture-owd-floor.md

Inputs read at 2026-10-23T22:19Z: card #25233 body and all 11 comments (ruling 6355206139 letter B; design report 6344121515 with options A/B/C and E1–E5; stage-2 scope note 6371125645; the stage-1 contract review 6371057486 on #25515 as context; the stage-2 os-dev-report 6375701518), PR #25541 body, its 36-file list, the net diff origin/main...9176322 (merge-base 5ea211c), and the 42 check-runs on the head: 34 success, 8 skipped (path-scheduled or opt-in: the dogfood shard matrix, Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, the two opt-in harnesses), none failed, none in progress. The rest of the diff (the regenerated reference mdx under content/docs/references, the remaining unit tests, the resolver and assembly test cells) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

Stage 2 of ruling B. Each accept-set and public-surface change the diff implies, judged against the ruling's channel list (shares incl. share links, sharing rules, position and permission-set grants with their RLS evaluated; never the internal OWD baseline, the everyone default sets or the member directory) and the scope note's three items.

  1. Spec prose (the hits) — right. ExecutionContextSchema.audience's docblock and AuthzPostureSchema's .describe move from "explicitly shared rows only" to the ruled channel list, the owner_only floor and the BU-tree exclusion. The enums are unchanged; no key added or removed; the .describe change re-renders in the generated reference pages, which are regenerated in the diff (Build Docs and Spec property liveness green). The prose states exactly what the code below does.

  2. Audience and posture (resolve-authz-context.ts, posture-ladder.ts, assemble-execution-context.ts) — right. holdsExternalIdentity(grants.positions) (stage 1's projection of sys_member.role = external to org_external, per organization, fail-closed with no active organization) is the one input: it sets grants.audience = 'external' and isExternal into derivePosture, which returns EXTERNAL after PLATFORM_ADMIN and before TENANT_ADMIN. Precedence judged right: E5 refuses external beside owner/admin, so an org-admin grant can reach an external only as an explicit grant, and the ruling keeps "EXTERNAL is not a restricted MEMBER"; the unscoped platform grant stays deployment standing. Every holder therefore gets both stamps, through the one resolver every transport uses; ENTRY_EXECUTION_CONTEXT_FIELDS already carried audience (it was the undefined placeholder), so the assembled context now carries authz.audience on the human face and on the agent envelope (the human's population, intersected by plugin-sharing's delegator leg); a guest never carries it. No pre-existing 'EXTERNAL' reader in non-test source outside core wakes up (grep: external-owd-floor.ts, the ladder constant, derivePosture, and the reference model's case 'EXTERNAL' are the only ones), so making the rung live activates no dormant deny branch elsewhere.

  3. The OWD seam (external-owd-floor.ts, sharing-service.ts, external-owd-grant-stamp.ts, step 2.6, security-plugin-sharing-verdicts.ts) — right. owdForAudience returns 'private' for an external on every non-private effective model (read, public, and the model-less system default) unless the operation context carries a stamp naming THIS object for THIS class; it is applied in buildReadFilter, buildWriteFilter, checkEdit and checkDelete, after the guest deny-all. The stamp is produced where the grants resolve: step 2.6 of the CRUD middleware on the live opCtx.context (only when isExternalAudienceContext, so an internal principal is byte-unchanged), and as a patch on every rebuilt context handed to a sharing gate (read filter, canEdit, write verdict, explain). The predicate counts only a using clause on a held set's policy, excluding the object's defaultRowScope (isObjectDefaultRowScopePolicy) and the ownership floor (isPlatformOwnershipFloorPolicy, keyed on object+name+using so the new external_default copies are excluded too); the write class borrows from select as Layer 1 does, the read class never borrows. A stamp is object-named, class-separated, and stampExternalGrant deletes a stale one. I read external_default's full policy list at the head: every using policy is object-specific (auditLedgerRowScope is sys_audit_log only); the only object: '*' policies are the two floor copies, which the provenance key excludes. So the floor lifts only where a grant's RLS genuinely speaks, which is the ruling's "with their RLS evaluated" sentence made mechanical (Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234's dependency).
    "Exactly its explicit grants, nothing by default", enumerated: no grant → external_default alone → own identity rows (own sys_user/sys_member; sys_team_member, sys_account, the two OAuth token tables fall to their objects' own _self defaultRowScope, read at the head), and the CRUD gate refuses every other object (matrix: private_obj, platform_global denied). A grant with no RLS on a public object → the object opens, rows are own + shared (dogfood supplierA: 2 of 4). A grant with RLS → the object's model stands, the RLS bounds the rows (dogfood supplierC: exactly 1; matrix stamp cells on the real middleware). The posture-ladder reference model is rewritten to the same three sources (own, shared, grant-visible, in-org only) and EXTERNAL ⊆ MEMBER still holds by construction.

  4. Write floor on external_default (default-permission-sets.ts) — right, and owed although the order did not name it. Without owner_only_writes/owner_only_deletes bound to org_external, an external's by-id write on an object its grant opened reached every row while a member's stopped at its own (the dev's I2): the EXTERNAL ⊆ MEMBER nesting broken on writes. Same name/object/using as the member floor, so provenance and every relief apply identically; the floor is position-bound, so no member resolves it. platform-ownership-policies.test.ts now dedupes by identity key and pins the two carriers.

  5. Explain and describe (external-baseline.ts, explain-engine.ts, security-plugin-explain-access.ts, resolveFallbackPermissionSets) — right. One helper (humanBaselineFor) names the baseline for the three readers that must agree (data plane, explainer attribution, describe fallback), so an external's baseline is external_default, attributed "external baseline (org_external)", and the member path is [...configured] (a copy; nothing compares by reference). Explain's OWD layer reports "owner_only for an external principal" through the SAME grantRowPolicySpeaks over the SAME collectRLSPolicies, so the report cannot disagree with the query. Explain's context from buildContextForUser now carries audience.

  6. Directory doors (external-directory-door.ts, auth-manager.ts) — right, scope-note items 1–3 closed. list-teams and get-full-organization.teams answer an external only the teams it sits on (sys_team_member, read under system context); list-invitations only its own email, case-insensitive; a failed role read withholds everything (withholdAll, members/total/invitations/teams or the bare array), a failed team read keeps no team, a missing email keeps no invitation; the organization judged is the one the rows name, then the query, then the active org, so a member elsewhere is unchanged there. The docblock line on includes('external') vs exactly 'external' is present and states E5 as the reason they agree. ExternalDirectoryDoorDeps gains a required readUserTeamIds; the module is not re-exported from plugin-auth's index, so this is not a published surface.

  7. The access matrix (authz-matrix-gate.test.ts) — right. The EXTERNAL column at Layers 0 and 1: task read walled to the org and write AND created_by = me (the floor, now from external_default), private_obj and platform_global CRUD-denied, better_auth own row only; the ladder-order check adds external at the foot; the stamp is measured end to end on the real middleware (no-RLS grant → not stamped; RLS grant → stamped read and derived write; stale stamp cleared; member never stamped).

  8. An ordinary member is byte-for-byte unchanged — right. owdForAudience returns the model untouched for a non-external context; step 2.6 stamps only inside isExternalAudienceContext; the door's NARROWED_PATHS branch returns before any mutation for a non-external caller; derivePosture with isExternal absent is the old function; audience stays undefined for a member exactly as before; humanBaselineFor returns the configured list for every non-external human. CONTROL cells are pinned in every new test file and in the dogfood boot (member reads all four rows, both teams, both invitations, the whole directory).

  9. Residual paths to the member directory or team names, enumerated: (a) sys_user/sys_member at the data door — own row (stage 1, unchanged); (b) sys_team — not granted by external_default; (c) sys_team_member — the object's own _self default scope, own rows; (d) the four better-auth doors — narrowed; (e) list-team-members — not narrowed, see ③; (f) an administrator binding member_default/viewer_readonly or a sys_team grant to an external — an explicit grant, which the ruling admits. No unintended path found.

② Semver level

Changeset .changeset/25233-external-posture-owd-floor.md: minor on @objectstack/spec, @objectstack/core, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-auth — exactly the five published packages with source changes (qa/dogfood is private; docs are not a package). It carries both declaration lines. Check Changeset green.

Clause-②: yes (widening) — judged right. Published additions: core exports EXTERNAL_READ_GRANT_KEY, EXTERNAL_WRITE_GRANT_KEY, isExternalAudienceContext, externalGrantSpeaks, owdForAudience; optional PostureEvidence.isExternal, optional ResolvedAuthzContext.audience / UserAuthzGrants.audience, optional ExplainEngineDeps.externalGrantSpeaks. Narrowings (the OWD floor, the write floor, the doors) reach only holders of the external role; stage 1's .changeset/25233-external-org-role.md is still pending on origin/main (read in the tree), so no release has shipped a holder to narrow. Two edges judged and accepted: (i) POSTURE_INJECTION_RULE.EXTERNAL's string value changes — documentary prose on an exported constant, within minor; (ii) a hand-built context carrying audience: 'external' is now floored where before nothing read the key — that is the key's documented meaning becoming enforced (the design round measured zero consumers), not a contract break. Metadata-protocol: semantic — right: nothing an author writes is added or refused; sharingModel on an object and rowLevelSecurity.using on a set gain a meaning for external holders. The level follows the kind: minor on every moved package.

③ Boundary flags

Dev flags (PR body and report 6375701518), each answered:

  • Open question — "access matrix": the authz-matrix-gate EXTERNAL column, or the ADR-0090 D6 explain matrix? Judged: the authz-matrix-gate column; the dev's reading is right and no follow-up is owed. The maintainer's 「25233 选择 B」 adopts option B as the dev's report states it, whose words are "the OWD seam and matrix cells" — "cells" is this repo's word for the authz-matrix-gate snapshot (posture-ladder.ts: "the real behavior guard is the authz-matrix-gate unit snapshot + the dogfood conformance matrix"; the gate's own retired D3 comment spoke of "EXTERNAL × layer … cells"). The ruling's restatement lists "the EXTERNAL column of the access matrix and the explainers" as two things, so the matrix is not the explain surface. ADR-0090 D6's AccessMatrix is @objectstack/lint's metadata-only permissionSet × object snapshot, and D11 says audience is a property of the principal, never of the object; D11's "external-audience column" sentence rode externalSharingModel, retired in spec 18 and marked superseded by the 2026-10-14 note. With no per-object external metadata left, a metadata-only snapshot has nothing to put in such a column. The Tier H docs PR amending D11 should retire that sentence explicitly.

  • Out of scope 1 — a share on a public object is refused as inert, closing an external's share channel there. Judged: a follow-up with its own card, needing a ruling; not a stage-2 defect. Read at the head: inertGrantReason refuses when effectiveSharingModel is 'public' (that is public_read_write, controlled_by_parent, and a model-less sys_* object) or there is no owner_id; it does NOT refuse public_read ('read'), so the dev's note overstates the closed set and the dogfood's direct insert on a public_read object was not forced by this gate. On the closed set, the gate's premise ("a share row on it would never be consulted", decided caller-free by design, plugin-sharing: grant() skips the ADR-0111 D7 inert-grant guard entirely for SYSTEM callers, so the sharing-rule evaluator can still materialise rows no gate consults #8207 / ADR-0111 D7) is now false for one population: this PR's floor DOES consult share rows for an external on those objects (pinned in external-owd-floor.test.ts, share s1 on the public_read_write board). Existing rows are honoured; minting new ones is refused for every recipient, and since assertNotInertGrant runs for system callers too, sharing-rule materialisation on those objects is refused as well. So two of the ruling's three channels cannot be provisioned on public_read_write / controlled_by_parent / model-less system objects; the grant-RLS channel (hotsrm's shape) and shares on private / public_read objects work, and the direction is fail-closed. Making an object-level inertness verdict audience-aware reverses a recorded decision, so it takes a ruling. Until it lands, sharing-rules.mdx's "a manual share or a sharing rule" bullet overstates the external's reach on those objects by one clause; the follow-up card should carry that doc line.

  • Out of scope 2 — list-team-members stays membership-only. Judged: measure first; a domain:services follow-up (triage 6345105238's directory-doors card), not stage-2 blocking. The two seat statements contradict each other: the stage-1 review (6371057486) read the door as requiring the caller on the team; the stage-2 report reads it as membership-only. better-auth's dist is not present in this container either, so I could not settle it. If membership-only, the exposure is teamMember rows (userId, teamId) of a team whose id the external already knows — ids only, since sys_user is own-row and list-members is narrowed, and team ids are no longer enumerable (list-teams narrowed, sys_team not granted). Neither E2 nor the scope note names this door. If better-auth already requires team membership, there is nothing to do.

  • list-invitations — measured, and with what result? Yes. The dev measured crud-invites.mjs listInvitations in better-auth 1.7.12 as findMemberByOrgId only (membership-only) and narrowed it. Not re-readable here, but the fact is pinned on a real boot by the dogfood CONTROL: a plain member is answered both fixture invitations (invitations.length >= 2), which only a membership-only door does, and the external is answered its own only, another external []. The dogfood project includes test/**/*.test.ts, and Dogfood Regression Gate is green on this head, so CI measured it.

  • Write floor outside the order; platform-ownership-policies.ts docblock and sharing-rules.mdx outside the file list: accepted (① item 4; the docblock and the doc state what the code does).

  • Pins moved (floor dedupe by identity key; posture-ladder dataset gains r6; matrix ladder order): each read; each is the derived consequence of ① items 3, 4 and 7.

  • An edge the PR pins but the ruling's sentence does not literally cover: an object with no owner_id in its fields and a public effective model is "not under OWD" for every principal, so an external holding an RLS-less grant on it reads every row (external-owd-floor.test.ts, the ownerless case). In practice applySystemFields injects owner_id into registered business objects and every system object external_default names is own-row-scoped by its own defaultRowScope, so the case needs an administrator's explicit grant on an author-declared ownerless object. Accepted as the stated limit; the Tier H ADR text should name it (or the follow-up may deny-all externals there absent a speaking grant).

  • Explain's guest → EXTERNAL debugger mapping now shares the rung's name with a live population; harmless (the guest deny-all precedes every sharing gate), noted for the ADR text.

  • #25234 PR ② serial constraint: whichever lands second merges main — accepted as declared.

Implemented-by: claude/project-thread-cmvqwf
Reviewed-by: session_013odhA26dvzy7pQmevdTTvy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head 9176322153 · card #25233 (stage 2 of ruling B) · claim 6368207082 · report 6375701518 · ruling 6355206139 (letter B, item 2) · scope note 6371125645 · contract review 6375883728 (PASS).

  • Shape: draft, base main, body line Closes #25233, one changeset. 36 files, +1556 / −148. check-governed-merges: not governed, 1704 lines ≤ 3000 ⇒ ordinary queue landing.
  • Contract: an org_external holder gets audience: 'external' and posture EXTERNAL from the one resolver. The OWD floor lifts only where a held set's policy speaks, so an external reaches explicit grants and nothing by default. explain/describe and the data plane name one external baseline. list-teams, get-full-organization.teams and the directory doors are narrowed fail-closed; list-invitations was measured membership-only and pinned. An ordinary member is unchanged on every edit (CONTROL cells).
  • Open question answered: "access matrix" is the authz-matrix gate's EXTERNAL column, as the dev read it (the ruling lists the explainers separately).
  • Changeset: minor on five packages, Clause-②: yes (widening), Metadata-protocol: semantic. Check Changeset success.
  • Gates pinned to the head: 42 check-runs on 9176322153, 0 red.
  • Follow-ups, not this PR: (1) share creation on a public_read_write / controlled_by_parent object is refused as inert, which closes an external's share channel there; that needs a ruling and gets its own card. (2) list-team-members: measure whether better-auth requires team membership; goes on the same card. (3) the Tier H docs PR retires the ADR-0090 D11 external-audience sentence explicitly.
  • Landing: pr_ready + automerge_enable through the relay now. On merge 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 closes; the Tier H docs PR follows from a fresh main.

Generated by Claude Code

…ternal-role

# Conflicts:
#	packages/plugins/plugin-auth/src/auth-manager.ts
… external, not the owner-only floor (#25233 stage 2)

The external OWD floor read a controlled_by_parent detail's effective
model ('public') and floored it to owner-only, on top of the master
derivation (ADR-0055) that already applies the master's floor. An
external shared a master therefore read none of its lines. The floor
now skips a controlled_by_parent detail in plugin-sharing and in
explain's OWD layer. A showcase-boot dogfood case pins it (invoice
shared to an external: its two lines, not the other invoice's), red
with the exemption removed.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 225d5bc02c4dcb441fd812288de8209d98192a48
Local-runs: none
Scope: packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/security/explain.zod.ts (hits; unchanged in the delta) + the delta 9176322153..225d5bc02c and what it makes claims about: packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/external-owd-floor.test.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/explain-external-principal.test.ts, packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts (unchanged, cited), packages/plugins/plugin-security/src/security-plugin-read-filter.ts (unchanged, cited), packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts (cited), packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts (unchanged, cited), packages/plugins/plugin-security/src/platform-ownership-policies.ts (cited), packages/plugins/plugin-security/src/security-plugin.ts (merge; §7004 cited), packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-manager-after-hook.ts (merge), packages/core/src/security/external-owd-floor.ts (unchanged, cited), examples/app-showcase/src/data/objects/invoice.object.ts (unchanged, cited), packages/qa/dogfood/test/external-member-directory.dogfood.test.ts, .changeset/25233-external-posture-owd-floor.md, .github/workflows/ci.yml (cited)

Inputs read at 2026-10-23T23:16Z: the prior ## Contract review 6375883728 (PASS on 9176322153), the seat 核对 6375896827, the PR body as amended (the "Fix after the first review" note), the two delta commits (c70863feb merge of origin/main at 4df174bcc; 225d5bc02 fix), the three-way diffs 4df174bcc..c70863feb and 5ea211ccd..9176322153, and the 46 check-runs on the head at the time of writing (see ④). This is a DELTA review: everything the prior record judged and the delta does not touch stands as judged there, with the two corrections in ③.

① Derived judgments

  1. Merge resolution (c70863feb) — faithful. origin/main brought refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts (#25224 cut 12) #25536, which moved the better-auth global after-hook body from auth-manager.ts into auth-manager-after-hook.ts; the PR had edited that body's E2 comment and added readUserTeamIds to externalDirectoryDoorDeps(). Measured: git diff 4df174bcc..c70863feb (merge vs main) touches exactly the PR's 36 files plus auth-manager-after-hook.ts, and on the two conflicted plugin-auth files it is only (a) the four-line E2 comment in the moved hook, now naming list-teams and list-invitations, and (b) the seven-line readUserTeamIds dep. Nothing of main's side is removed: main's auth-manager.ts at 4df174bcc already wires externalDirectoryDoorDeps into the hook host (line 2835) and the moved hook already calls narrowDirectoryForExternalCaller(ctx, this.externalDirectoryDoorDeps()) (line 224), so every door the pre-merge head narrowed (list-members, get-full-organization incl. teams, list-teams, list-invitations) is reached through the same one call, with the same deps object, now from the hook's new file. On explain-engine.ts and security-plugin.ts the merge-vs-main diff is byte-identical to the PR's pre-merge hunks (the baseline helper, the OWD layer, step 2.6 stamp, the collectRLSPolicies host members): main's refactor(plugin-security): move the security service registration and the seed-ownership claim out of security-plugin.ts (#25223 cut 11) #25530/refactor(metadata-protocol): move the query validation out of protocol.ts into protocol-query-validation.ts (#25467 cut 3) #25538 refactors are intact. Judged right.

  2. The exemption (225d5bc02) — exactly right, and the prior record's ① item 3 was wrong on one model. Before the fix owdForAudience(effectiveSharingModel(schema), …) floored a controlled_by_parent detail (effective 'public') to owner ∪ shares for an external. But a detail's rows are ALREADY derived from its master: getReadFilter ANDs plugin-security's cbp leaf { fk: { $in: ReadableIds.of(master) } } with the sharing filter (security-plugin-read-filter.ts §159–165), and the engine resolves that leaf with canReadObject(master) + getReadFilter(master) for the caller's context as it stands (security-plugin.ts §7004). So for an external the composed detail filter was (owner = ext ∪ shares on the detail) AND fk IN (masters the external may read): a line owned by the master's owner and never shared on its own matched nothing — an external shared an invoice read none of its lines. The fix routes all four sharing gates (buildReadFilter, buildWriteFilter, checkEdit, checkDelete) through owdForPrincipal, which returns the model unfloored when the DECLARED model is controlled_by_parent and owdForAudience otherwise. Judged against the three required properties:

    • A detail whose master the external can read reads only that master's lines. With the sharing leg at null, the detail filter is the cbp leaf alone, and the leaf is the master's own read scope for THIS caller: getReadFilter(master) → resolveSharingReadFilter(master, context), which re-derives the external stamp FOR THE MASTER (security-plugin-sharing-verdicts.ts §145–158, externalGrantPatch(context, object, 'read', …)) before buildReadFilter, so the master is floored to owner ∪ shares unless a held grant's RLS speaks for the master, exactly as a direct read of the master is. Lines of another master, not shared and not owned, are outside $in. Right.
    • A detail whose master the external cannot read reads none. $in: [] when canReadObject(master) refuses; an empty readable-master set otherwise; RLS_DENY_FILTER when the relation does not resolve, and $in: [] on a cycle or depth refusal (security-plugin-cbp-read-filter.ts). The write side: buildWriteFilter/checkEdit/checkDelete abstain, and the 2.8 master write gate judges per master through resolveSharingCanEdit(rel.master, masterId, context, sets) (security-plugin-cbp-write-gate.ts §844), which patches the WRITE stamp for the master and so meets the external's floor on the master; a predicate write is first narrowed to the rows the caller's own read door returns (§591–600, the context minus operation-private keys, so the floor re-derives). The Layer-1 ownership floor already excludes controlled_by_parent (OWD_DELEGATING_ROW_WRITES), so a member and an external meet the same master-derived write rule. Right, fail-closed.
    • Nothing else effective-public escapes the floor. The exemption keys on the declared value controlled_by_parent only; public_read_write, public_read and the model-less system default still go through owdForAudience. A controlled_by_parent declaration with no resolvable master is refused by plugin-security (deny leaf; controlledByParentMasterRefusal), so no object can wear the exemption without a master to derive from. Deployments running plugin-sharing without plugin-security have no resolver to stamp audience, so the branch is unreachable there. Right.
    • Explain says the same as the data plane. explain-engine.ts owdForPrincipal adds owd.model === 'controlled_by_parent' to its pass-through; describeOwd returns model: String(m) for that declaration, so the string test is exact. The OWD layer reports the master-derived model (verdict: 'neutral'), and the record-grained read/write attribution already goes through the master legs (MASTER_READ_DERIVED, checkControlledByParentWrite). The unit explain-external-principal.test.ts pins the layer text. Right.
  3. Unit pins moved — right. external-owd-floor.test.ts drops line from the floored loop (the wrong answer the prior review passed) and adds a four-gate case (buildReadFilter null, buildWriteFilter null, checkEdit/checkDelete abstain) for the external on line; the member CONTROL cells are untouched. The docblock states the new rule.

  4. Dogfood case — pins it, and is red without the exemption. showcase_invoice is public_read_write, showcase_invoice_line is controlled_by_parent with the required master_detail invoice (invoice.object.ts §41–43, §199–218). The fixture: two invoices owned by owner, two lines each owned by owner, the first invoice shared to supplierD by a sys_record_share; supplierD and the control member hold the same read-only set on both objects and own no row. Assertions: supplierD reads exactly [inv_shared] of the invoices and exactly {shared1, shared2} of the lines (toEqual on the sorted arrays — the other invoice's lines are excluded by construction, not just not asserted). With the exemption removed the sharing leg returns owner_id = supplierD ∪ shares on showcase_invoice_line — no match — and the AND with the cbp leaf is empty, so lines.ids is [] and the toEqual fails: red, as the dev measured. The armedWhen control (member reads both invoices and all four lines) disarms the case if lines are unreadable for everyone, so it cannot pass over an empty answer. Right.

  5. Unchanged for a member, still — right. owdForPrincipal differs from the old call only for a controlled_by_parent declaration, where effectiveSharingModel already answered 'public' for every principal; for a non-external context owdForAudience returned the model untouched. Byte-identical answers on every model for a member.

② Semver level

The changeset keeps minor on the same five packages and gains one sentence: "A controlled_by_parent detail is not floored on its own: its rows follow the master, which meets the floor, so an external reads the lines of a master shared to it." Judged right: it states exactly ① item 2 — the detail is exempt from the floor, the master is not, and the reach is the master's shared lines. (It is also true for a master a held grant's RLS opens, which the sentence does not claim; the narrower sentence is not wrong.) No published surface changes in the delta: owdForPrincipal in plugin-sharing is module-private; explain's owdForPrincipal was already private. Clause-②: yes (widening) and Metadata-protocol: semantic stand as judged before. Check Changeset: success on the head.

③ Boundary flags

  • Correction to the prior record (① item 3 there): it listed controlled_by_parent among the models the floor closes and passed the line pin. The fix corrects the data plane; the stale prose remains in ONE place the delta did not touch: packages/core/src/security/external-owd-floor.ts docblock line 13 still says "where a member reads every row of a public_read / public_read_write / controlled_by_parent object … an external reads the rows it owns and the rows shared to it". Documentary only (the function takes the model the caller resolved and plugin-sharing no longer passes a detail's), so not blocking; the Tier H docs PR or a one-line follow-up should drop controlled_by_parent from that sentence. sharing-rules.mdx §125–137 does not name the model and stays right.
  • Correction to the prior record (③ "list-invitations — measured"): it said "Dogfood Regression Gate is green on this head, so CI measured it". Measured now in ci.yml: the dogfood shards run only off pull_request/merge_group (the skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998 two-speed rule, if: at §2106), and the bare "Dogfood Regression Gate" context is the roster aggregator (dogfood-gate, §2488) which on a PR passes with the leg skipped — on this head it ran 10 s and the shard row is skipped. So neither the invitations case nor the new master-detail case is measured by CI before merge; both rest on the dev's reported showcase run (11/11, red-with-exemption-removed) and on the unit pins that Test Core does run (plugin-sharing external-owd-floor.test.ts, plugin-security explain-external-principal.test.ts). The full battery runs on main after the merge. Accepted as the repo's design, noted so the record does not overstate the gate.
  • The prior record's follow-up 1 (shares refused as inert on effective-public objects) shrinks: with the detail exempt, a share on a controlled_by_parent detail is inert for an external too (never consulted — the master's share is what reaches the lines), so the inert gate is right there. The follow-up card's set is public_read_write and model-less system objects only; its text should say so.
  • Dev flag "red with the exemption removed (measured)" — accepted on the reasoning in ① item 4; not re-run here (read-only).
  • Merge note "took main's side and carried their comment" — verified in ① item 1.

④ Check-runs on 225d5bc02c

48 check-runs at 2026-10-23T23:16Z: 38 success, 8 skipped, 0 failed, 2 still in progress. Green and material to the delta: Check Changeset, Build Core, Build Docs, Spec property liveness, every Type Check · lane (source gates, consumer gates, debt ledger, workspace), every Lint · lane, Test Core shards 1, 2, 3 and 6, Governed Surface Queue Guard, the claim/closure guards, Dogfood Regression Gate (the aggregator; its shard leg is skipped on a PR by design, see ③). Skipped, all path-scheduled or opt-in: Check PR Size, Auto Label (duplicate rows of successes), the dogfood shard matrix row, Console Pin Gate, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Packed-tarball smoke (opt-in), 18.x upgrade harness (opt-in).

Pending: Test Core (4/6) and Test Core (5/6), both started 2026-10-23T22:46Z and still in_progress after the polling window (the other four shards finished). The plugin-sharing and plugin-security unit pins of the delta run inside Test Core, so this record's PASS is conditional on those two shards concluding success; a failure or cancelled on either reopens it.

Implemented-by: claude/project-thread-cmvqwf
Reviewed-by: session_013odhA26dvzy7pQmevdTTvy

VERDICT: PASS (conditional on Test Core (4/6) and Test Core (5/6) concluding success; both were still in progress at 2026-10-23T23:16Z)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head 225d5bc02c · card #25233 (stage 2) · supersedes the seat ACCEPT 6375896827 on 9176322153 · contract review 6375883728 (PASS) + delta review 6376653249 (PASS, conditional on Test Core, now met).


Generated by Claude Code

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

2 participants