Uh oh!
There was an error while loading. Please reload this page.
test(auth): pin the human-user predicate agreement across the plugin-security boundary - #12512
Merged
os-support-ai merged 3 commits intoAug 26, 2026
Merged
Conversation
… boundary "Is this `sys_user` row a HUMAN?" is answered by two owners that decide two halves of one boot sequence on one population: plugin-auth's consolidated `isHumanUserRow` (audience-posture.ts) decides whether a sign-up is ADMITTED, and plugin-security's hand-spelled `isHumanUser` (bootstrap-platform-admin.ts) prints "no human users yet" and then PERFORMS the platform-admin promotion. Nothing gated their agreement. Divergence means a seed that decides to run and a gate that then refuses it -- a fresh-looking install locked out of itself, observable on any database still carrying the legacy `usr_system` service row. This pins the agreement rather than consolidating the copies. Moving the predicate into a package both plugins depend on expands a published surface, which is a separate and currently declined decision; the pin closes the contradiction risk with no new API. Reaching both predicates from one test is a package-boundary problem with exactly one solution that widens nothing: - `isHumanUserRow` is module-scope-exported but is NOT re-exported from plugin-auth's index.ts and is absent from its `exports` map, so nothing outside plugin-auth can import it (verified against the built dist: `isHumanUserRow` is `undefined` there). Pinning from plugin-security would require ADDING that export. - `isHumanUser` is a local closure and is not exported at all -- but its real call site, `bootstrapPlatformAdmin`, is already published. So the pin lives in plugin-auth, imports `isHumanUserRow` relative, and reads `isHumanUser` THROUGH the published entry point: one row in `sys_user` under the default `single` posture makes `adminPromoted` report the predicate's verdict on that row directly. The only new edge is a devDependency; no production dependency and no new export in either package. That edge is also what makes this a pin -- CI's affected-package computation walks the dependency graph, so without it a plugin-security-only change would never mark this package affected. `check:test-source-alias` requires the new cross-package specifier to resolve to source rather than `dist/`, so plugin-auth's vitest config gains one anchored alias entry. The registry it audits is shrink-only and aliasing is the remedy it names. The negative side asserts `reason === 'no_users'` because every other way `bootstrapPlatformAdmin` returns `adminPromoted: false` carries a different reason -- without it a harness that short-circuited early would read as a unanimous "not human" and pass vacuously. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8
…man-user-predicate-agreement-pin
… fake Two shrink-only ratchets judged the new fake and were right about both. `check:objectql-double-limit` read the `find` double as limit-blind: it answered every matching row while `bootstrapPlatformAdmin` really does pass a bound (1 for the permission-set probe, 50 for the user and grant reads). The bound is now applied AFTER the filter, by presence, the shape the gate prescribes. `check:engine-double-contract` flagged the double's `update()` as a fake write verb looser than `ObjectQL.update`. The verb is DELETED rather than pinned: its only caller is the `resync` branch, which this pin never asks for, so it was dead surface. Its absence also short-circuits `claimSeedOwnership` at that function's own `typeof ql.update !== 'function'` guard, one step earlier than the registry guard it used to stop at. Neither ratchet's baseline was touched. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Aug 26, 2026
os-support-ai
marked this pull request as ready for review
August 26, 2026 07:12
This was referenced Aug 26, 2026
Uh oh!
There was an error while loading. Please reload this page.
os-support-ai
deleted the
claude/issue-11959-human-user-predicate-agreement-pin
branch
August 26, 2026 07:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#11959
"Is this
sys_userrow a HUMAN?" is answered by two owners that decide two halves of one boot sequence, on one population:isHumanUserRow—plugin-auth/src/audience-posture.tsisHumanUser—plugin-security/src/bootstrap-platform-admin.ts:401[security] no human users yet …and then performs the promotionNothing gated their agreement. Divergence means a seed that decides to run and a gate that then refuses it — a fresh-looking install locked out of itself, observable on any database still carrying the legacy
usr_systemservice row.This PR lands the agreement pin, which is the scope triage ruled at grading. It deliberately does not consolidate the copies: moving the predicate into
@objectstack/specor@objectstack/platform-objectsexpands a published surface, which is a separate and currently declined decision. #11767 (which unified the two plugin-auth copies) is unaffected by this PR and stays as it landed.The package boundary is the whole problem, and it has exactly one solution that widens nothing
plugin-securitydoes not depend onplugin-auth. Measured, not assumed:isHumanUserRowis unreachable from outsideplugin-auth. It is module-scope-exported, butsrc/index.tsdoes not re-exportaudience-posture.jsand the packageexportsmap carries only.and./rate-limit-storage. Verified against the built artifact — importingplugin-auth/dist/index.mjsand readingisHumanUserRowyieldsundefined. A pin living inplugin-securitywould therefore require adding a new export.isHumanUseris a local closure insidebootstrapPlatformAdminand is not exported at all — butbootstrapPlatformAdminitself is already published, and it is the predicate's real call site.So the pin lives in
plugin-auth: it importsisHumanUserRowrelative (in-package, no surface change) and readsisHumanUserthrough the published entry point. The only new edge is a devDependencyplugin-auth → plugin-security. No production dependency, and no new export in either package.That edge is also what makes this a pin rather than decoration: CI's affected-package computation walks the dependency graph, so without it a
plugin-security-only change would never mark this package affected and the pin would sit green through the very edit that breaks it.How the security-side verdict is read
bootstrapPlatformAdminis driven with exactly one row insys_userunder the default (single, non-walled) posture, so its own return reports the predicate's verdict on that row:isHumanUser(row)truthy ⇒ oldest human ⇒adminPromoted: trueisHumanUser(row)falsy ⇒ zero humans ⇒adminPromoted: false, reason: 'no_users'The negative side asserts
reason === 'no_users'on purpose. Every other way that function returnsadminPromoted: falsecarries a different reason (objectql_unavailable,admin_permission_set_missing,already_have_admin,walled_*,insert_failed), so a harness that broke and short-circuited early would otherwise read as a unanimous "not human" and let the file pass vacuously.'no_users'is reachable only through the human filter. There is an explicit anti-vacuity case as well, asserting the corpus really elicits both answers.Result: the two predicates agree on the entire corpus — 14/14 green. The corpus covers the legacy
usr_systemrow, that same id carrying a non-system role, an ordinary id carryingrole: 'system', aNULLrole (the three-valued-logic case the JS filter exists for), an absent role, an empty-string role,'System'with different case, an id merely containing the system id as a substring, and null/undefined rows.Dissolution verification — the pin has been seen red
A pin never seen red is not evidence.
isHumanUserwas deliberately broken (theu.id !== SystemUserId.SYSTEMlimb removed), the mutation confirmed on disk before measuring (removed text count 0, injected marker count 1, object hash moved off the HEAD blob), and the pin went red on exactly the row that limb protects:Restored with
git checkout HEAD -- <abs path>, then verified byte-identical:git hash-objectback to4ca4c6abe9ff4f45673e5d08846802881658f833(the HEAD blob),git diff HEADempty,git diffempty, marker count 0. The control leg re-ran green on the restored tree.That ablation carries a second result worth naming: the mutation was made in
src/only, with no rebuild, and the pin still went red. That is the direct proof that the new vitest alias is live — adist-resolved test would have stayed green, which is the exact shape of an ablation that quietly proves nothing.Two shrink-only ratchets judged the new fake, and both were right
Neither baseline was touched; both were fixed at the root.
check:objectql-double-limitread thefinddouble as limit-blind — it answered every matching row whilebootstrapPlatformAdminreally does pass a bound. The bound is now applied after the filter, by presence.check:engine-double-contractflaggedupdate()as a fake write verb looser thanObjectQL.update. The verb is deleted rather than pinned: its only caller is theresyncbranch, which the pin never asks for, so it was dead surface. Its absence short-circuitsclaimSeedOwnershipone guard earlier than before.check:test-source-aliasand the aliasAdding the cross-package specifier made that gate red, naming the remedy: its registry is shrink-only, and aliasing the import — not widening the entry — is the fix.
plugin-auth/vitest.config.tsgains one anchored (^…$, array-form) alias entry so the specifier resolves to source, which is what a pin should be a verdict about. Blast radius is one file: the only other mentions of@objectstack/plugin-securityin this package are prose inside comments, not imports.Verification
All run against the final commit of this branch,
fc38b082.pnpm --filter @objectstack/plugin-auth run test --maxWorkers=2— 81 files, 1645 tests, all passedpnpm --filter @objectstack/plugin-auth run typecheck— clean (needs this package's owndist; on a worktree without it theexamplesprogram fails to resolve@objectstack/plugin-auth, which is an unbuilt-artifact result and not a code verdict)TEST_DEBT is a zero-margin ratchet for this package (
errors: 97, "the next new error here goes red immediately"), and the package tsconfig excludes**/*.test.ts, so a plaintypechecksays nothing about the new file. Measured the way the ledger defines it —tsc --noEmitwith the exclusion lifted: total 97, exactly the recorded value, with 0 errors in the new pin file.Local gate union, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real changed set — all green:check:cross-package-test-inputs·check:objectql-double-limit·check:override-consistency·check:page-declaration-shape·check:published-files·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:type-check-coverage·check:nul-bytes·check-changeset-fixed.mjs·check-ci-filter-parity.mjs·check-comment-mask-adoption.mjs·check-osv-exemptions.mjs·check-plugin-teardown-shape.mjsQuoting the gates' own verdict lines rather than a shell status:
check-engine-double-contract: OK — 416 pinned, 133 in the DEBT ledger, 2 exempt.OK ObjectQL doublelimitconformance holds: 280 double(s) graded, 80 apply the caller's bound or refuse it loudly.OK: 20 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep throughdist/; 45 published subpath(s) resolved through every alias table.Changeset
None, deliberately —
skip-changeset. This ships nothing: one new test file, one vitest alias, and one devDependency. No production dependency, no export, no runtime behaviour, and devDependencies are not part of the published dependency set. An empty changeset would be the wrong instrument.One measured observation, reported rather than fixed
The two predicates diverge on non-object truthy inputs:
isHumanUserRow('usr_alice')isfalse(it requirestypeof row === 'object'), while the plugin-security spelling returnstrue(a bareu &&truthiness check, then two property comparisons that are bothundefined). Measured by executing the expression extracted verbatim from the source: a string row, a number row andtrueall scoretrueon the security side.This is deliberately not in the pinned corpus and deliberately not "fixed" here: a
sys_userread never yields a non-object row, so it is unreachable rather than a live defect, and which of the two spellings is right is not a question this PR should answer silently. Recorded so it is a decision rather than an oversight. The direction is worth noting if it is ever revisited — the security copy fails open (calls a non-row "human").There is also a return-shape difference with no behavioural consequence: for falsy input the security closure returns the input itself (
null) whereisHumanUserRowreturnsfalse. Both are consumed by.filter(), so the decision is identical; the pin compares decisions for that reason.Generated by Claude Code