Repository navigation
feat(security): re-open a dismissed or revoked suggested audience binding - #27353
Conversation
…ding A dismissed suggestion (a decline, or a revoked acceptance) was terminal: confirm accepts only pending rows and the reconciliation never moves a dismissed one. A new door, POST /security/suggested-bindings/:id/reopen, moves it back to pending exactly as the sync re-opens a row (deployment decision cleared). Re-opening grants nothing: a pending row is not read by the derivation, so a revoked set stays out of `everyone` until confirm accepts it again, high-risk gate included. - spec: ISecurityService gains the OPTIONAL reopenAudienceBindingSuggestion. - plugin-security: reopenAudienceBindingSuggestion, same authority and Default-Organization tenancy as dismiss; refuses a non-dismissed row by name and a set its package no longer suggests (409). - runtime + rest: both mounts serve the route; 501 when the service omits the optional member. Route ledgers, census rows, docs, SDK method. Claude-Session: https://claude.ai/code/session_01Utp6CaofYKDcV5XtynJUdP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 178 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 a6ffd0cdc89c287be570b2db2b3e1be1fae7e65f && git checkout a6ffd0cdc89c287be570b2db2b3e1be1fae7e65f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73725c206f55580db058645dbffe68280b095ef6 ce4b73d2f2178098b105b661c80db9970b8d32d7 && git checkout -B drift-repro 73725c206f55580db058645dbffe68280b095ef6 && git merge --no-ff ce4b73d2f2178098b105b661c80db9970b8d32d7
node scripts/docs-audit/affected-docs.mjs --json 73725c206f55580db058645dbffe68280b095ef6
|
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: The lane seat takes the Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Inputs read: card #27149 (body, triage 6464854240, claim 6481567449, retriage request 6481594971, cross-domain grant 6484879688, amended claim 6485403386, os-dev-report 6486108507), the PR #27353 body and both PR comments, the net diff against ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Holds:
Findings:
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK 席位核对 — ACCEPT ·
|
Requested by Bill · project thread
Fixes #27149
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
packages/plugins/plugin-security/src/suggested-audience-bindings.ts(reopen 函数)、packages/spec/src/contracts/security-service.ts(新增可选成员)、packages/rest/src/security-routes.ts(第二个线上入口)。pnpm --filter @objectstack/plugin-security exec vitest run src/suggested-audience-bindings.test.ts,应看到 reopen 一组 5 条用例全部通过。What changes
A dismissed audience-binding suggestion (a declined one, or a revoked acceptance) was terminal:
confirmAudienceBindingSuggestionaccepts only pending rows, andsyncAudienceBindingSuggestionsnever moves a dismissed row. This adds one door that takes the "no" back.POST /api/v1/security/suggested-bindings/:id/reopenmoves adismissedrow topending, writing exactly what the sync's own re-open arm writes (deployment_decision: false,resolved_by/resolved_atnull). The row then reads, and is reconciled, as a suggestion never answered.readAcceptedEveryoneSets, so nothing is re-derived: a revoked set stays out of theeveryonedefinition until the existing confirm accepts it again, with ADR-0090 D7's high-risk gate.403); the walled-host refusal namingacceptedDefaultPermissionSetsand the no-record-owner refusal (409); unknown id (404); a row that is notdismissed(409 SUGGESTION_STATE, the message names its status); a set its package no longer suggests (409). The last one keeps the decision on record: re-opened, such a row would be pruned by the next sync.ISecurityServicegains the OPTIONALreopenAudienceBindingSuggestion(callerContext, id). Optional so an existing implementer still satisfies the contract; absence is a defined state (every dismissal stands, nothing granted). Both mounts feature-detect it and answer501 NOT_IMPLEMENTEDnaming the door.packages/rest/src/security-routes.ts(RouteManager) andpackages/runtime/src/domains/security.ts(dispatcher). Rows added to both route ledgers (disposition: 'sdk'), and the SDK gainsclient.security.suggestedBindings.reopen(id).confirmandreopenshare one helper for the "is the set still suggested by this package" check (requireStillSuggested); confirm's messages are unchanged except the verb slot.content/docs/permissions/permission-sets.mdxlists the door and what re-opening does.Tests
suggested-audience-bindings.test.ts, newreopenAudienceBindingSuggestionblock: dismiss → re-open → pending, listed as pending by the list (which reconciles first, and does not undo it), then accepted through the existing confirm; revoke → re-open grants nothing (definition, re-derive, sync andreadAcceptedEveryoneSetsall agree); the re-accept still runs the high-risk gate (403); pending, accepted and observed rows refused by name (409, nothing written); unknown id 404; a no-longer-suggested dismissal refused and kept as history. Authority and walled-host cases extended to reopen.optional.INTERNAL_ERROR, one skeleton, 200 success); write-route ledgers carry the row.reopenPOSTs to the encoded id subroute.Acceptance notes
File surface (cross-domain exception, triage comment 6484879688; seat claim 6485403386): the spec face is exactly one OPTIONAL
ISecurityServicemember and its contract-test pin. Files beyond the claim's literal list, each required by a gate or pinning a touched door, cleared by the seat before push: runtimehttp-dispatcher.test.tsanddomains/security-anonymous-deny-ordering.test.ts; restsecurity-suggested-bindings-envelope.test.ts,rest-write-route-hook-refusal-sentence.ledger.test.tsandrest-write-response-internal-fields.tripwire.test.ts(the last two enumerate every write route); clientadmin-surfaces.test.ts;content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md(regenerated byscripts/tenant-audit-census.mjs --write, becausecheck-tenant-audit-censusreds on the new write call site, plus its prose figures).The untyped-fault terminal of the REST reopen route is the standard
INTERNAL_ERRORrather than a family codeSUGGESTION_REOPEN_FAILED: registering a family code is an edit to the spec error-code ledger and its generated JSON schemas, beyond the one contract member. If the spec seat prefers family symmetry, that is a small follow-up.The Studio control (a re-grant button in the suggestions panel) is the ui follow-up Studio Access polish: jargon duplicate-draft refusal, OWD banner outliving revert, stale set label in the rail, clipped field counter, no re-grant for a dismissed suggestion #26846 left out; this PR builds the server door only.
Verification
All readings on head
ce4b73d2f(rebased onorigin/main6d7634a92).turbo run buildafter the rebase, 116/116 tasks, lock VERDICT command-exit 0.tsc --noEmit+check:test-typecheck) of spec, plugin-security, runtime, rest and client: green (pre-rebase tree; the rebase brought one cli-only commit).status: 'confirmed', deployment_decision: true(an implicit accept) viascripts/ablation-replace.mjs(anchor 1 to 0, blob014d57b4cf14to121c7e0c7aca): 3 reopen cases red (dismiss-then-accept, revoke grants nothing, re-accept runs the gate). Restored withgit checkout HEAD; the file's blob equals the HEAD blob014d57b4cf14,git diff HEADempty.eslint --no-inline-configover the changed.tsfiles: exit 0.node scripts/check-platform-checklist.mjs --changed-from origin/main: OK, 0 lost.node scripts/pm/dispatch-gates.mjs --commandson this head = 109 families, all run, all exit 0;--ranreconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.109 derived gates, exit codes
Generated by Claude Code