feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row - #15108

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale
Sep 4, 2026
Merged

feat(plugin-security): a rank-and-file member may edit their OWN sys_user row#15108
hotlong merged 5 commits into
mainfrom
claude/issue-14959-member-self-locale

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14959

Do not arm this PR for merge before the ADR PR lands. The decision this implements amends ADR-0092 D5, docs/adr/** is governed and the maintainer merges it by hand, and the amendment ships as its own PR: #15109. This PR is draft and unarmed; the seat lands them in that order.

needs:contract-review carrier label applied (clause ②: permission-set defaults move). ⛔ Not to be cleared here — the seat clears it after review.

The ruling

Maintainer ruling 2026-09-03, decision batch #22, verbatim and untranslated as adopted:

「同意」

A rank-and-file member may edit their own sys_user row on the generic data path, bounded on two axes that already exist.

The gap this closes, restated as measured

The 2026-09-03 ruling on #14787 (landed as PR #14958) admitted locale to the ADR-0092 D2 column whitelist — it opened which columns a permitted actor may touch. It did not open who, and ADR-0092 D5 kept that with the permission layer, where member_default denied allowEdit on sys_user. So a member's PATCH /api/v1/data/sys_user/self was refused by the object gate, before the column guard was ever consulted, and sys_user.locale shipped as a user-stated preference only a platform administrator could set.

Verified at the tree rather than assumed, per the dispatch: SYS_USER_PROFILE_EDIT_FIELDS on origin/main already holds {name, image, locale} and sys_user.locale already carries no readonly. The column half is in place; only the route was missing.

What changed

packages/plugins/plugin-security/src/objects/default-permission-sets.ts — two lines of behaviour, in the shape sys_api_key has shipped since #8053:

  • member_default gains an explicit sys_user entry: allowRead/allowEdit true, allowCreate/allowDeletefalse. Being explicit is also what makes it survive kernel:readyapplyManagedWriteDenies injects its deny only for managed objects a set does not already name.
  • the sys_user_self RLS carve-out (id == current_user.id) widens from select to all, so it reaches the by-id write pre-image check.

sys_user_org_members — the org-peer visibility policy — deliberately stays select-only. RLS policies OR-combine, so widening it would have composed "my id OR every user id in my organization" and handed every member their colleagues' profile rows. That is pinned in three separate places, because the two lines are 100+ apart in the source.

packages/plugins/plugin-auth/src/sys-user-writable-fields.ts — comment only. Its doc block asserted that member_default still denies allowEdit on sys_user; this change makes that false, so it is corrected in the same diff.

The pins, and why each names a layer

Three layers can refuse this write, in order: the CRUD object gate, the row scope (the by-id write pre-image check), and ADR-0092 D2's identity write guard. Layer 1 shadows the other two — before this change all four of the ruling's cases were refused by the object gate, so "another member's row is refused" and "a non-whitelisted column is refused by the guard" were both green while neither mechanism had run.

packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts therefore reports which layer answered, established mechanically rather than inferred: the middleware throwing with ql.findOne never called dates the refusal to the object gate (the pre-image re-read is the first engine call past the CRUD check); throwing with findOne called is the row scope; passing and then having the guard hook throw is the guard. It drives the real SecurityPlugin middleware over the real shipped permission sets and the real SysUser schema, and answers findOne by evaluating the filter the middleware actually composed against a two-row fixture, so the row scope is a measurement and not a stub.

  • PIN 1 — an own-row locale update is admitted end to end, and the value survives to the payload (a "success" that stripped the column would be a silent no-op at the driver).
  • PIN 2 — another member's row is refused, refusedBy === 'row-scope', and the composed pre-image filter is asserted verbatim as the caller's id.
  • PIN 3name/image are admitted, and the ADR-0092 D6 session refresh is observed: better-auth's cached {session, user} snapshot is re-written at the same key with the new value, and the session survives (rewrite, not delete). A companion pin records that locale correctly does not touch the snapshot — better-auth carries no such field, so mirroring it would manufacture an incoherence rather than repair one.
  • PIN 4email gets past the object gate and past the row scope (the pre-image read ran and succeeded) and is stopped by the guard: refusedBy === 'identity-guard', code: 'PERMISSION_DENIED', status: 403, message naming the field and the editable set.

Plus: the composed write filter asserted verbatim; the org-peer scope proved absent from it; insert/delete still refused at the object gate (which is also the file's positive control for that verdict, so PIN 2 and PIN 4 can fail for the reason they are written to catch).

Ablation — predicted before measuring, two legs

Predictions were written down before either leg ran. No rebuild was needed and none happened: plugin-auth's vitest config aliases the plugin-security specifier to src/index.ts, and dist/index.js was hash-compared before and after each leg and was byte-identical, so the redness came from source.

Leg A — revert the whole permission-set file to origin/main. Predicted 9 red / 3 green in the route suite and 6 red across the three shipped-set suites. Measured exactly that. The two discriminating failures are the point:

PIN 2: AssertionError: expected 'object-gate' to be 'row-scope'
PIN 4: AssertionError: expected 'object-gate' to be 'identity-guard'

The 3 that stay green are correct: the whitelist-property pin is a pure column-half assertion, and the two object-gate cases were already object-gate refusals.

Leg B — revert only the RLS widening, keeping the object entry. Predicted 9 red / 3 green in the route suite and 3 red in the shipped-set suites. Measured exactly that, and PIN 4 now reads:

PIN 4: AssertionError: expected 'row-scope' to be 'identity-guard'

which is the independent evidence that the which-rows half is load-bearing for the guard even being reached. Each leg proved its mutation on disk before measuring (blob hash differs from the HEAD blob, plus marker counts), restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap, and proved the restore by an empty git diff HEADand a blob hash equal to the HEAD blob.

Shipped-set pins updated (and why each moved)

  • default-permission-sets.test.tsEDIT_EXCEPTIONS grows from one pair to two. The "exactly one pair" assertion becomes "exactly these two", and now checks that each exception rides a WRITE-class row scope, plus that the org-peer scope stays select.
  • member-default-explicit-allow.test.ts — the managed-object update axis grows a second permitted table; sys_user insert/delete are asserted still shut, and a new case pins the write-class row scope and the read-only org-peer scope.
  • authz-matrix-gate.test.ts — the three better_auth write cells move from CRUD_DENY to the caller's own row. This is the most informative reading in the diff: org_admin gets oadmin and not the organization, and no_org_member gets its own id even with no active organization (sys_user is non-tenant, so Layer 0 is inert). Exactly one row per principal, nobody reaches anybody else's identity row.

Verification

Run at the pushed head d845d56.

  • pnpm --filter @objectstack/plugin-security exec vitest run96 files / 1801 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-auth exec vitest run94 files / 1951 tests, all passing (exit 0).

  • pnpm --filter @objectstack/plugin-security run typecheck — exit 0, and its test layer is measured: check:test-typecheck: OK — 0 file(s) / 0 error(s).

  • pnpm --filter @objectstack/plugin-auth run typecheck — exit 0. ⚠️ Worth stating precisely, because the package's build tsconfig.jsonexcludes**/*.test.ts: tsc --noEmit says nothing about the new test file. The leg that does is check:test-typecheck against tsconfig.test.json, and it was confirmed to actually see the file — tsc --listFiles -p tsconfig.test.json names sys-user-self-service-route.test.ts (1 hit), and the file appears in no entry of the shrink-only test-typecheck-debt.json, so it compiles with zero errors under the strict test config rather than being ledgered.

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 52 commands from the merge-base change set (not a hand-written diff). All 52 were run, exit code captured by redirect before any pipe. 48 exit 0. The other four answered exit 3 = NOT MEASURED, which is neither a pass nor a finding, and none of them is movable by this diff:

    • check-test-completeness — grades a saved turbo run test log that only CI produces.
    • check:dual-build-cjs-loads — reads built output for every workspace package; most have no dist/ in this worktree.
    • check:i18n — runs the built CLI (packages/cli/dist/commands/i18n/extract.js), which is not built here. This diff adds no translatable strings.
    • check:type-check-debt — refuses to re-measure without the whole workspace closure built, deliberately, because an unresolved import invents TS2307/TS7006 and erases real debt. Its sibling check:type-check-coverage ran green.

    Gates worth naming individually because they are the ones this diff could plausibly move, all green: check:engine-double-contract, check:test-source-alias, check:cross-package-test-inputs (both spellings), check:nul-bytes, check:empty-changeset, check:changeset-no-major, check:published-files, check:type-check-coverage, check-undeclared-dep-imports, check:pm-half-states.

  • Control bytes: check:nul-bytes green, plus a direct scan of all seven changed files with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.

