fix(plugin-auth): refuse a self-registration grant on a malformed permission-set row, and remove the 14 dead find() envelope limbs behind it - #15827
Conversation
…fuse on a malformed permission-set row 14 union-normalizer blocks (13 `records`-limb, 1 `data`-limb), all reading the same concrete engine: the ObjectQL instance registered as the `objectql`/`data` kernel service. Driven rather than inferred — every read answers a bare array. The `settleSelfRegistrationGrant` block is the opposite defect (#15092's DROP shape) and is fixed in the opposite direction: its trailing filter no longer silently drops a malformed row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…OP fix's direction Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ugin-auth-dead-limbs
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
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): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1fcb5641607e77f88183dd575265d971c9ec71d3 && git checkout 1fcb5641607e77f88183dd575265d971c9ec71d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ec0a6e7bd90f11334967c85f37ee489a3808c0c8 180187383471faa7c19c7495f5374717c2c3f8a3 && git checkout -B drift-repro ec0a6e7bd90f11334967c85f37ee489a3808c0c8 && git merge --no-ff 180187383471faa7c19c7495f5374717c2c3f8a3
node scripts/docs-audit/affected-docs.mjs --json ec0a6e7bd90f11334967c85f37ee489a3808c0c8
|
Clause-② contract review — PR #15827 (card #15597) — verdict: PASS with it notedHead reviewed: Already verified by the PM and not redone here: head match, merge-base, the three fences ( 1 ⭐ The census methodology — tested, and the answer is better than the PR's framingTwo-anchor census (
2 ⭐ Pins-are-not-tautologies — the mechanism is real, by reading and by run
3 ⭐ #15823 — measurement confirmed; premise survives; one addendum
4 B5 — the DROP shape reproduces verbatim; the ⭐ pin does not drive the mechanism the PR describesSource ablation in my worktree (pre-fix filter restored by script; on disk
Case C shows the ⭐ pin's setup grants
5 The one it did NOT change — verified by drive, correct call, one residual
6 The engine claim — wiring confirmed, both halves measured
7 Honesty audit
8 #15587 case ⑦
Contract judgment (contract-review.md ①②③)① Derived judgments: 14 limb removals — right, engine driven (populated + empty) on the one concrete engine; three local ports narrowed to Blocking bar, stated for the two B5 findings (an unpinned fix and an under-declared behaviour cost are the two shapes this lane holds PRs on, so the call is made explicitly rather than left inside "noted"):
Verdict: PASS with it noted. The three things the PM made the outcome turn on all hold — the census claim (and #15094/#15598 with it), the tautology guard, and B5's mutation. Noted, and owed as one small follow-up commit (they do not change the accept/reject judgment): (i) wire Generated by Claude Code Generated by Claude Code |
…wrong grant, correct two counts
- changeset: name the family that now gets a refusal where it previously got a
grant (an active, correctly-named row with a missing/blank id — including a
malformed GLOBAL row beside a well-formed org-scoped one), what it now sees,
and that repairing or deleting the row restores the grant with no code change.
- the wrong-GRANT pin now RESOLVES an organization via `getTenancy`, so it drives
the shape that actually bit: `ps_global` stamped `organization_id: 'org_1'`.
Asserted by column, not only by row count.
- counts corrected: four guard-clause spellings (not five — the fifth match was
`member-role-canonical`'s `raw.join(',')`, a different helper); and this
package has 47 `.find(` sites in non-test source, a count that already
includes `Array#find` (the earlier "~140" was unmeasured). Also "four local
engine ports" -> "three", which is what that sentence names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Fix-up for the clause-② review — three closes, no reworkNew head: 1. The changeset now states the Case D refusal costAdded as an explicit Upgrade note, so it reaches release notes as a behaviour change rather than as generic prose:
Which family, what they now get, and that it is reversible — all three stated. 2. The ⭐ pin resolves an organization, and reds on the stamp
Shown red under the same B5 ablation (pre-fix filter restored, nothing else changed): Tests
3. Two prose numbers corrected
Fixed in the changeset, the test-file header, and the PR body. One more in the same sentence while I was there: the changeset said "the four local engine ports" while naming three — corrected to three ( I also folded your census finding into the PR body: the repo-wide #15094 census is not undercounted — it is Verification on
|
| run | exit |
|---|---|
pnpm --filter @objectstack/plugin-auth exec vitest run src/find-envelope-limb-removal.test.ts |
0 — Test Files 1 passed (1) / Tests 25 passed (25) |
pnpm --filter @objectstack/plugin-auth test |
0 — Test Files 96 passed (96) / Tests 2035 passed (2035) |
pnpm --filter @objectstack/plugin-auth typecheck |
0 — check:test-typecheck: OK, its --self-test control green |
check:changeset-gate-self-tests · check:nul-bytes · check:cross-package-test-inputs · check:pm-governed-prose · check:partof-closing-keyword · check:test-source-alias |
0 each |
The dependency-closure build was re-run first (exit 0, VERDICT line read from the lock wrapper) because the worktree was recreated — an earlier run failed to resolve @objectstack/objectql and reported Tests no tests, which I read as NOT WIRED, not a pass, and did not report as one.
Noted, nothing done: the middleware seam (executeWithMiddleware returning ctx.result) added to #15823's record, and the shared-scratchpad filename hazard — everything I wrote this round is suffixed -15597.
Generated by Claude Code
PM verification of the review fix-up — undrafted and armedAll three closes verified independently at head Scope fences on the incremental diff
The two corrected numbers were re-derived here, not taken on report. At the merge-base
One observation, recorded rather than held. The ⭐ pin's substance is the The census question that opened this round is settled in the body and I agree with the resolution: #15094 is not undercounted. It is Undrafted and auto-merge armed at 10:34Z. Generated by Claude Code |
Fixes #15597
plugin-authcarried the repo's largest concentration of the #15094 census class: array-or-envelope union normalizers of the shapeArray.isArray(x) ? x : x.records ?? [], whose envelope limb is dead code that reads as a contract. One of them was the opposite defect and is fixed in the opposite direction.The census is re-derived, and the card's number was stale
The card says 12
records-limb blocks plus adata-limbrowsOf. Measured onorigin/mainat claim time (c99449ab5) with a comment-masked scan over the package's 56 non-test source files, anchored on the limb rather than onArray.isArray, with a 5-line window so multi-line ternaries are seen:14 blocks — 13
records-limb, 1data-limb.The delta is exactly one, and it reconciles: PR #15738 landed as
aedbaeff1hours before this round and addedhasExistingUserFor, whoserowsline is this shape.git merge-base --is-ancestor aedbaeff1 HEADconfirms it is in the base; the commit's own diff shows the added line. 12 + 1 = 13.The repo-wide #15094 census is not undercounted, and nothing here suggests it is. That census is
Array.isArray-anchored per its own Method section and both package counts re-derive exact (#15094 = 12+1 atca46f8f12, before #15738 landed; #15598 = 7). A first pass at this scan found only 10 — that was an artefact of this round's own limb-anchored spelling, not a defect in the census: four of the fourteen are written as a guard clause (if (Array.isArray(raw)) return raw; const records = raw?.records; …) rather than a ternary. That spelling is the same defect and is included.isBootstrapCreationauth-manager.tshasPendingInvitationForauth-manager.tshasExistingUserFor(new in #15738)auth-manager.tsfindPermissionSetRowsauth-manager.tssettleSelfRegistrationGrantauth-manager.tstryFindensure-default-organization.tsfindRowstenancy-service.tsfindRowsreconcile-membership.tstryFindbackfill-account-issuer.tssys_memberscanmember-role-canonical.tsasRows(guard clause)dev-admin-seed-gate.tsasRows(guard clause)walled-owner-verification-path.tsasRows(guard clause)boot-sign-in-reachability.tsrowsOf(guard clause)phone-sms-texts.tsRe-running the same scan on this branch reports 0.
The engine was driven, not inferred
#14843's standard, and the clause the card says gets skipped: resolve the concrete engine each block reads and drive it once, because
IDataEngine.findbeing declared to resolve toany[]is not proof — #13706 is this repo's counter-example.All fourteen blocks resolve to one concrete engine: the
ObjectQLinstance the kernel registers as theobjectql/dataservice.auth-plugin.tsobtains it withctx.getService('objectql')and hands it to every standalone module;AuthManagerreadsthis.config.dataEnginethroughwithSystemReadContext, which forwardsfindwithout touching its result (it only merges a system context into the query).Driven on a real
ObjectQLover a realSqlDriver(better-sqlite3,:memory:), 23 reads — every block's exact object and query, populated and empty:records, nodata, no bare-row lift[], never a nullish value, so the: []tail was unreachable tooThe static half agrees and is recorded for the reviewer: the only
{ records }producer anywhere inpackages/**ismetadata/src/loaders/database-loader.ts'squeryHistory, a different port that no block here can reach. And no test fixture in the package ever fed an envelope to these ports either — the limbs were never exercised even in test.The one place the limb was NOT impossible, and why removal is still right
ObjectQL.findreturnshookContext.resulton its hook path, and nothing re-checks it against the declared array. Driven: anafterFindhook assigningctx.result = { records: [ … ] }really does makefind()resolve to an envelope.Nothing in this tree does that — the repo's only registered
afterFindisplugin-audit's read recorder, which never touchesctx.result. And the limb did not repair that case, it masked it: a hook corruptingfind()would be a contract violation, and the limb silently absorbed it at fourteen sites while this package's remainingfind()call sites broke anyway (47.find(sites in its non-test source in total, a measured count that already includesArray#find). Fourteen sites of false immunity is worse than one visible failure.That seam is a real question and it is not this card's (this card forbids a spec change and a gate change), so it is filed separately as #15823 with the measurement attached. The review widened that finding with a second door on the same unguarded return — the middleware seam (
executeWithMiddlewarereturningctx.result, withsecurity-plugin.ts:3143assigning it, shape-preserving today) — and it is recorded on that card.B5 is the opposite defect
settleSelfRegistrationGrantalso carried #15092's DROP shape. Its candidate filter was:active !== falseis a genuine selection predicate — a deactivated set legitimately does not resolve — and it stays a filter. The id test is not a selection: it silently drops a malformed row, before the code decides which row to grant. Two ways that goes wrong, both silent, and both measured by ablation on this branch:Cause: no active sys_permission_set row named 'portal_user' resolves— while an active row named exactly that is present. That report is the only signal this path emits and nothing retries it, so a wrong cause is the whole failure.organization_id == nullarm match instead. The pin now resolves an organization (viagetTenancy), which is the shape that actually bit, and the ablation output is verbatim:permission_set_id: "ps_global",organization_id: "org_1"— the global set granted and stamped with the organization, so the store ends up asserting thatorg_1granted a setorg_1never declared. Therows.length === 1arm has the same problem: it can fire on a family that was never singular, because the count is taken after the silent removal.The fix refuses instead of resolving by accident: malformed candidates are detected, the grant is declined, and
reportUngrantedSelfRegistrantnames the malformed row. Same gap-not-a-silent-answer direction the rest of the method already takes. A well-formed family grants exactly as before.The refusal has a cost, and the changeset states it rather than leaving it to release notes: a deployment whose
sys_permission_setalready holds an active, correctly-named row with a missing or blankidnow gets a loud refusal where it previously got a grant — including Case D, a malformed global row sitting beside a well-formed org-scoped one, which used to be dropped harmlessly. That is deliberate (the old behaviour could not tell that family apart from the one where the drop granted the wrong set) and it is fully reversible with no code change: repair or delete the malformed row and the grant proceeds as before. Nothing is written while the refusal stands.The #15587 pin
Untouched and green. B3's edit removes only the envelope limb from the
rowsline; thetry/catchfall-through direction and its single log line are not changed.signup-existing-address-refusal.test.tsruns 8/8, case ⑦ included.Tests — one pin per block, and every pin mutated
src/find-envelope-limb-removal.test.ts, 25 cases, all on a realObjectQL+SqlDriver:probeHumanUsersPresence/probeSignInAccountsPresence,decideDevAdminSeedGate,loadPhoneSmsTemplateBody+seedPhoneSmsTemplates,resolveDefaultOrgId,backfillAccountIssuer+canonicalizeStoredMemberRoles,AuthManager.findPermissionSetRows.org_1grantedps_global, not merely that the table is empty); and a deactivated row still reports the ordinary non-resolution, which is the boundary between the two filter clauses.Mutation, both kinds:
afterFindhook above, drives all 14 reads under it, asserts each really returns an envelope, and asserts every pin's assertion rejects it. It uses the only mechanism in the tree that can actually produce the shape, so the pins are not tautologies.git hash-object(30ee8736→d30e2475), never by the editor's exit code; then restored withgit checkout HEAD -- ABSOLUTE_PATHand proved by an emptygit diff HEADplus a hash back to the HEAD blob, under anEXIT INT TERMtrap with absolute paths. Result: 2 of the 4 red, including the org-resolved ⭐ case, which reds with the stamped row quoted above. The other two stayed green by design: they are the no-regression and boundary guards, not discriminators. No rebuild was needed and none is claimed — the code under test is reached by a relative import, so vitest readssrc, which the ablation itself proves by going red on a source-only change.Gates
Family re-derived from the actual changed files with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-built path list). Its first run refused politely: the tree was 8 commits behind and 7 gate scripts had moved. Mergedorigin/mainand re-derived — 49 gates. All exit codes captured by redirect, never through a pipe.All 49 green, plus
scripts/check-adr-0087-registration.mjsgreen with its--self-testcontrol green.Two first returned exit 3 = PREREQUISITE NOT MET = not measured (
check:dual-build-cjs-loads,check:published-readme-exports— both read built output, and 39 packages had nodist). Rather than report those as unmeasured,pnpm buildwas run to completion (72/72 tasks) and both re-run: both then genuinely exit 0. No gate is reported from a run whose exit was 3, 124 or 137.On the fix-up commit
180187383: package suite96 files / 2035 testsexit 0,typecheckexit 0 (itscheck:test-typecheckself-test control green), and the six gates that read the edited files re-run individually —check:changeset-gate-self-tests,check:nul-bytes,check:cross-package-test-inputs,check:pm-governed-prose,check:partof-closing-keyword,check:test-source-alias— all exit 0.Scope
No
packages/specchange. No gate change.plugin-securityuntouched.service-analyticsandservice-automationuntouched. #15587's pin file unmodified. No exported type surface moved: the three narrowed local ports (BootProbeEngine,DevAdminSeedProbeEngine,PhoneSmsTemplateEngine) and the four private helpers appear 0 times in the builtdist/index.d.ts, so no consumer can see them.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y