Skip to content

fix(plugin-security): surface the isDefault audience-binding suggestion on stock instead of skipping auto-bound declarations - #7704

Merged
os-help merged 2 commits into
mainfrom
claude/issue-7677-suggested-binding-surfacing
Aug 11, 2026
Merged

fix(plugin-security): surface the isDefault audience-binding suggestion on stock instead of skipping auto-bound declarations#7704
os-help merged 2 commits into
mainfrom
claude/issue-7677-suggested-binding-surfacing

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7677

Problem

GET /api/v1/security/suggested-bindings returned {suggestions: [], synced: {created: 0}} on a stock boot, even though the app's isDefault permission set and its everyone binding both existed. The reporter's discriminator — delete the binding row, re-list, and a PENDING row appears with created: 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.ts binds the composed baseline set(s) to the everyone anchor at boot, and its own comment says this must run before syncAudienceBindingSuggestions "so the app's own fallback set is already bound and never generates a redundant pending suggestion".
  • suggested-audience-bindings.ts then reached if (bound) continue for 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 in pending, and on stock no row ever reached pending. 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, with resolved_at set and resolved_by left empty. This is the same end state the existing pending-to-confirmed branch reaches, just arrived at without passing through pending first.

Why confirmed and not pending

The card left this open. Every consumer of the status field points the same way:

  1. The module docblock already specifies "binding already present → confirmed (observed)". confirmed implements the stated contract; pending would contradict it.
  2. The backing object schema defines this exact case in the resolved_by field 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.
  3. The console panel (objectui SuggestedBindingsPanel) lists exactly status=pending and renders nothing when that set is empty — pending is the actionable prompt state. A pending row here would ask an admin to "accept" a binding that already exists.
  4. The confirm/dismiss service methods both 409 on a non-pending row. A pending-but-already-bound row would permit a meaningless confirm (a no-op write) and a misleading dismiss (recorded "no" while the binding stays in force).
  5. The boot comment in security-plugin.ts requires that a satisfied baseline "never nags". confirmed honours that; pending breaks it.

So confirmed is 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; typecheck clean.

The pre-existing test skips a set that is already bound to the anchor (e.g. the boot baseline) asserted created: 0 and zero rows — it pinned the defect, so it is replaced by a test asserting the recorded confirmed/observed row. Added:

  • already-bound set is recorded as confirmed with empty resolved_by and a set resolved_at;
  • idempotency for the bound case — second sync yields created: 0, confirmedObserved: 0, no duplicate row;
  • stock surfacing through listAudienceBindingSuggestions — non-empty list, created: 1 once, and status=pending still returns nothing (never nag);
  • the discriminating step still works — an unbound declaration surfaces as pending, and the pending-to-confirmed transition still fires when the binding is observed later.

Also run green: @objectstack/runtime full suite (127 files / 2018 tests, covering http-dispatcher and domain-handler-registry which carry the /security/suggested-bindings routes) and @objectstack/client admin-surfaces.test.ts (7 tests).

Reverse verification (ablation)

Reverted suggested-audience-bindings.ts to origin/main while 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.ts is 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. Merged origin/main and re-ran the suites before pushing.


Generated by Claude Code

claude added 2 commits August 11, 2026 11:20
…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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 11:34am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-help
os-help marked this pull request as ready for review August 11, 2026 11:37
@os-help
os-help enabled auto-merge August 11, 2026 11:37
@os-help
os-help added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 00e9196 Aug 11, 2026
26 checks passed
@os-help
os-help deleted the claude/issue-7677-suggested-binding-surfacing branch August 11, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

suggested-binding-loop (a): the isDefault audience-binding suggestion is never surfaced on stock (auto-bound at boot, then skipped)

2 participants