Skip to content

feat(spec,plugin-security): publish the caller's resolved permission SETS on the security service (#7616) - #7829

Open
claude[bot] wants to merge 2 commits into
mainfrom
claude/issue-7616-permission-set-contract
Open

feat(spec,plugin-security): publish the caller's resolved permission SETS on the security service (#7616)#7829
claude[bot] wants to merge 2 commits into
mainfrom
claude/issue-7616-permission-set-contract

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes the step 1 half of #7616. Step 2 (switching the call sites in plugin-hono-server's current-user-endpoints.ts and deleting the local resolution) is deliberately not here — see "What this does not do".

Why

ISecurityService could report the caller's effective permission-set names (resolvePermissionSetNames) and nothing else. That is the right primitive for an audience check — "does this caller hold sales_manager?" — and the wrong one for a merge. A consumer folding the caller's grants into one answer needs the sets themselves: objects, fields, systemPermissions, tabPermissions. None of the four is reachable from a name.

So the two consumers that need a merge re-implement the resolution instead. /auth/me/permissions and /me/apps each resolve the caller's permission sets by hand, alongside SecurityPlugin's own copy on the data plane — one rule, three copies, and it has drifted from the enforcement path three times, each divergence found only after it reached a user:

card drift measured effect
#7608 the plugin applied the ADR-0090 D5 baseline additively; both endpoints kept the resolved.length === 0 fallback cliff a member's first grant took them from 2 apps to 1 on /me/apps
#7555 / PR #7605 an app-declared isDefault set displaced member_default rather than composing with it every member of that app lost the platform floor
#6334 grant aggregation missed sys_user_position / sys_position_permission_set entirely closed by delegating to resolveUserAuthzGrants — the precedent this extends one step further

What changed

packages/spec/src/contracts/security-service.ts — new resolvePermissionSetsForContext?(context): the same resolution resolvePermissionSetNames reports the names of, returned whole and in resolution order. Implementations must return the sets their own enforcement path resolved (positions expanded, the D5 baseline applied additively, the D10 agent-principal rule honoured), never a re-derivation. Merge semantics stay with the caller on purpose — two consumers legitimately project different subsets of the same sets, and folding a merge in here would make the method a fourth copy of the rule rather than the one source of its input.

