Skip to content

Commit efdf11a

Browse files
feat(security): re-open a dismissed or revoked suggested audience binding (#27353)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27149 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 访问设置里「推荐绑定」被忽略或撤销后,管理员现在可以把它重新打开,回到待定状态,再按原流程接受。 - 为什么改:之前点错一次「忽略」或「撤销」就无法在产品里反悔,只能手改 everyone 定义。 - 风险与回滚:重新打开只把记录改回待定,不授予任何权限;接受仍要走原来的高风险检查。回滚即撤掉这一个 PR。 - 先看:`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 条用例全部通过。 - 需要你做的:无,席位落地(spec 契约成员按 triage 的跨域例外裁决处理)。 ## What changes A dismissed audience-binding suggestion (a declined one, or a revoked acceptance) was terminal: `confirmAudienceBindingSuggestion` accepts only pending rows, and `syncAudienceBindingSuggestions` never moves a dismissed row. This adds one door that takes the "no" back. - **`POST /api/v1/security/suggested-bindings/:id/reopen`** moves a `dismissed` row to `pending`, writing exactly what the sync's own re-open arm writes (`deployment_decision: false`, `resolved_by` / `resolved_at` null). The row then reads, and is reconciled, as a suggestion never answered. - **It is not an implicit accept** (triage ruling on #27149). A pending row is not an input of `readAcceptedEveryoneSets`, so nothing is re-derived: a revoked set stays out of the `everyone` definition until the existing confirm accepts it again, with ADR-0090 D7's high-risk gate. - **Refusals**, all before any write: the deployment authority as for dismiss (`403`); the walled-host refusal naming `acceptedDefaultPermissionSets` and the no-record-owner refusal (`409`); unknown id (`404`); a row that is not `dismissed` (`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. - **Contract**: `ISecurityService` gains the OPTIONAL `reopenAudienceBindingSuggestion(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 answer `501 NOT_IMPLEMENTED` naming the door. - **Both mounts** serve the route, as for confirm/dismiss: `packages/rest/src/security-routes.ts` (RouteManager) and `packages/runtime/src/domains/security.ts` (dispatcher). Rows added to both route ledgers (`disposition: 'sdk'`), and the SDK gains `client.security.suggestedBindings.reopen(id)`. - `confirm` and `reopen` share one helper for the "is the set still suggested by this package" check (`requireStillSuggested`); confirm's messages are unchanged except the verb slot. - Generated/census rows: the tenant-audit census page and counts file (one new write call site: 244 → 245, with its prose figures), and the runtime route-ledger census sentence (105 → 106 rows). - Docs: `content/docs/permissions/permission-sets.mdx` lists the door and what re-opening does. ## Tests - plugin-security `suggested-audience-bindings.test.ts`, new `reopenAudienceBindingSuggestion` block: 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 and `readAcceptedEveryoneSets` all 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. - spec contract test: reopen is optional (the unguarded call does not compile) and a served member answers. - plugin-security members pin: reopen declared `optional`. - runtime: dispatcher reopen passes the caller context and id, 501 when the member is absent, 409 mapping; anonymous deny covers reopen. - rest: envelope family extended to reopen (501 not registered, 501 member absent, typed 403/404/409, untyped 500 `INTERNAL_ERROR`, one skeleton, 200 success); write-route ledgers carry the row. - client: `reopen` POSTs 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 `ISecurityService` member 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: runtime `http-dispatcher.test.ts` and `domains/security-anonymous-deny-ordering.test.ts`; rest `security-suggested-bindings-envelope.test.ts`, `rest-write-route-hook-refusal-sentence.ledger.test.ts` and `rest-write-response-internal-fields.tripwire.test.ts` (the last two enumerate every write route); client `admin-surfaces.test.ts`; `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` (regenerated by `scripts/tenant-audit-census.mjs --write`, because `check-tenant-audit-census` reds on the new write call site, plus its prose figures). - The untyped-fault terminal of the REST reopen route is the standard `INTERNAL_ERROR` rather than a family code `SUGGESTION_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 #26846 left out; this PR builds the server door only. ## Verification All readings on head `ce4b73d2f` (rebased on `origin/main` `6d7634a92`). - Build: full `turbo run build` after the rebase, 116/116 tasks, lock VERDICT command-exit 0. - Typecheck (`tsc --noEmit` + `check:test-typecheck`) of spec, plugin-security, runtime, rest and client: green (pre-rebase tree; the rebase brought one cli-only commit). - Full test suites of the five packages: green on the pre-rebase tree (748 + 236 + 334 + 468 + 52 files, 0 failures). Targeted suites re-run on this head: spec contract 26/26, plugin-security 58/58, runtime 315/315, rest 640/640, client 8/8. - Ablation (once, then restored): reopen's write changed to `status: 'confirmed', deployment_decision: true` (an implicit accept) via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `014d57b4cf14` to `121c7e0c7aca`): 3 reopen cases red (dismiss-then-accept, revoke grants nothing, re-accept runs the gate). Restored with `git checkout HEAD`; the file's blob equals the HEAD blob `014d57b4cf14`, `git diff HEAD` empty. - `eslint --no-inline-config` over the changed `.ts` files: exit 0. - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: OK, 0 lost. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` on this head = 109 families, all run, all exit 0; `--ran` reconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. <details><summary>109 derived gates, exit codes</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:route-ledger-census :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaofYKDcV5XtynJUdP)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ac5ae5 commit efdf11a

22 files changed

Lines changed: 462 additions & 56 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/rest': patch
6+
'@objectstack/client': patch
7+
---
8+
9+
feat(security): a dismissed or revoked suggested audience binding can be re-opened, back to pending
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
Once an admin dismissed a pending audience-binding suggestion, or revoked an accepted one, the row stayed `dismissed` and nothing in the product could take that "no" back: `confirm` accepts only pending rows, and the reconciliation never moves a dismissed row. A new door re-opens it:
15+
16+
- **`POST /api/v1/security/suggested-bindings/:id/reopen`** moves a `dismissed` row back to `pending`, exactly as if it had never been answered (`deployment_decision` false, `resolved_by` / `resolved_at` cleared). It is served by both mounts of the family: the REST route and the dispatcher's `/security` domain.
17+
- **Re-opening grants nothing.** A pending row is not an acceptance, so a revoked set stays out of the `everyone` anchor's definition until `POST …/:id/confirm` accepts it again, and that accept runs the ADR-0090 D7 high-risk gate like any other.
18+
- **Refusals.** The same authority as the other doors (`manage_metadata`; under a walled posture, the platform operator; `403` otherwise), the same walled-host refusal naming `acceptedDefaultPermissionSets` (`409`), `404` for an unknown id, and `409 SUGGESTION_STATE` for a row that is not `dismissed` (the message names its status) or whose set its package no longer suggests. The last keeps the decision on record: a re-opened row with no declaration would be pruned by the next reconciliation.
19+
- **Contract.** `ISecurityService` gains the OPTIONAL member `reopenAudienceBindingSuggestion(callerContext, id)`. A security service without it still satisfies the contract; both routes answer `501 NOT_IMPLEMENTED` naming the door, and every dismissal stands. plugin-security serves it and exports `reopenAudienceBindingSuggestion`.
20+
- **SDK.** `client.security.suggestedBindings.reopen(id)`.

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,22 @@ package produces one:
281281
GET /api/v1/security/suggested-bindings?status=pending # list (reconciles first)
282282
POST /api/v1/security/suggested-bindings/:id/confirm # accept: everyone carries the set
283283
POST /api/v1/security/suggested-bindings/:id/dismiss # decline, or revoke an acceptance
284+
POST /api/v1/security/suggested-bindings/:id/reopen # take a dismissal back: pending again
284285
```
285286

286287
`status` accepts `pending`, `confirmed` or `dismissed`, and may be omitted to
287288
list every suggestion. Any other value is rejected with a `400` naming the
288289
accepted set — it previously reached the query as a filter nothing matched, so
289290
a mistyped status returned an empty list that read as "no suggestions".
290291

291-
All three require the `manage_metadata` capability, and under a walled
292+
Re-opening is how an admin changes their mind about a dismissal or a revoke:
293+
the row returns to `pending`, exactly as if it had never been answered. It
294+
grants nothing by itself. A revoked set stays out of `everyone` until `confirm`
295+
accepts it again, and that accept runs the high-risk gate as any other does.
296+
Only a `dismissed` row can be re-opened (`409` otherwise), and only while its
297+
package still suggests the set.
298+
299+
All four require the `manage_metadata` capability, and under a walled
292300
tenancy posture the platform operator: the decision reaches every member of
293301
every organization, so a tenant administrator cannot make it. An acceptance
294302
takes effect at once. What `everyone` grants is the `permissionSets` its

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**The expensive failure direction is a keyword.** Sites whose receiver the author
96-
typed `any` have no type to read, and there are 55 of them — just over a fifth
96+
typed `any` have no type to read, and there are 56 of them — just over a fifth
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
dropped every one of them silently, with a clean exit and a smaller number that
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 244 sites are spelled that way**. A
135+
forwarding shim cannot, and **51 of the 245 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **244** |
200+
| 175 write call sites | quoted in the merged changeset | **245** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **166 of 244** decidable, **78** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 141 decidably elevated, 0 decidably not, 95 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **167 of 245** decidable, **78** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 141 decidably elevated, 0 decidably not, 96 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -210,18 +210,18 @@ be stated is what this instrument counts, which is written above and re-runnable
210210
at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213-
one, and both are counted in the generated tables below: the 55 sites reached
214-
through an erased (`any`) receiver, and the 51 that name their object through a
213+
one, and both are counted in the generated tables below: the 56 sites reached
214+
through an erased (`any`) receiver, and the 52 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
141 of 244 (58%) as decidably elevated, with 95 more whose elevation is a
220+
141 of 245 (58%) as decidably elevated, with 96 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 244`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 245`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,29 +233,29 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **244** |
237-
| …whose object name is statically decidable | 166 |
236+
| write call sites on the application surface | **245** |
237+
| …whose object name is statically decidable | 167 |
238238
| …whose object name is chosen at run time | 78 |
239-
| …against an object with tenancy ENABLED | 165 |
239+
| …against an object with tenancy ENABLED | 166 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 185 |
241+
| threading a tenant context | 186 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246246
| threading a decidably ELEVATED (`isSystem`) context | 141 |
247247
| threading a context that is decidably NOT elevated | 0 |
248-
| threading a context whose elevation is a run-time fact | 95 |
248+
| threading a context whose elevation is a run-time fact | 96 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252252
| receiver carried a readable engine type | 189 |
253-
| receiver erased, placed by the object NAME | 45 |
253+
| receiver erased, placed by the object NAME | 46 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257257
| object name spelled inline | 115 |
258-
| object name spelled through a `const` | 51 |
258+
| object name spelled through a `const` | 52 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 71 |
261261

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-26 at `700904cd5`.
310+
Measured on 2026-10-28 at `7b081efc9`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 779 |
315-
| engine-shaped types recognised | 89 |
316-
| declared objects in the registry | 117 |
317-
| same-named calls subtracted as non-engine | 177 |
314+
| tracked non-test sources scanned | 799 |
315+
| engine-shaped types recognised | 93 |
316+
| declared objects in the registry | 120 |
317+
| same-named calls subtracted as non-engine | 181 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 244 |
37-
| Object name statically decidable | 166 |
36+
| Write call sites | 245 |
37+
| Object name statically decidable | 167 |
3838
| Object name chosen at run time | 78 |
39-
| Against a tenancy-enabled object | 165 |
39+
| Against a tenancy-enabled object | 166 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 185 |
41+
| Threading a tenant context | 186 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
4646
| Threading a decidably elevated context | 141 |
4747
| Threading a decidably non-elevated context | 0 |
48-
| Threading a context of undecidable elevation | 95 |
48+
| Threading a context of undecidable elevation | 96 |
4949

5050
## Subtractions the census could NOT defend — enforced
5151

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-26 at `700904cd5`.
93+
Measured on 2026-10-28 at `7b081efc9`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 779 |
98-
| engine-shaped types recognised | 89 |
99-
| declared objects in the registry | 117 |
100-
| same-named calls subtracted as non-engine | 177 |
97+
| tracked non-test sources scanned | 799 |
98+
| engine-shaped types recognised | 93 |
99+
| declared objects in the registry | 120 |
100+
| same-named calls subtracted as non-engine | 181 |
101101

102102
## Every site
103103

@@ -183,7 +183,7 @@ Measured on 2026-10-26 at `700904cd5`.
183183
| `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 |
184184
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 |
185185
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 |
186-
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 |
186+
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 4 |
187187
| `packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts` | `update` | `sys_record_share` | enabled | elevated | 1 |
188188
| `packages/plugins/plugin-sharing/src/primary-bu-projection.ts` | `update` | `sys_user` | enabled | elevated | 2 |
189189
| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | options unreadable | 2 |

‎packages/client/src/admin-surfaces.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,14 @@ describe('client.security.suggestedBindings', () => {
116116
'http://localhost:3000/api/v1/security/suggested-bindings/sug_2/dismiss',
117117
);
118118
});
119+
120+
it('reopen POSTs to the id-scoped subroute, encoding the id', async () => {
121+
const { client, fetchMock } = createMockClient({ success: true, data: { suggestion: { id: 'sug/3', status: 'pending' } } });
122+
const result = await client.security.suggestedBindings.reopen('sug/3');
123+
expect(String(fetchMock.mock.calls[0][0])).toBe(
124+
'http://localhost:3000/api/v1/security/suggested-bindings/sug%2F3/reopen',
125+
);
126+
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
127+
expect(result).toEqual({ suggestion: { id: 'sug/3', status: 'pending' } });
128+
});
119129
});

