Skip to content

test(auth): pin the human-user predicate agreement across the plugin-security boundary - #12512

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-11959-human-user-predicate-agreement-pin
Aug 26, 2026
Merged

test(auth): pin the human-user predicate agreement across the plugin-security boundary#12512
os-support-ai merged 3 commits into
mainfrom
claude/issue-11959-human-user-predicate-agreement-pin

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#11959

"Is this sys_user row a HUMAN?" is answered by two owners that decide two halves of one boot sequence, on one population:

ownerwhat it decides
isHumanUserRowplugin-auth/src/audience-posture.tswhether a sign-up is admitted (audience gate bootstrap bypass + dev-admin seed)
isHumanUserplugin-security/src/bootstrap-platform-admin.ts:401prints [security] no human users yet … and then performs the 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 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/spec or @objectstack/platform-objects expands 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-security does not depend on plugin-auth. Measured, not assumed:

  • isHumanUserRow is unreachable from outside plugin-auth. It is module-scope-exported, but src/index.ts does not re-export audience-posture.js and the package exports map carries only . and ./rate-limit-storage. Verified against the built artifact — importing plugin-auth/dist/index.mjs and reading isHumanUserRow yields undefined. A pin living in plugin-security would therefore require adding a new export.
  • isHumanUser is a local closure inside bootstrapPlatformAdmin and is not exported at all — but bootstrapPlatformAdmin itself is already published, and it is the predicate's real call site.

So the pin lives in plugin-auth: it imports isHumanUserRowrelative (in-package, no surface change) and reads isHumanUserthrough 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

bootstrapPlatformAdmin is driven with exactly one row in sys_user under the default (single, non-walled) posture, so its own return reports the predicate's verdict on that row:

  • isHumanUser(row) truthy ⇒ oldest human ⇒ adminPromoted: true
  • isHumanUser(row) falsy ⇒ zero humans ⇒ adminPromoted: false, reason: 'no_users'

The negative side asserts reason === 'no_users' on purpose. Every other way that function returns adminPromoted: false carries 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_system row, that same id carrying a non-system role, an ordinary id carrying role: 'system', a NULL role (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. isHumanUser was deliberately broken (the u.id !== SystemUserId.SYSTEM limb 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:

 FAIL src/human-user-predicate-agreement.pin.test.ts > agrees on the legacy usr_system id carrying a NON-system role
AssertionError: plugin-security and plugin-auth disagree on this row.
row: {"id":"usr_system","role":"admin","email":"system@internal.test"}
plugin-auth isHumanUserRow -> false
plugin-security isHumanUser -> true (reason: none)
Test Files 1 failed (1)
Tests 1 failed | 13 passed (14)

Restored with git checkout HEAD -- <abs path>, then verified byte-identical: git hash-object back to 4ca4c6abe9ff4f45673e5d08846802881658f833 (the HEAD blob), git diff HEAD empty, git diff empty, 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 — a dist-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-limit read the find double as limit-blind — it answered every matching row while bootstrapPlatformAdmin really does pass a bound. The bound is now applied after the filter, by presence.
  • check:engine-double-contract flagged 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 the pin never asks for, so it was dead surface. Its absence short-circuits claimSeedOwnership one guard earlier than before.

check:test-source-alias and the alias

Adding 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.ts gains 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-security in 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=281 files, 1645 tests, all passed

  • pnpm --filter @objectstack/plugin-auth run typecheck — clean (needs this package's own dist; on a worktree without it the examples program 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 plain typecheck says nothing about the new file. Measured the way the ledger defines it — tsc --noEmit with 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/objectstack from 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.mjs

    Quoting 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 double limit conformance 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 through dist/; 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') is false (it requires typeof row === 'object'), while the plugin-security spelling returns true (a bare u && truthiness check, then two property comparisons that are both undefined). Measured by executing the expression extracted verbatim from the source: a string row, a number row and true all score true on the security side.

This is deliberately not in the pinned corpus and deliberately not "fixed" here: a sys_user read 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) where isHumanUserRow returns false. Both are consumed by .filter(), so the decision is identical; the pin compares decisions for that reason.


Generated by Claude Code

… 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
… 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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 11 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 2f665a1afb116cb8a7e9ed34488676158b0fc216packageMentionDocs.

@github-actionsgithub-actionsBot added dependencies Pull requests that update a dependency file tests labels Aug 26, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 07:12
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 7c368e8Aug 26, 2026
38 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-11959-human-user-predicate-agreement-pin branch August 26, 2026 07:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filesize/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] a third hand-spelled copy of the human-user predicate lives in plugin-security, out of reach of the one #11767 consolidated

2 participants

@os-support-ai@claude