From 5567e8ce923b59e8dd32a5579d1d57a122b0787b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:08:27 +0000 Subject: [PATCH 1/2] fix(spec,runtime): the slot->contract ledger extends past `CoreServiceName`, and `/security` stops passing unvalidated input to the security service (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`). Three slots — `security`, `shareLinks`, `objectql` — each had a written contract, a provider registering them, and call sites already inside the contract. The only missing link was that the slot name was not a `CoreServiceName` member, so nothing could connect them and all three sat behind `as any`. The ledger extends past the enum rather than the enum growing. The two answer different questions, and conflating them is what left these untyped: `CoreServiceName` answers "what happens at boot when this slot is empty?" — it sits beside `ServiceCriticality` and drives startup orchestration and discovery, so adding a member changes runtime behaviour and is effectively permanent. The ledger answers "what shape occupies this slot?" — pure type information. These three need only the second, so `ServiceSlotContracts extends CoreServiceContracts` adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero runtime effect. If one is later promoted to a genuine core service, its entry moves up and nothing else changes. Evidence before an entry, as always: `plugin-security` registers `security` and `ISecurityService`'s own doc names that registration; `plugin-sharing` registers `ShareLinkService`, which declares `implements IShareLinkService`; and `objectql` is an ALIAS of `data` — `packages/objectql`'s plugin registers the same instance under both names two lines apart, so one object was resolving as `IDataEngine` through one name and `any` through the other. `protocol` (22 call sites) and `mcp` have no written contract and stay unmapped. Turning it on found four things, all on the `/security` admin surface: 1. Request input reached the security service unvalidated. `?status=` was `String(query.status)` — any string — handed to a method whose contract declares exactly three values, and from there into the query's `where` clause. Not an injection (the `where` is structured, never interpolated), but `?status=garbage` matched no row and returned an empty list, which reads as "there are no suggestions" rather than "that is not a status". Now a 400. 2. A test pinned that bug as expected behaviour. The existing case asserted `status: 'open'` — not one of the three declared values — reached the service and returned 200. It proved the delegate carried A FILTER and nothing about that filter being a status. Same shape as batch 1's `auth.handler` mocks: coverage in appearance, a wrong contract in substance. 3. and 4. Two writes could not prove they had a caller. `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare `callerContext: SecurityContext` non-optionally — deliberately, since the read beside them declares it optional — and the domain passed a possibly-`undefined` execution context. This was NOT a live hole, and the distinction matters: with no execution context `shouldDenyAnonymous` already denied, because it sees no `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam never passes, so it fell through to `return true`. What it never did was narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is behaviour-preserving and makes the invariant legible to the compiler and the next reader. The `?status=` rejection is the one BEHAVIOUR CHANGE: an unknown status was a silent empty list and is now a 400 naming the accepted values. The accepted set is a `Record` keyed on the contract type, so adding a status to the contract leaves a key missing and renaming one leaves a key excess — either fails to compile, where a plain array would have drifted silently. Refs #4127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw --- .../slot-contract-ledger-beyond-the-enum.md | 73 +++++++++++++++++++ .../src/domain-handler-registry.test.ts | 39 +++++++++- .../runtime/src/domain-handler-registry.ts | 26 ++++--- packages/runtime/src/domains/packages.ts | 9 +-- packages/runtime/src/domains/security.ts | 60 ++++++++++++++- packages/runtime/src/domains/share-links.ts | 5 +- packages/spec/api-surface.json | 2 + .../contracts/core-service-contracts.test.ts | 52 ++++++++++++- .../src/contracts/core-service-contracts.ts | 56 ++++++++++++++ 9 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 .changeset/slot-contract-ledger-beyond-the-enum.md diff --git a/.changeset/slot-contract-ledger-beyond-the-enum.md b/.changeset/slot-contract-ledger-beyond-the-enum.md new file mode 100644 index 0000000000..3b3908b5fb --- /dev/null +++ b/.changeset/slot-contract-ledger-beyond-the-enum.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": patch +--- + +fix(spec,runtime): the slot→contract ledger extends past `CoreServiceName`, and `/security` stops passing unvalidated input to the security service (#4127) + +Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`). + +Three slots — `security`, `shareLinks`, `objectql` — each had a written +contract, a provider registering them, and call sites already inside the +contract. The only missing link was that the slot name was not a +`CoreServiceName` member, so nothing could connect them and all three sat behind +`as any`. + +**The ledger extends past the enum rather than the enum growing.** The two +answer different questions, and conflating them is what left these untyped: +`CoreServiceName` answers *"what happens at boot when this slot is empty?"* — it +sits beside `ServiceCriticality` and drives startup orchestration and discovery, +so adding a member changes runtime behaviour and is effectively permanent. The +ledger answers *"what shape occupies this slot?"* — pure type information. These +three need only the second, so `ServiceSlotContracts extends CoreServiceContracts` +adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero +runtime effect. If one is later promoted to a genuine core service, its entry +moves up and nothing else changes. + +Evidence, as always, before an entry: `plugin-security` registers `security` and +`ISecurityService`'s own doc names that registration; `plugin-sharing` registers +`ShareLinkService`, which declares `implements IShareLinkService`; and `objectql` +is an **alias of `data`** — `packages/objectql`'s plugin registers the *same +instance* under both names two lines apart, so one object was resolving as +`IDataEngine` through one name and `any` through the other. `protocol` (22 call +sites) and `mcp` have no written contract and stay unmapped. + +**Turning it on found four things, all on the `/security` admin surface:** + +1. **Request input reached the security service unvalidated.** `?status=` was + `String(query.status)` — any string — handed to a method whose contract + declares exactly three values, and from there into the query's `where` + clause. Not an injection (the `where` is structured, never interpolated), but + `?status=garbage` matched no row and returned an empty list, which reads as + "there are no suggestions" rather than "that is not a status". Now a 400. + +2. **A test pinned that bug as expected behaviour.** The existing case asserted + `status: 'open'` — not one of the three declared values — reached the service + and returned 200. It proved the delegate carried *a filter* and nothing about + that filter being a status. Same shape as batch 1's `auth.handler` mocks: + coverage in appearance, a wrong contract in substance. + +3. **and 4. Two writes could not prove they had a caller.** + `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare + `callerContext: SecurityContext` non-optionally — deliberately, since the + read beside them declares it optional — and the domain passed a possibly- + `undefined` execution context. + + **This was not a live hole**, and the distinction matters: with no execution + context `shouldDenyAnonymous` already denied, because it sees no + `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam + never passes, so it fell through to `return true`. What it never did was + narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is + behaviour-preserving and makes the invariant legible to the compiler and the + next reader. + +The `?status=` rejection is the one **behaviour change**: an unknown status was +a silent empty list and is now a 400 naming the accepted values. The accepted +set is a `Record` keyed on the contract type, so adding a status to the contract +leaves a key missing and renaming one leaves a key excess — either fails to +compile, where a plain array would have drifted silently. + +Verified: `@objectstack/runtime` **945 tests / 66 files** (+8), `@objectstack/spec` +**7141 / 274** (+29), plugin-security **677**, plugin-sharing **225**; +`tsc --noEmit` on spec, runtime, downstream-contract and all four examples; +`pnpm lint`; all nine `check:*` gates. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 3128210dfb..a4136063fe 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -227,12 +227,49 @@ describe('HttpDispatcher extracted domains (PR-2)', () => { dismissAudienceBindingSuggestion: vi.fn(), }; // Direct delegate call for the same reason as the notifications case. + // + // [#4127 batch 3] This asserted `status: 'open'` — not one of the three + // values `AudienceBindingSuggestionFilter` declares. The test pinned the + // unvalidated pass-through as EXPECTED, so it proved the delegate + // carried a filter through and nothing about that filter being a status. + // Same shape as the `auth.handler` mock in batch 1: coverage in + // appearance, a wrong contract in substance. Now a real status. + const context: any = { executionContext: { userId: 'admin-1' } }; + const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'pending' }, context); + expect(result.response?.status).toBe(200); + expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'admin-1' }), + expect.objectContaining({ status: 'pending' }), + ); + }); + + it('/security rejects a status filter that is not a declared status, without calling the service', async () => { + const security = { + listAudienceBindingSuggestions: vi.fn().mockResolvedValue([{ id: 's1' }]), + confirmAudienceBindingSuggestion: vi.fn(), + dismissAudienceBindingSuggestion: vi.fn(), + }; const context: any = { executionContext: { userId: 'admin-1' } }; const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'open' }, context); + // Previously this reached the service and became `where.status = 'open'`, + // which matched no row — an empty list that reads as "no suggestions" + // rather than "that is not a status". + expect(result.response?.status).toBe(400); + expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled(); + }); + + it('/security omits the status filter entirely when the query has none', async () => { + const security = { + listAudienceBindingSuggestions: vi.fn().mockResolvedValue([]), + confirmAudienceBindingSuggestion: vi.fn(), + dismissAudienceBindingSuggestion: vi.fn(), + }; + const context: any = { executionContext: { userId: 'admin-1' } }; + const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, {}, context); expect(result.response?.status).toBe(200); expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith( expect.objectContaining({ userId: 'admin-1' }), - expect.objectContaining({ status: 'open' }), + expect.objectContaining({ status: undefined }), ); }); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 77aa811d44..212f98edec 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -35,7 +35,7 @@ import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js'; import type { CoreServiceName } from '@objectstack/spec/system'; -import type { CoreServiceContract } from '@objectstack/spec/contracts'; +import type { CoreServiceContract, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; /** * The normalized request slice a domain handler receives. `path` is the @@ -86,18 +86,24 @@ export interface DomainHandlerDeps { * its call sites already passed a `CoreServiceName`. This one is mixed, and * the overloads split it exactly where the evidence does: * - * - A **`CoreServiceName`** — however it is written. 17 call sites address a + * - An **evidenced slot** — however it is written. 17 call sites address a * core slot with a bare literal (`'metadata'` ×10, `'automation'` ×3, * `'auth'` ×3, `'ai'`) rather than `CoreServiceName.enum.*`, so the same - * slot was being addressed two ways; both resolve to the contract now. - * - **Anything else** — `protocol`, `objectql`, `mcp`, `kernel-resolver`, - * `security`, `scope-manager`. These are real services with no - * `CoreServiceName` entry and no written contract, so they keep today's - * `any` rather than being given a shape here that nothing verifies. - * Writing their contracts is the next batch; until then the `any` marks - * where the ledger ends. + * slot was being addressed two ways; both resolve to the contract. + * - **Anything else** — `protocol`, `mcp`, `kernel-resolver`, + * `scope-manager`. Real services with no written contract, so they keep + * today's `any` rather than being given a shape here that nothing + * verifies. That `any` is where the ledger honestly ends. + * + * [batch 3] The key is `keyof ServiceSlotContracts`, not `CoreServiceName`. + * `security`, `shareLinks` and `objectql` each had a contract, a provider + * registering them, and call sites already within the contract — the only + * missing link was that the slot name was not in the enum, so nothing could + * connect them. Widening the *enum* to fix that would have paid for a type + * answer with a change to the boot/criticality vocabulary; the ledger + * extends past the enum instead. See {@link ServiceSlotContracts}. */ - resolveService(name: K, environmentId?: string): Promise | undefined>; + resolveService(name: K, environmentId?: string): Promise | undefined>; resolveService(name: string, environmentId?: string): any; /** * Unscoped service lookup on the current kernel, typed by the slot. diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 578d49b28e..b9670cebba 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -623,13 +623,12 @@ names: string[], organizationId: string | undefined, _context: HttpProtocolContext, ): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string }> { + // [#4127] `protocol` keeps its `any` — no written contract, so this is where + // the ledger honestly ends. `metadata` and `ql` are both evidenced now, + // `objectql` as of batch 3: it is the same instance the `data` slot holds. const protocol: any = await deps.resolveService('protocol'); - // [#4127] `metadata` was annotated `: any`, which erased the slot type even - // after the lookup started returning `IMetadataService`. `protocol` and - // `ql` keep theirs: neither is a `CoreServiceName` slot and neither has a - // written contract, so their `any` is where the ledger honestly ends. const metadata = await deps.getService(CoreServiceName.enum.metadata); - const ql: any = await deps.resolveService('objectql'); + const ql = await deps.resolveService('objectql'); if (!protocol || typeof protocol.getMetaItem !== 'function' || !ql || !metadata) { return { success: false, error: 'seed apply: required services unavailable' }; } diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 7738ea5e58..8aa046b7e6 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -23,9 +23,29 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; +import type { AudienceBindingSuggestionFilter } from '@objectstack/spec/contracts'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +type SuggestionStatus = NonNullable; + +/** + * [#4127 batch 3] The accepted `?status=` values, keyed BY the contract type so + * the correspondence is mechanical: adding a status to + * `AudienceBindingSuggestionFilter` leaves a key missing here and renaming one + * leaves a key excess, and either way this fails to compile. A plain + * `['pending', …]` array would have silently drifted, which is the failure mode + * this whole work line exists to remove. + */ +const SUGGESTION_STATUSES: Record = { + pending: true, + confirmed: true, + dismissed: true, +}; + +const isSuggestionStatus = (value: string): value is SuggestionStatus => + Object.prototype.hasOwnProperty.call(SUGGESTION_STATUSES, value); + export function createSecurityDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/security', @@ -44,7 +64,11 @@ export async function handleSecurityRequest( query: any, context: HttpProtocolContext, ): Promise { - const service = await deps.resolveService('security', context.environmentId) as any; + // [#4127 batch 3] The `as any` was the only thing between this call and + // `ISecurityService`. The contract was written, `plugin-security` registers + // the slot, and all three methods used below were already declared — the + // slot name simply was not in the ledger, so nothing connected them. + const service = await deps.resolveService('security', context.environmentId); if (!service || typeof service.listAudienceBindingSuggestions !== 'function') { return { handled: true, response: deps.error('Security service not available', 503) }; } @@ -54,7 +78,18 @@ export async function handleSecurityRequest( // even before the opt-out was retired this seam never honoured it, so an // anonymous caller could never list or confirm audience bindings. Shares // the decision + body with every other HTTP seam. - if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) { + // + // [#4127 batch 3] The `!ec` arm is BEHAVIOUR-PRESERVING, not a new gate: with + // no execution context, `shouldDenyAnonymous` already denied — it sees no + // `userId`/`isSystem`, and its allowlist arm needs a non-empty `path` this + // seam never passes, so it fell through to `return true`. What it did not do + // is narrow `ec` itself, because it only ever read `ec?.userId`. So the + // contract's requirement — `confirmAudienceBindingSuggestion(callerContext: + // SecurityContext, …)`, non-optional precisely because a WRITE needs a + // caller identity, unlike the optional one on the read — could not be seen + // to hold even though it did. Checking `ec` directly makes the invariant + // legible to the compiler and to the next reader. + if (!ec || shouldDenyAnonymous({ userId: ec.userId, isSystem: ec.isSystem })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), @@ -70,9 +105,26 @@ export async function handleSecurityRequest( try { // GET /security/suggested-bindings if (parts.length === 1 && m === 'GET') { - const status = query?.status ? String(query.status) : undefined; + // [#4127 batch 3] `status` was `String(query.status)` — any request + // string, handed to a service whose contract declares exactly three + // values, and from there straight into the query's `where` clause. + // Not an injection (the `where` is structured, never interpolated), + // but `?status=garbage` matched no row and returned an empty list, + // which reads as "there are no suggestions" rather than "your filter + // was not a status". Rejecting is the honest answer, and the only + // one that keeps the call inside the contract. + const rawStatus = query?.status ? String(query.status) : undefined; + if (rawStatus !== undefined && !isSuggestionStatus(rawStatus)) { + return { + handled: true, + response: deps.error( + `Unknown status filter '${rawStatus}' — expected one of: ${Object.keys(SUGGESTION_STATUSES).join(', ')}`, + 400, + ), + }; + } const packageId = query?.packageId ? String(query.packageId) : undefined; - const result = await service.listAudienceBindingSuggestions(ec, { status, packageId }); + const result = await service.listAudienceBindingSuggestions(ec, { status: rawStatus, packageId }); return { handled: true, response: deps.success(result) }; } diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index 3803c185b9..6cb0e192fd 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -56,7 +56,10 @@ export async function handleShareLinksRequest( query: any, context: HttpProtocolContext, ): Promise { - const svc: any = await deps.resolveService('shareLinks', context.environmentId); + // [#4127 batch 3] `plugin-sharing` registers `ShareLinkService`, which + // declares `implements IShareLinkService`; the four methods called below + // were all already on that contract. Only the ledger entry was missing. + const svc = await deps.resolveService('shareLinks', context.environmentId); if (!svc) { return { handled: true, response: deps.error('Sharing is not configured for this environment', 501) }; } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..c8d80df444 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3764,6 +3764,8 @@ "SendSmsInput (interface)", "SendSmsResult (interface)", "SendTemplateInput (interface)", + "ServiceSlotContract (type)", + "ServiceSlotContracts (interface)", "ShareAccessLevel (type)", "ShareLink (interface)", "ShareLinkAudience (type)", diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts index da8ed468f4..de4654a00b 100644 --- a/packages/spec/src/contracts/core-service-contracts.test.ts +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -8,10 +8,15 @@ import { describe, it, expect } from 'vitest'; import { CoreServiceName } from '../system/core-services.zod'; -import type { CoreServiceContracts, CoreServiceContract } from './core-service-contracts'; +import type { + CoreServiceContracts, CoreServiceContract, ServiceSlotContracts, ServiceSlotContract, +} from './core-service-contracts'; import type { IAutomationService } from './automation-service'; import type { INotificationService } from './notification-service'; import type { II18nService } from './i18n-service'; +import type { IDataEngine } from './data-engine'; +import type { ISecurityService } from './security-service'; +import type { IShareLinkService } from './share-link-service'; /** Compile-time assertion helper: fails to typecheck unless `T` is exactly `true`. */ type Expect = T; @@ -59,3 +64,48 @@ describe('CoreServiceName → contract map (#4127)', () => { expect(true).toBe(true); }); }); + +describe('slot → contract ledger beyond the enum (#4127 batch 3)', () => { + it('keeps every core binding', () => { + // The extension must not shadow or drop a core entry — `data` staying + // `IDataEngine` is what makes the `objectql` alias below meaningful. + type _Data = Expect, IDataEngine>>; + type _Automation = Expect, IAutomationService>>; + expect(true).toBe(true); + }); + + it('maps the evidenced non-core slots', () => { + type _Security = Expect, ISecurityService>>; + type _ShareLinks = Expect, IShareLinkService>>; + expect(true).toBe(true); + }); + + it('resolves objectql to the same contract as data, because it is the same instance', () => { + // `packages/objectql`'s plugin registers `this.ql` under both names two + // lines apart. Anything else here would claim they are two services. + type _Alias = Expect, ServiceSlotContract<'data'>>>; + expect(true).toBe(true); + }); + + it('holds exactly the non-core slots whose contract is written', () => { + const extra: Array> = [ + 'security', 'shareLinks', 'objectql', + ]; + const slots = new Set(CoreServiceName.options); + for (const key of extra) { + // The point of the split: these are deliberately NOT enum members. + // If one is ever promoted to a real core service, its entry moves + // into `CoreServiceContracts` and this assertion is what catches it. + expect(slots.has(key), `'${key}' is a CoreServiceName — move its entry into CoreServiceContracts`).toBe(false); + } + expect(extra).toHaveLength(3); + }); + + it('still resolves a slot with no written contract to unknown', () => { + // `protocol` (22 call sites) and `mcp` have no contract, so they stay + // out of the ledger and keep reading as open gaps. + type _Protocol = Expect, unknown>>; + type _Mcp = Expect, unknown>>; + expect(true).toBe(true); + }); +}); diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts index b51620ad2f..93264a3c67 100644 --- a/packages/spec/src/contracts/core-service-contracts.ts +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -38,6 +38,8 @@ import type { INotificationService } from './notification-service'; import type { IAIService } from './ai-service'; import type { II18nService } from './i18n-service'; import type { IWorkflowService } from './workflow-service'; +import type { ISecurityService } from './security-service'; +import type { IShareLinkService } from './share-link-service'; /** * The evidenced slot → contract bindings. @@ -86,3 +88,57 @@ export interface CoreServiceContracts { */ export type CoreServiceContract = K extends keyof CoreServiceContracts ? CoreServiceContracts[K] : unknown; + +/** + * Every evidenced slot → contract binding, including slots that are **not** + * `CoreServiceName` members. + * + * [#4127 batch 3] `CoreServiceName` and this ledger answer two different + * questions, and conflating them is what left these slots untyped: + * + * - **`CoreServiceName`** answers *"what happens at boot when this slot is + * empty?"* — it sits beside `ServiceCriticality` and drives startup + * orchestration and discovery. Adding a member changes runtime behaviour. + * - **This ledger** answers *"what shape is whatever occupies this slot?"* — + * pure type information, zero runtime effect. + * + * `security`, `shareLinks` and `objectql` need only the second. Widening the + * enum to type them would pay for a type answer with a runtime-vocabulary + * change, and enum members are effectively permanent once added. So the ledger + * extends past the enum instead, and each side keeps its own meaning. + * + * This is not a permanent split: if one of these is later promoted to a genuine + * core service — criticality semantics and all — its entry moves up into + * {@link CoreServiceContracts} and nothing else changes. + * + * The evidence bar is unchanged from {@link CoreServiceContracts}: an entry is a + * claim, made only where the binding is established. + */ +export interface ServiceSlotContracts extends CoreServiceContracts { + /** + * `plugin-security` registers it (`security-plugin.ts`), and + * `ISecurityService`'s own doc names the registration — `ctx.registerService('security', …)`. + * The contract, the provider and the three methods `domains/security.ts` + * calls all already agreed; only the binding was unwritten. + */ + security: ISecurityService; + /** `plugin-sharing` registers `ShareLinkService`, which declares `implements IShareLinkService`. */ + shareLinks: IShareLinkService; + /** + * An **alias of `data`**, not a second service: `packages/objectql`'s plugin + * registers the *same instance* under both names, two lines apart — + * `ctx.registerService('objectql', this.ql)` and + * `ctx.registerService('data', this.ql) // ObjectQL implements IDataEngine`. + * `data` resolved to `IDataEngine` and `objectql` to `any`, for one object. + */ + objectql: IDataEngine; +} + +/** + * The contract for any evidenced slot `K` — core or not — or `unknown` when no + * binding has been evidenced. Same deliberate `unknown` as + * {@link CoreServiceContract}: `protocol` and `mcp` have no written contract, so + * they stay unmapped and visible rather than being given a shape nothing checks. + */ +export type ServiceSlotContract = + K extends keyof ServiceSlotContracts ? ServiceSlotContracts[K] : unknown; From cb0903db579d70be527521ece01e4cbdabe4d8c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:10:38 +0000 Subject: [PATCH 2/2] docs(permissions): document the accepted `status` filter values on /security/suggested-bindings (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 400 introduced alongside the slot-contract ledger is observable API behaviour, and this page is where the endpoint is documented. Both existing examples already used valid statuses, so nothing here was invalidated — what was missing is that the filter has a fixed set at all, and what an unknown value now does instead of quietly returning an empty list. Refs #4127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw --- content/docs/permissions/permission-sets.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 37a8187156..ac8ad8deef 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -204,6 +204,11 @@ POST /api/v1/security/suggested-bindings/:id/confirm # create the everyone POST /api/v1/security/suggested-bindings/:id/dismiss # decline the prompt ``` +`status` accepts `pending`, `confirmed` or `dismissed`, and may be omitted to +list every suggestion. Any other value is rejected with a `400` naming the +accepted set — it previously reached the query as a filter nothing matched, so +a mistyped status returned an empty list that read as "no suggestions". + All three require a tenant-level administrator (the anchors are tenant-level only — D12). Confirm writes the `sys_position_permission_set` row **as the caller**, so the audience-anchor gate re-checks the forbidden-bits predicate