‎packages/client/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6228,6 +6228,15 @@ export class ObjectStackClient {
62286228
);
62296229
return this.unwrapResponse(res);
62306230
},
6231+
6232+
/** Re-open a dismissed or revoked suggestion: pending again, nothing granted until `confirm` accepts it. */
6233+
reopen: async (id: string): Promise<{ suggestion: AudienceBindingSuggestion }> => {
6234+
const res = await this.fetch(
6235+
`${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/reopen`,
6236+
{ method: 'POST' },
6237+
);
6238+
return this.unwrapResponse(res);
6239+
},
62316240
},
62326241
};
62336242

‎packages/plugins/plugin-security/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export {
9494
listAudienceBindingSuggestions,
9595
confirmAudienceBindingSuggestion,
9696
dismissAudienceBindingSuggestion,
97+
reopenAudienceBindingSuggestion,
9798
SuggestionNotFoundError,
9899
SuggestionStateError,
99100
} from './suggested-audience-bindings.js';

‎packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const DECLARED_MEMBERS = {
7979
listAudienceBindingSuggestions: 'required',
8080
confirmAudienceBindingSuggestion: 'required',
8181
dismissAudienceBindingSuggestion: 'required',
82+
reopenAudienceBindingSuggestion: 'optional',
8283
contributeOwnershipFloorAlternates: 'optional',
8384
} as const satisfies DeclaredOptionality;
8485