Out of scope, named rather than fixed

ADR-0092 D1's tier table still lists two Tier-1 members while the enforced whitelist constant holds three. Already filed as #14951 and left alone here.

Cross-links

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/access-recipes.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/permission-metadata.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/permissions/rls.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))
  • content/docs/protocol/objectql/security.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via rowLevelSecurity (symbol, a field of const object baseDefaultPermissionSets))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/sys-user-writable-fields.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 20 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 4428dd5756750939e2fd503e431c34c1cb83a19cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e43ce297541f01a7e4fbefc699a70b5196988aa3 — the merge of head d845d564ec7bc7c8184ae8e4bec1459643a655c9 into base 4428dd5756750939e2fd503e431c34c1cb83a19c, 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 e43ce297541f01a7e4fbefc699a70b5196988aa3 && git checkout e43ce297541f01a7e4fbefc699a70b5196988aa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4428dd5756750939e2fd503e431c34c1cb83a19c d845d564ec7bc7c8184ae8e4bec1459643a655c9 && git checkout -B drift-repro 4428dd5756750939e2fd503e431c34c1cb83a19c && git merge --no-ff d845d564ec7bc7c8184ae8e4bec1459643a655c9
node scripts/docs-audit/affected-docs.mjs --json 4428dd5756750939e2fd503e431c34c1cb83a19c

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@hotlong
hotlong marked this pull request as ready for review September 4, 2026 03:21
@hotlong
hotlong enabled auto-merge September 4, 2026 03:22
@hotlong
hotlong added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit ebb0822Sep 4, 2026
45 checks passed
@hotlong
hotlong deleted the claude/issue-14959-member-self-locale branch September 4, 2026 03:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude