Skip to content

fix(qa,platform-objects,docs): re-premise member_default's removed wildcard and replace two vacuous D7 denial cases (#6964) - #7149

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6964-member-default-wildcard-prose
Aug 10, 2026
Merged

fix(qa,platform-objects,docs): re-premise member_default's removed wildcard and replace two vacuous D7 denial cases (#6964)#7149
os-zhuang merged 2 commits into
mainfrom
claude/issue-6964-member-default-wildcard-prose

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6964

#5491 (PR #6684) removed member_default's plain '*' object grant; ADR-0095 D1 retired its wildcard tenant_isolation RLS policy. PR #6958 fixed the two plugin-security surfaces #6842 named. This PR is the non-overlapping remainder, four packages further out — and Part 2 turned out to be a real defect, not prose.

Every anchor was re-measured against origin/main at this branch point (3e8e669c0); none had drifted.

Part 2 first — the two D7 denials were vacuous, and this is how it was measured

showcase-default-profile and showcase-d7-default-profile each proved ADR-0056 D7 with expect(status).not.toBe(200) on an app object, justified inline by "member_default has a wildcard grant, so this would be 200". Since #5491 that baseline grants nothing on app objects, so the denial is the trivially expected outcome either way.

Confirmed on a live stackbootStack from @objectstack/verify booting the real showcase app in-process, one fresh signUp per wiring, three wirings, HTTP status read off GET /data/{object}:

wired baseline showcase_announcement showcase_private_note showcase_contact sys_user_preference
member_default (built-in) 403 403 403 200
showcase_demo_default 200 403 403 403
showcase_member_default 200 200 403 403

Row 1 is the world the two assertions claimed to exclude — and the objects they read are 403 there too. Both cases held identically whether or not the declared default was in force, i.e. they passed because nothing is produced, which is the #5046 "replace wholesale" shape and not something re-wording can fix.

The same run settles the risk that would have killed the replacement: a named fallbackPermissionSet replaces member_default rather than merging additively on top of it. That makes sys_user_preference — granted by the built-in baseline and by nothing else here — 200 if and only if the built-in baseline governs. Both denial cases now read it.

Reverse verification — direction predicted before the run

Predicted, for a mutation that strips the declared-default wiring so the built-in baseline governs: positive case RED, new denial case RED, old (deleted) denial case GREEN — that last row being the vacuity itself.

Measured, both files in one run: Tests 4 failed | 3 passed (7).

x a fresh sign-up is governed by the app-declared default (grants announcement)
x and NOT by the built-in member_default baseline (its own explicit grant is absent)
x a fresh member is governed by the app-declared default (reads announcements)
x and NOT by the built-in member_default baseline (its own explicit grant is absent)

AssertionError: default profile grants announcement read: expected 403 to be 200
AssertionError: NEW case: expected 200 not to be 200

PASS  OLD deleted case (private_note is denied)
PASS  OLD deleted case (contact is denied)
PASS  appDefaultPermissionSetName extracts the showcase default profile from stack metadata

All three predicted rows landed. Honest delta, not predicted in advance: the third green. That case reads showcaseStack.permissions only and never touches the boot, so it is insensitive to this mutation by construction. It is not a gap — it pins the helper's extraction — but it is worth naming, because #7001's own header says a name-only assertion "could not tell 'wired' from 'wired but inert'", which is exactly why the behavioural pair exists beside it. Unmutated: Test Files 2 passed (2) / Tests 5 passed (5).

Part 1 — the named-location prose sweep

Two distinct expired premises are mixed across these locations and are handled separately, never conflated: (a) the '*' grant removed by #5491, (b) the wildcard tenant_isolation RLS retired by ADR-0095 D1.

  • sys-scim-provider.object.ts:40 — premise (a). The gate is not redundant; its live reason is stronger. requiredPermissions is a capability AND-gate evaluated before the CRUD grant (security-plugin.ts step 1.5), so a caller missing the capability is denied "regardless of how permissive their grants are" — including a grant an app-declared profile or a customer-authored set names on this object.
  • sys-sso-provider.object.ts:38 — premise (b), and :43 — premise (a). :38 is re-premised on the Layer 0 tenant wall: the table has an organization_id column that better-auth never stamps, so a wall AND-composing organization_id == < caller org > would deny every row; tenancy.enabled:false sets tenancyDisabled, computeTenantLayer0Filter returns null, Layer 0 contributes nothing. Also corrected: under ADR-0095 W2 the viewAllRecords superuser bit alone no longer crosses the wall, so this opt-out is now the only thing opening the table up.
  • rls-multitenant.dogfood.test.ts:7 — premise (b). The investigation narrative is kept as history and tensed correctly, then brought to the current mechanism: the tenant scope is Layer 0, inert under the single posture by construction rather than by policy stripping. The conclusion the file is built on survives — a single-tenant boot applies no org row scope to reads, and member_default still carries no owner-scoped READ policy (owner_only_* are update/delete only).
  • content/docs/permissions/index.mdx:47 — premise (b), the sharpest piece: a published security page asserting the retired policy as shipped behaviour, contradicting releases/implementation-status.mdx:294/:435 in the same repo. Per the dispatch's direction, the doc moved and the status page was not touched; the replacement is worded to match it.

Scope

No runtime behaviour changes — comments, published prose, and two test fixtures.

Deliberately not done: audience-anchor-set-claims.pin.test.ts's watchedSurfaces() is left at its two surfaces. #6964 states that whether the pin should grow a repo-wide surface, and whether platform-objects/qa should depend on plugin-security to get one, is a scoping decision for triage — the triage grading did not rule on it, so assuming it here would be the guess the filing warned against.