‎packages/plugins/plugin-security/src/security-plugin-security-service.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
listAudienceBindingSuggestions,
7777
confirmAudienceBindingSuggestion,
7878
dismissAudienceBindingSuggestion,
79+
reopenAudienceBindingSuggestion,
7980
type SuggestionDeps,
8081
type SuggestionListFilter,
8182
} from './suggested-audience-bindings.js';
@@ -370,13 +371,16 @@ export function registerSecurityService(
370371
},
371372
// [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest a
372373
// set for `everyone`; a `manage_metadata` holder accepts it once for
373-
// the deployment (the `everyone` definition is re-derived) or dismisses.
374+
// the deployment (the `everyone` definition is re-derived) or dismisses,
375+
// and may re-open a dismissal (back to pending — nothing granted).
374376
listAudienceBindingSuggestions: (callerContext?: any, filter?: SuggestionListFilter) =>
375377
listAudienceBindingSuggestions(suggestionDeps, callerContext, filter),
376378
confirmAudienceBindingSuggestion: (callerContext: any, id: string) =>
377379
confirmAudienceBindingSuggestion(suggestionDeps, callerContext, id),
378380
dismissAudienceBindingSuggestion: (callerContext: any, id: string) =>
379381
dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id),
382+
reopenAudienceBindingSuggestion: (callerContext: any, id: string) =>
383+
reopenAudienceBindingSuggestion(suggestionDeps, callerContext, id),
380384
};
381385
// [ADR-0106 D7] The metadata-plane readable-field query, registered as an
382386
// EXTENSION of the published contract rather than inside the typed

0 commit comments

Comments
 (0)