Skip to content

feat(security): re-open a dismissed or revoked suggested audience binding - #27353

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-7m9yx2
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-7m9yx2

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread
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 Studio Access: a dismissed or revoked suggested binding cannot be accepted again #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 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 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.
109 derived gates, exit codes
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

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/client, @objectstack/plugin-security, @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints), security.suggestedBindings.confirm (sdk, the route ledger binds it to POST /api/v1/security/suggested-bindings/:id/confirm, selected by route anchor /security/suggested-bindings/:id/confirm; the route ledger binds it to POST /security/suggested-bindings/:id/confirm), security.suggestedBindings.list (sdk, the route ledger binds it to GET /api/v1/security/suggested-bindings))
  • content/docs/api/data-api.mdx (via NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/api/error-catalog.mdx (via INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/api/wire-format.mdx (via NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/automation/flows.mdx (via NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/attachments-access.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/authentication.mdx (via NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/security/suggested-bindings (route, a path literal in security), /api/v1/security/suggested-bindings/:id/reopen (route, a path literal in REST_ROUTE_LEDGER), /security/suggested-bindings/:id/confirm (route, a path literal in a comment in handleSecurityRequest; bridged from symbol confirmAudienceBindingSuggestion — its route source's handler names it), /security/suggested-bindings/:id/reopen (route, a path literal in ROUTE_LEDGER; a path literal in a comment on a changed line; a path literal in registerSecurityEndpoints; bridged from symbol reopenAudienceBindingSuggestion — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via handleSecurityRequest (symbol, a top-level function), registerSecurityService (symbol, a top-level function))
  • content/docs/plugins/development.mdx (via INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints))
  • content/docs/protocol/kernel/error-handling.mdx (via INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface), INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/releases/v17/17-6.mdx (via ISecurityService (symbol, a top-level interface), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/releases/v17/17-7.mdx (via ISecurityService (symbol, a top-level interface), NOT_IMPLEMENTED (literal, a string literal in registerSecurityEndpoints))
  • content/docs/releases/v18/18-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v18/18-2.mdx (via INTERNAL_ERROR (literal, a string literal in registerSecurityEndpoints))

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.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 178 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 73725c206f55580db058645dbffe68280b095ef6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a6ffd0cdc89c287be570b2db2b3e1be1fae7e65f — the merge of head ce4b73d2f2178098b105b661c80db9970b8d32d7 into base 73725c206f55580db058645dbffe68280b095ef6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 73725c206f55580db058645dbffe68280b095ef6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head ce4b73d2f2178098b105b661c80db9970b8d32d7 · 2026-10-29T02:22Z

The lane seat takes the CONTRACT_REVIEW_TIER review on this head itself (Bill 2026-10-29T00:46Z: this account now serves that tier), by an isolated reviewer agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ce4b73d2f2178098b105b661c80db9970b8d32d7
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts + packages/spec/src/contracts/security-service.test.ts, packages/plugins/plugin-security/src/suggested-audience-bindings.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts, packages/runtime/src/domains/security.ts, packages/rest/src/security-routes.ts, docs/adr/0090-permission-model-v2-concept-convergence.md

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 main at the head (22 files, +462/−56), the cited code at the head, ADR-0090 D5/D7/D9, and the head's check-runs (two reads, at 02:24Z and 02:27Z). The rest of the diff (client, runtime, rest, plugin-security, docs, census files, changeset) is the lane seat's ACCEPT at TIER_DEFAULT. It was read here only to judge the one hits file.

① Derived judgments

  • Public surface, @objectstack/spec — right. ISecurityService gains ONE member, reopenAudienceBindingSuggestion?(callerContext: SecurityContext, id: string): Promise<{ suggestion: AudienceBindingSuggestion }>, declared OPTIONAL. Nothing else under packages/spec moves except the contract test. That is exactly the surface triage 6484879688 granted.
  • The contract's accept set — right. It is unchanged for every existing implementer, since an optional member admits the pre-existing shape. For consumers it widens: they may now feature-detect the door and call it. The spec test pins both directions at compile time. The unguarded call is a @ts-expect-error, and a served member answers. The pin is not a phantom: packages/spec/tsconfig.test.json is the tsc program check:test-typecheck runs over that file.
  • REQUIRED_MEMBERS is unchanged — right. That follows from declaring the member optional: the satisfies readonly RequiredMember[] clause would refuse an optional member listed there.
  • Generated spec artifacts — none owed. api-surface/contracts.json records ISecurityService (interface) by name only, and api-surface-signatures.json does not hash this interface, so no regeneration is owed. Type Check · consumer gates, the lane that runs check:api-surface, concluded success.
  • Implementer side (read, not reviewed). plugin-security's registered security object serves the member (security-plugin-security-service.ts 382–383). Its DECLARED_MEMBERS pin carries reopenAudienceBindingSuggestion: 'optional', held equal to the interface by satisfies DeclaredOptionality.
  • Consumer side (read, not reviewed). Both mounts feature-detect the member and answer 501 naming the door before any call, which is the absence handling the contract prescribes.
  • Check-runs at the second read.
    • success: Lint & Repo Gates, Governed Surface Queue Guard, Type Check · source gates, Type Check · consumer gates, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI release & engine gates, the Lint · scoped lanes, Lint · PM dispatch-gates, Check Changeset, Check PR Size, PR-body guards, Spec property liveness, Check Documentation Links, Dogfood Regression Gate, Plan Test Core shards.
    • Still in progress: Test Core (1/8 … 8/8), Build Core, Type Check · workspace, Type Check · debt ledger.
    • This PASS is a design verdict. The queue leg still needs the required aggregates to conclude success.

② Semver level

  • The changeset is right. .changeset/27149-reopen-dismissed-suggested-binding.md is patch on @objectstack/spec, @objectstack/plugin-security, @objectstack/runtime, @objectstack/rest and @objectstack/client, exactly the five published packages the diff moves. The changeset and the PR body both carry Clause-②: yes (widening) and Metadata-protocol: none.
  • The level is right. Metadata-protocol: none takes patch. The widening is additive: an optional member, one route on two mounts and one SDK method. No author-writable key moves, and no ADR-0087 disposition is owed. Check Changeset concluded success.

③ Boundary flags

  • Files beyond the claim's literal list — within surface. They are tests pinning the touched doors, plus census regenerations. Under packages/spec the diff touches only contracts/security-service.ts and its test, which is the grant's boundary.
  • Regenerated census rows — answered. They are tool-written. check:tenant-audit-census and check:route-ledger-census concluded success.
  • The member is OPTIONAL, not REQUIRED — accepted. The retriage request offered "required vs optional as the spec lane prefers". Two precedents exist on this interface (checkControlledByParentWrite?, contributeOwnershipFloorAlternates?). Absence fails closed: every dismissal stands and nothing is granted.
  • REST reopen's untyped-500 code is INTERNAL_ERROR — stands. A family code would mean an edit to the spec error-code ledger, which is outside the one-member grant. INTERNAL_ERROR is a registered code and is pinned. Family symmetry is the spec seat's optional follow-up.
  • The Studio re-grant control — answered. It is the ui half, routed by the card.
  • The grant's in-flight intersection check — done. It is recorded in claim 6485403386.
  • open_questions — none.

④ Design judgment

packages/spec/src/contracts/security-service.ts is judged against ADR-0090 D5/D7/D9, through the card's triage line "re-opening returns the row to pending; it is not an implicit accept", and against its first implementer and consumers in the live tree.

Holds:

  • D5: packages suggest, admins confirm. Re-open writes status: 'pending', deployment_decision: false, resolved_by: null, resolved_at: null, the same shape the sync's own re-open arm writes. readAcceptedEveryoneSets cannot read it, and no re-derive runs. A revoked set stays out of everyone through a re-derive, a sync and the derivation read, and that is pinned.
  • D7: the re-accept goes through the unchanged confirmAudienceBindingSuggestion, gate included. This is pinned: after a re-open, a forbidden bit gets 403 and the row stays pending.
  • The throw clause matches the implementer. Unknown is 404. Not dismissed is 409 SUGGESTION_STATE, naming the status. No longer suggested is the 409 from requireStillSuggested. Refusing a no-longer-suggested dismissal is right, because the next sync would prune a re-opened row and the decision on record would vanish. That is pinned.
  • "Gated like the list." assertDeploymentAuthority and requireRecordOwner run before any write.
  • Optional with fail-closed absence. Both mounts answer 501 naming the door. Leaving reopen out of MUST_SERVE_OPTIONAL is consistent with that pin's admitting-fallback design.
  • Probed and not broken. A guest-anchor dismissal re-opens, and confirm then refuses it. A dismissed row whose set the baseline now names re-opens, and the next sync marks it confirmed. Nothing is granted beyond the baseline.

Findings:

  • should-fix (not a FAIL). No test pins that plugin-security's REGISTERED security service serves reopenAudienceBindingSuggestion. If the wiring at security-plugin-security-service.ts 382–383 were dropped, every suite would stay green and both mounts would answer 501. One served-member assertion on the registered object would close it. This is the lane seat's follow-up.
  • No must-fix.

Implemented-by: claude/project-thread-7m9yx2
Reviewed-by: session_01Utp6CXc5hequf76uJfzrdK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

席位核对 — ACCEPT · domain:server#3 · session_01Utp6CXc5hequf76uJfzrdK · head ce4b73d2 · 2026-10-29T02:43Z

  • Shape: 22 files, +462/−56: plugin-security's reopenAudienceBindingSuggestion (with a shared requireStillSuggested helper used by confirm), the REST route and the dispatcher route (both feature-detect the OPTIONAL member and answer 501), the SDK security.suggestedBindings.reopen, one OPTIONAL ISecurityService member and its contract-test pin, rows in both route ledgers, regenerated census rows, permission-sets.mdx, and one patch changeset over five packages. No governed surface, under the 3,000-line threshold.
  • Ruling, held in the diff: triage 6464854240, verbatim: "⛔ Re-opening returns the row to pending; it is not an implicit accept." The write is pending / deployment_decision: false / resolution cleared. Nothing is re-derived, and the re-accept goes through the unchanged confirm, with ADR-0090 D7's gate. The ablation, an implicit accept, reds 3 cases.
  • Cross-domain exception: triage 6484879688, with the amended claim 6485403386. Under packages/spec the diff touches only contracts/security-service{,.test}.ts, and the in-flight check found no intersecting domain:spec claim.
  • Contract review: PASS on this head (6486241512, CONTRACT_REVIEW_TIER). Its one should-fix, a pin that the REGISTERED plugin-security service serves reopen, is carried as a follow-up on Studio Access: a dismissed or revoked suggested binding cannot be accepted again #27149 and does not block.
  • Declaration: Clause-②: yes (widening) and Metadata-protocol: none, at patch.
  • Gates: every check suite on ce4b73d2 completed with no failure; mergeable_state: clean.
  • Landing: ready → auto-merge through the queue.

Generated by Claude Code

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio Access: a dismissed or revoked suggested binding cannot be accepted again

2 participants