fix(plugin-security): surface the isDefault audience-binding suggestion on stock instead of skipping auto-bound declarations - #7704
Conversation
…on on stock
`GET /api/v1/security/suggested-bindings` was empty on a stock boot even though
the `isDefault` permission set and its `everyone` binding both existed.
The security plugin binds the app's baseline set to the `everyone` anchor at
boot, deliberately before `syncAudienceBindingSuggestions` runs. The reconciler
then took `if (bound) continue` for any declaration with no row yet, so on stock
the declaration was never written at all — it only surfaced after an admin
deleted the binding by hand, which is exactly the discriminator the report used.
An already-satisfied declaration is now recorded instead of skipped, in the state
it is actually in: `confirmed` with an empty `resolved_by` — the object schema's
own definition of an observed binding ("bound at boot or by hand, not confirmed
through the prompt"), and the same end state the pending->confirmed branch
reaches, just without passing through `pending` first. It is deliberately not
`pending`: that is the actionable-prompt state the console panel lists and the
confirm/dismiss service methods accept, so a pending row would prompt an admin to
accept a binding that already exists — the nagging the module's docblock rules
out.
The existing flow is untouched: an unbound declaration still becomes `pending`,
and the pending->confirmed (observed) transition still fires later.
Fixes #7677
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…gested-binding-surfacing
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7677
Problem
GET /api/v1/security/suggested-bindingsreturned{suggestions: [], synced: {created: 0}}on a stock boot, even though the app'sisDefaultpermission set and itseveryonebinding both existed. The reporter's discriminator — delete the binding row, re-list, and a PENDING row appears withcreated: 1— showed the declaration is collected and merely never surfaced.Premise verified on
origin/main, and the ordering that causes it is explicit in the boot path:security-plugin.tsbinds the composed baseline set(s) to theeveryoneanchor at boot, and its own comment says this must run beforesyncAudienceBindingSuggestions"so the app's own fallback set is already bound and never generates a redundant pending suggestion".suggested-audience-bindings.tsthen reachedif (bound) continuefor any declaration with no row yet. "Already bound" is therefore the normal stock case, not an exception, so nothing was ever written.Net effect: the reconciler's
confirmed (observed)transition only fired for a row already inpending, and on stock no row ever reachedpending. That contradicted the module's own docblock, which promises the table reflects every current declaration.Fix
An already-satisfied declaration is now recorded rather than skipped, in the state it is actually in:
confirmed, withresolved_atset andresolved_byleft empty. This is the same end state the existing pending-to-confirmed branch reaches, just arrived at without passing throughpendingfirst.Why
confirmedand notpendingThe card left this open. Every consumer of the
statusfield points the same way:confirmedimplements the stated contract;pendingwould contradict it.resolved_byfield description: "Empty on a confirmed row means the binding was observed (e.g. bound at boot or by hand), not confirmed through the prompt." A confirmed row with no resolver is the schema's own name for a boot-observed binding.objectuiSuggestedBindingsPanel) lists exactlystatus=pendingand renders nothing when that set is empty —pendingis the actionable prompt state. A pending row here would ask an admin to "accept" a binding that already exists.security-plugin.tsrequires that a satisfied baseline "never nags".confirmedhonours that;pendingbreaks it.So
confirmedis the only value that satisfies the docblock, the schema, the UI and the boot contract at once — and it is the truthful one, since the binding does exist. No new status value was introduced.Deliberate consequence, recorded: on a post-fix stock system the row is created
confirmed, so a later unbind does not re-open it as pending. That matches how the module treats resolved rows everywhere else — confirmed and dismissed rows are terminal audit history, never re-opened, and re-opening would nag an admin who just deliberately unbound the set.Tests
packages/plugins/plugin-security: 46 files, 955 tests green;typecheckclean.The pre-existing test
skips a set that is already bound to the anchor (e.g. the boot baseline)assertedcreated: 0and zero rows — it pinned the defect, so it is replaced by a test asserting the recorded confirmed/observed row. Added:confirmedwith emptyresolved_byand a setresolved_at;created: 0,confirmedObserved: 0, no duplicate row;listAudienceBindingSuggestions— non-empty list,created: 1once, andstatus=pendingstill returns nothing (never nag);pending, and the pending-to-confirmed transition still fires when the binding is observed later.Also run green:
@objectstack/runtimefull suite (127 files / 2018 tests, coveringhttp-dispatcheranddomain-handler-registrywhich carry the/security/suggested-bindingsroutes) and@objectstack/clientadmin-surfaces.test.ts(7 tests).Reverse verification (ablation)
Reverted
suggested-audience-bindings.tstoorigin/mainwhile keeping the new tests. Result matched the prediction: exactly the 3 new stock-surfacing tests flipped red; all 18 existing pins stayed green (952 passed / 3 failed) — including pending creation, the pending-to-confirmed transition, pruning, dismiss and the full confirm flow. Restored afterwards.Scope
Three files:
suggested-audience-bindings.ts, its test, and the changeset.security-plugin.tsis not touched — no overlap with #7505 / PR #7697 or #7665, which edit that file. The fix stayed entirely within the reconciler, so no reordering of the boot sequence was needed. Mergedorigin/mainand re-ran the suites before pushing.Generated by Claude Code