It is OPTIONAL, and that is load-bearing. The contract's own availability rule has consumers resolve this service as Partial<ISecurityService>, so a caller must keep its local resolution as the fallback until a floor version carrying the method can be assumed. Declaring it optional makes that degradation a property of the type — the unguarded call does not compile — rather than a promise in prose. This follows the file's established precedent for getMetadataReadableFields? (ADR-0106 D7) and checkAuthoredRowWrite? (#5493), both added optional for exactly this reason.

packages/plugins/plugin-security/src/security-plugin.ts — exposed on the registered service literal, not merely as a public class member. plugin-hono-server must never take a runtime dependency on plugin-security (it is optional in the stacks those endpoints serve — the !evaluator degraded branches are exactly its absence), so the service locator is the only seam that can carry the delegation, and a method the class declares but the literal does not expose is unreachable across it. The class method stays private: the literal is the supported surface.

Same file — one implementation gap closed so the declaration is true rather than nominal. The plugin's sys_permission_set loader hydrated objects, fields and systemPermissions but dropped tab_permissions, so every DB-authored set came back without the column /me/apps filters its app list with. Shipping a contract that promises the sets whole over a loader that drops a quarter of them is the declared-≠-delivered defect Prime Directive #10 exists to prevent. Nothing on the data plane reads tabPermissions (the evaluator never mentions it), so this is inert for enforcement today; the row is already fetched in full, so it is one JSON parse and no extra query.

Same file — the comment-only fold-in the card asks for. resolveFallbackPermissionSets' doc-comment still described "the second step /auth/me/permissions takes when a caller's own names resolve to nothing (resolved.length === 0 && fallbackName)". PR #7615 deleted that step — the guard was the fallback cliff D5 abolishes. Corrected, plus a second stale cross-reference to "the same two-step" further down the same comment.

Testssecurity-service.test.ts pins the optionality (the unguarded call must not compile) and the four columns the names surface cannot reach. resolve-permission-sets-for-context.pin.test.ts (new, in plugin-security) pins declared = reachable.

The pin: declared = reachable

An unused method still has something worth pinning, and it is the part that actually breaks. Every case in the new pin test resolves the service the way a cross-package consumer does — off the ctx.registerService('security', …) call, as a Partial<ISecurityService> — and never off the plugin instance:

  1. exposed on the registered literal (typeof svc.resolvePermissionSetsForContext === 'function');
  2. callable through that handle and returning all four columns, on both authoring paths a set arrives by (metadata-declared, and sys_permission_set through the DB loader — the path where a column silently goes missing);
  3. the same resolution the names surface reports (sets.map(s => s.name) deep-equals resolvePermissionSetNames), so a future edit cannot give the two surfaces separate resolutions;
  4. the D5 baseline is additive — a caller holding an explicit grant still resolves member_default (the /auth/me/permissions and /me/apps still apply the baseline only when the caller resolves to ZERO sets — the ADR-0090 D5 fallback cliff, one plane over from where it was abolished #7608 shape);
  5. an anonymous caller resolves nothing — the baseline stays gated on a principal, matching the middleware.

Reverse-verified, both halves:

removed result
the literal's resolvePermissionSetsForContext entry 5 / 5 cases failexpected 'undefined' to be 'function', and every call returns nothing
the loader's tabPermissions hydration 1 case failsexpected undefined to deeply equal { app_crm: 'visible' }

Both restored and re-confirmed green afterwards.

What this does not do — and why

The call sites are untouched. current-user-endpoints.ts is not in this diff, and the local resolution is not deleted.

/me/apps deliberately projects a narrower column set (name + systemPermissions + tabPermissions) than /auth/me/permissions does. Delegating changes which columns load on both surfaces — a user-visible behaviour change that wants its own before/after measurement, not a refactor riding on a contract addition. Landing both steps together is how this becomes an unreviewable diff.

The honest tension, stated rather than papered over

This ships a contract method with no caller, and it is a net addition rather than a removal. Because the contract mandates feature-detection, the local copy in current-user-endpoints.ts must stay as the fallback for a SecurityPlugin too old to carry the method — so for now the repo has the copy plus the delegation path plus a branch between them. De-duplication that adds a third path is not de-duplication yet.

It is still the right first move: step 2 is unreachable until a floor version carrying the method can be assumed, and this is what makes that floor exist. Nothing about this PR changes behaviour today — it is not a fix, and the report on #7616 says so plainly rather than claiming one.

Gates (measured)

gate result
pnpm lint (repo-wide, required) clean
pnpm --filter @objectstack/spec typecheck clean (tsc + scripts + test layer)
pnpm --filter @objectstack/plugin-security typecheck clean
pnpm --filter @objectstack/spec check:generated 13 / 13 up to date — no artifact needed regenerating
check:exported-any 2409 types + 1509 schemas, 0 resolve to any
check:dual-source-exports 4842 names, 0 new dual-source
check:test-typecheck (spec, exact ratchet) 265 errors / 57 files — unchanged, ledger untouched
pnpm check:type-check-coverage 63/77 packages, unchanged
check:service-providers, check:init-service-contract, check:authz-resolver, check:role-word, check:adr-anchors, check:tenant-chokepoint, check:nul-bytes, check:empty-changeset all green
pnpm --filter @objectstack/plugin-security test 48 files / 978 tests pass
packages/spec contract test 14 pass

No gate objected to the unused method. check:liveness governs authorable metadata-type properties against the registry, not contracts/ interfaces, and it is green. No ratchet ledger was raised — the one exact ratchet in the blast radius (test-typecheck-debt.json) came out byte-identical.

Two findings, reported not fixed

  1. packages/spec/src/contracts/security-service.test.ts's "exhaustive" method list is not exhaustive, on main as well as here. hasWriteBypass, resolveWriteScope and describeDelegableScope are required on ISecurityService and appear in neither the assertion list nor makeService()'s literal — which is why that file carries exactly 1 held error in test-typecheck-debt.json (TS2322 at line 21: the stub is not assignable to ISecurityService). The compile-time half of that test is currently being asserted past a suppressed error. Adding an optional method changes neither the runtime assertion nor the error count, so nothing here goes red — but "the suite goes red if you don't update the list" is not true today, and the reason it isn't is a pre-existing defect. Left alone deliberately: fixing it takes the ledger 1 → 0 and belongs to whoever owns those three methods, not to a contract addition.
  2. main is red on one spec test, unrelated to this diff: src/data/api-methods-batch-conformance.test.tssys_api_key: [get, list, update] grants single-record writes but not 'bulk' (packages/platform-objects/src/identity/sys-api-key.object.ts, a file this PR does not touch and which is byte-identical to main).

Generated by Claude Code

…SETS on the security service (#7616)

`ISecurityService` could report the caller's effective permission-set NAMES
(`resolvePermissionSetNames`) and nothing else — the right primitive for an
audience check, the wrong one for a merge. A consumer folding the caller's
grants into one answer needs `objects`, `fields`, `systemPermissions` and
`tabPermissions`; none of the four is reachable from a name, so
`/auth/me/permissions` and `/me/apps` each re-implement the resolution locally
instead. One rule, three copies — drifted from the enforcement path three times
(#7608, #7555, #6334), each divergence found only after it reached a user.

Adds `resolvePermissionSetsForContext(context)` to the published contract: the
same resolution the names surface reports the names of, returned whole and in
resolution order. Merge semantics stay with the caller, because two consumers
legitimately project different subsets of the same sets.

The method is OPTIONAL, and that is load-bearing: the contract's availability
rule has consumers resolve this service as `Partial<ISecurityService>`, so a
caller must keep its own resolution as the fallback until a floor version
carrying the method can be assumed. Optional makes that degradation a property
of the type — the unguarded call does not compile.

`plugin-security` exposes it on the REGISTERED SERVICE LITERAL, not merely as a
public class member. The consumers must never take a runtime dependency on this
plugin (optional in the stacks those endpoints serve), so the service locator is
the only seam that can carry the delegation, and a method the class declares but
the literal does not expose is unreachable across it. Pinned by a test that
resolves the service off `registerService` and calls through that handle;
reverse-verified (all 5 cases fail without the exposure).

Also closes one implementation gap so the declaration is true rather than
nominal: the `sys_permission_set` loader dropped `tab_permissions`, so every
DB-authored set came back without the column `/me/apps` filters its app list
with. Inert for enforcement (nothing on the data plane reads `tabPermissions`),
no extra query — the row is already fetched in full.

No behaviour change today: the method has no caller yet, by design. The call
sites are step 2 and land separately, because `/me/apps` deliberately projects a
narrower column set than `/auth/me/permissions` and delegating changes which
columns load on both surfaces — a user-visible change wanting its own before/
after measurement.

Also corrects a stale `resolveFallbackPermissionSets` doc-comment describing the
`resolved.length === 0 && fallbackName` step PR #7615 deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rK3z836HF4CjfpbJuEjaz
@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 6:20pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security, @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

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.

2 participants