A consumption-radius sweep for the same two premises turned up two further stale sites outside this card's named file face — skills/objectstack-data/SKILL.md (both premises, in a published customer-facing skill) and packages/plugins/plugin-security/README.md (premise (b)). Filed separately rather than folded in. Verified correct and left alone: content/docs/permissions/access-recipes.mdx:65 already names Layer 0; serve-verify-security-parity.contract.test.ts:18 is correctly historical; and plugin-approvals/lifecycle-hooks.ts:423's "wildcard created_by == current_user.id RLS" is a grep false positive — that policy is object: '*' and still ships.

Gates

gate result
pnpm lint (ESLint, --no-inline-config) pass
pnpm --filter @objectstack/platform-objects --filter @objectstack/dogfood typecheck pass, both
pnpm --filter @objectstack/platform-objects test Test Files 11 passed (11) / Tests 289 passed (289)
pnpm --filter @objectstack/dogfood test (full suite) Test Files 85 passed | 1 skipped (86) / Tests 526 passed | 3 skipped (529)
check:nul-bytes + grep -naP self-scan of every touched file pass, no hits
adr-anchors, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, authz-resolver, tenant-chokepoint, wildcard-fallthrough, error-code-casing, route-envelope, engine-double-contract, empty-changeset, skill-frame-sync pass, all 13

The one skip is rls-multitenant itself, which skips loudly in this workspace because the enterprise @objectstack/organizations package is not linked in — pre-existing and unrelated to this change.


Generated by Claude Code

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 1:13am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects.

2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

PM review — PASS. Marked ready and enqueued (added_to_merge_queue 01:27:34Z). Identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ.

Part 2 was the load-bearing deliverable, and it was answered by a real run

The dispatch envelope said plainly that a green suite is not evidence here, and that answering the vacuity question needs a live stack. It got one: @objectstack/verify's bootStack starting the real showcase app in-process, a fresh signUp under three wirings, HTTP status read directly off GET /data/{object}:

fallback announcement private_note sys_user_preference
member_default (built-in) 403 403 200
showcase_demo_default 200 403 403
showcase_member_default 200 200 403

Row 1 is the world the old assertion claimed to exclude — and private_note is 403 there too. Verdict: confirmed vacuous. The old not.toBe(200) held in every wiring, so it was passing because nothing was produced.

The replacement discriminates, which is the whole point

Fixed in the #5046 wholesale-replace shape rather than reworded. The new case reads sys_user_preference, which is 200 if and only if the built-in baseline governs — because a named fallbackPermissionSet replaces member_default rather than merging with it, a property the same live run measured rather than assumed. Under the mutation the new case goes red with NEW case: expected 200 not to be 200. That is a test that can fail for the right reason; the old one could not fail at all.

The three-row table is written into the test file itself, so the next reader can see why that object was chosen instead of rediscovering it.

Reverse verification — the prediction that mattered was the third line

Predicted before running: strip the declared-default wiring → the positive case RED, the new denial case RED, and the old (deleted) denial cases GREEN. That third line is the vacuity, stated as a falsifiable prediction. Measured: 4 failed | 3 passed, matching all three lines. One unpredicted GREEN reported rather than smoothed over — appDefaultPermissionSetName extracts… reads stack metadata only and never boots, so it is insensitive to that mutation by construction; the author explained why that is division of labour rather than a hole, and noted it is exactly why the behavioural pair must sit beside it.

Verified independently by this seat

CI: 26 runs, none non-green (ESLint ✅, TypeScript Type Check ✅, Check Changeset ✅, Dogfood Regression Gate ✅). Scope 7 files, +168 −43. Boundaries: content/docs/releases/ untouched (0 files), no CHANGELOG edits, plugin-security untouched so #6842's landed surfaces were not redone. PR body English (0 CJK) with the attribution footer present. And the direction of the docs fix is right: permissions/index.mdx moved to agree with implementation-status.mdx, not the reverse.

Part 1 kept the two premises apart, which was the stated trap

Six locations, each rewritten against its own expired premise — (a) the '*' grant removed by #5491, (b) the wildcard tenant_isolation RLS retired by ADR-0095 D1 — rather than being swept as one. Two of the rewrites went further than deletion: sys-scim-provider.object.ts:40 now states the real reason the gate is not redundant (requiredPermissions is a capability AND-gate evaluated before CRUD authorization, so it holds against an app-declared default profile too), and sys-sso-provider.object.ts:38 re-grounds on the Layer 0 tenant wall and corrects a second stale claim — that after ADR-0095 W2 a viewAllRecords superuser bit alone no longer crosses the wall.

Follow-ups

#7151 filed by the author for two sites outside this card's named face — a published customer skill and plugin-security/README.md. Triage has already graded it pm:queue + domain:identity + target:v17, and this seat has claimed and dispatched it (branch claude/issue-7151-published-prose-expired-premises); its file face is disjoint from this PR's, so the two do not serialise.

The author also recorded three grep hits it checked and deliberately did not touch, including one false positive (plugin-approvals/src/lifecycle-hooks.ts:423 — "wildcard" there means the policy's object: '*', and owner_only_writes/owner_only_deletes still ship). Recording what was examined and cleared is as useful as recording what was changed.


Generated by Claude Code

Merged via the queue into main with commit 59c544d Aug 10, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6964-member-default-wildcard-prose branch August 10, 2026 01:42
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/m tests tooling

Projects

None yet

2 participants