Found while measuring the recovery path for #14495 (docs card); filed unassigned and unlabeled for triage.
Measured
Real kernel: ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, plugin-auth's own authIdentityObjects, driven through AuthManager.handleRequest. Population: three human sys_user rows, zero sys_account rows, NODE_ENV=test. Audience posture widened to email_domain with the directory's domain allowlisted and a resolvable selfRegistrationPermissionSet.
A sign-up for an address that ALREADY carries a sys_user row answers 200 with a freshly minted user id — and persists nothing:
POST /sign-up/email { email: 'alice@corp.example', password, name }
-> 200 {"token":null,"user":{"email":"alice@corp.example","emailVerified":false,
"id":"HEfl4PyZmNaP2F4zjfawppU3R9IhsO9t"}}
rows carrying that address afterwards [["usr_alice", false]] <-- the ORIGINAL row; no row with the returned id
sys_account rows afterwards [] <-- no credential was created
POST /sign-in/email as that person -> 401 INVALID_EMAIL_OR_PASSWORD
The same call on the same population under the invite_only default (admitted by a pending sys_invitation) is refused honestly:
-> 422 {"code":"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"}
Why it matters
The 200 carries a user id no row holds. An operator (or a provisioning script, or the console) that reads the status code concludes the account exists; the next sign-in is a 401 with nothing anywhere explaining it. It is the silent-success shape: a write path that reports created and stored nothing. It is also directly on the recovery path a locked-out deployment walks — the operator who widens the posture to let a seeded person register is told it worked.
Not investigated here: whether the response is synthesized on the forced-email-verification lane before the uniqueness refusal, or whether an insert is attempted and swallowed. The measurement above only establishes the observable contract violation.
Suggested acceptance
The refusal is the same fact under both postures: an address that already exists must answer 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (or another explicit refusal) rather than a 200 for a row that was never written — pinned on a real engine, since the population predicate and the uniqueness check both live below the fake doubles.
Refs: #14495 (the docs card this was measured under) · #14349 (the posture ruling) · #14353 (the boot diagnostic).
Generated by Claude Code
Found while measuring the recovery path for #14495 (docs card); filed unassigned and unlabeled for triage.
Measured
Real kernel:
ObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, plugin-auth's ownauthIdentityObjects, driven throughAuthManager.handleRequest. Population: three humansys_userrows, zerosys_accountrows,NODE_ENV=test. Audience posture widened toemail_domainwith the directory's domain allowlisted and a resolvableselfRegistrationPermissionSet.A sign-up for an address that ALREADY carries a
sys_userrow answers 200 with a freshly minted user id — and persists nothing:The same call on the same population under the
invite_onlydefault (admitted by a pendingsys_invitation) is refused honestly:Why it matters
The 200 carries a user id no row holds. An operator (or a provisioning script, or the console) that reads the status code concludes the account exists; the next sign-in is a 401 with nothing anywhere explaining it. It is the silent-success shape: a write path that reports created and stored nothing. It is also directly on the recovery path a locked-out deployment walks — the operator who widens the posture to let a seeded person register is told it worked.
Not investigated here: whether the response is synthesized on the forced-email-verification lane before the uniqueness refusal, or whether an insert is attempted and swallowed. The measurement above only establishes the observable contract violation.
Suggested acceptance
The refusal is the same fact under both postures: an address that already exists must answer
422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL(or another explicit refusal) rather than a 200 for a row that was never written — pinned on a real engine, since the population predicate and the uniqueness check both live below the fake doubles.Refs: #14495 (the docs card this was measured under) · #14349 (the posture ruling) · #14353 (the boot diagnostic).
Generated by Claude Code