diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3eccac663..4d2003662 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,9 +51,11 @@ The credential PostgreSQL adapter requires canonical persisted UUIDv4 identity, The Plugin-owned Vault adapter uses one operator-configured canonical HTTPS HashiCorp Vault origin and KV v2 mount. One credential-binding UUID maps to one deterministic opaque `lifeos-plugin-vault://` reference and one Vault path. Creation uses KV v2 `cas: 0`; a lost/ambiguous completion or concurrent CAS loser is accepted as replay only after the durable Vault winner is read back and its canonical binding/installation/workspace/installer/name evidence and secret bytes exactly match the attempted write. A different secret for the same binding fails closed instead of overwriting provider state. Redirects are rejected, requests have a finite deadline, replay responses are size-bounded, caller and Vault-returned binding/installation/workspace/user UUIDs must already be canonical lowercase evidence, and external failures expose only the fixed Plugin secret-storage error. The adapter never normalizes an authority identity before selecting a Vault path or accepting replay evidence. This adapter owns no LifeOS database table and copies no Calendar secret-store implementation. +The authenticated Plugin application composition reads only `INTEGRATION_OPERATOR_CONTEXT_SECRET` and the service-owned `INTEGRATION_PLUGIN_VAULT_ORIGIN`, `INTEGRATION_PLUGIN_VAULT_TOKEN`, and `INTEGRATION_PLUGIN_VAULT_MOUNT` configuration names. Generic `PLUGIN_VAULT_*` aliases are not authority. It creates the Vault adapter only behind the existing Integration-owned installation, credential-metadata, and durable replay ports, so signed workspace/user authority and one-time replay consumption remain upstream of credential materialization. Composition failures collapse to one credential-free configuration error rather than reflecting verifier/Vault values. + The integration service separately owns `life-os.plugin-delivery-origin.v1` grants that bind one opaque grant ID to an exact installation, workspace, granting user, and normalized HTTPS origin. Grant creation requires active host-owned installation evidence. PostgreSQL grant admission rechecks that lifecycle at INSERT and serializes it against installation revocation. Every read that would expose an active origin grant re-resolves exact installation/workspace/user authority and requires the installation to remain active, so installation revocation fences future use of the origin grant. Revoked grant records remain readable as bounded lifecycle/audit evidence inside their original scope. Grant and installation chronology must also be internally consistent. -Neither credential binding nor origin-grant persistence performs outbound plugin delivery. The concrete Vault adapter is an active implementation slice, but hosted credential materialization is not production-complete until authenticated Plugin operator composition supplies its Vault origin/token/mount through operator-owned secret configuration, preserves fail-closed startup/runtime behavior when that dependency is unavailable, and proves a real Vault integration path. Outbound plugin delivery additionally requires separately reviewed DNS/IP rebinding resistance, connect-time address enforcement, redirect/proxy policy, bounded request/response sizes and phase/total deadlines, retry/dead-letter behavior, revocation fencing, and durable delivery outcome evidence. An origin grant or opaque secret reference alone never implies those capabilities. +Neither credential binding nor origin-grant persistence performs outbound plugin delivery. The Vault adapter and authenticated application composition are active implementation slices, but hosted credential materialization is not production-complete until one Integration-owned runtime constructs the required PostgreSQL installation/binding/replay ports, registers the composed operator before the listener starts, fails closed when its PostgreSQL/Vault/operator dependencies are unavailable, closes its owned pool exactly once, and proves the composed path against a real Vault. Outbound plugin delivery additionally requires separately reviewed DNS/IP rebinding resistance, connect-time address enforcement, redirect/proxy policy, bounded request/response sizes and phase/total deadlines, retry/dead-letter behavior, revocation fencing, and durable delivery outcome evidence. An origin grant or opaque secret reference alone never implies those capabilities. ## 2. AI proposal safety boundary @@ -142,4 +144,4 @@ The pinned OpenCode configuration disables project-local overrides, explicitly r 7. `docs/research/` — standards and research rationale with APA 7 references. 8. `CHANGELOG.md` — user-visible unreleased and released changes. -A behavior or boundary change is incomplete until the relevant level is updated and executable tests prove the claim. +A behavior or boundary change is incomplete until the relevant level is updated and executable tests prove the claim. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e2a33b9..40d0bc130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to LifeOS are documented in this file. ### Added +- An authenticated Plugin Vault operator composition that keeps signed workspace/user authority and durable replay consumption upstream of secret materialization, uses only Integration-owned installation/credential/replay ports, and reads Vault origin/token/mount from service-owned `INTEGRATION_PLUGIN_VAULT_*` configuration without making the composition factory a hosted PostgreSQL owner. - A Plugin-owned HashiCorp Vault KV v2 secret-store adapter that binds one credential UUID to one opaque reference/path, uses create-only CAS for concurrent/retry safety, accepts replay only after exact durable authority-and-secret comparison, rejects redirects, bounds transport deadlines/replay response size, and never stores Vault credentials or provider plaintext in LifeOS persistence. - Durable PostgreSQL plugin-installation authority with opaque UUIDv4 installation/workspace/installer identity, exact manifest digests, normalized explicit grants, bounded conflict replay, and atomic revocation evidence in the service-owned `plugin_integration` schema. - A host-owned `life-os.plugin-delivery-origin.v1` authority boundary that accepts only exact normalized HTTPS origins after active installation evidence, scopes grant lifecycle to installation/workspace/granting-user identity, revalidates current active installation authority before exposing an active grant so installation revocation fences future origin use, and keeps manifest intent separate from network authority. @@ -34,6 +35,7 @@ All notable changes to LifeOS are documented in this file. ### Fixed +- Plugin Vault operator configuration no longer accepts generic `PLUGIN_VAULT_*` aliases as credential authority; origin, token, and mount must come from the Integration service-owned configuration namespace and malformed/incomplete composition still fails through one credential-free error. - Plugin credential binding now validates command/context envelopes, server-clock evidence, persistence result envelopes, canonical durable UUID/timestamp/lifecycle evidence, and exact absence sentinels before they become authority; malformed durable reads cannot cause fresh secret materialization, malformed create winners trigger secret compensation instead of leaving provider material silently orphaned, and credential/install chronology is fenced so future or pre-installation bindings and future-dated revocation replays cannot become current authority or trigger provider deletion. - The public Gateway Today endpoint now fails explicitly with bounded `today_composition_unavailable` problem details instead of returning fabricated successful composition data while authenticated Planning/Habit integration is absent; issue #163 remains open for the real composition path. - Data-rights request-ID and idempotency collisions now resolve through stable credential-free domain conflicts instead of exposing raw PostgreSQL uniqueness errors, including ambiguous dual-collision evidence. @@ -62,4 +64,4 @@ All notable changes to LifeOS are documented in this file. - Planning-search upstream responses are stopped at a fixed byte limit before they can be fully buffered by the web boundary. - Notification persistence stores SHA-256 idempotency digests instead of raw delivery keys, validates every untrusted row, and keeps all SQL tenant-scoped and parameterized. - The AI production boundary rejects direct client-selected ownership headers, verifies a short-lived HMAC-SHA-256 context bound to workspace, actor, HTTP method, and exact path, returns credential-free problem details, and exposes no proposal apply or execution route. -- The AI web boundary consumes and bounds identity-session response streams exactly once, avoiding unbounded buffering from cloning an untrusted streamed response. +- The AI web boundary consumes and bounds identity-session response streams exactly once, avoiding unbounded buffering from cloning an untrusted streamed response. \ No newline at end of file diff --git a/apps/integration-service/src/plugin-vault-operator-composition.test.ts b/apps/integration-service/src/plugin-vault-operator-composition.test.ts new file mode 100644 index 000000000..90558ad88 --- /dev/null +++ b/apps/integration-service/src/plugin-vault-operator-composition.test.ts @@ -0,0 +1,187 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { + PluginCredentialBindingRecord, + PluginCredentialBindingStore, +} from './plugin-credential'; +import type { + PluginInstallationContext, + PluginInstallationRecord, +} from './plugin-installation'; +import type { PluginInstallationOperatorPort } from './plugin-operator-application'; +import type { PluginOperatorReplayGuardPort } from './plugin-operator-replay'; +import type { + PluginVaultHttpClient, + PluginVaultHttpResponse, +} from './plugin-vault-secret-store'; +import { + createPluginVaultOperatorApplication, + PluginVaultOperatorCompositionError, +} from './plugin-vault-operator-composition'; + +const WORKSPACE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const INSTALLATION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const BINDING_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const EVIDENCE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const CONTEXT_SECRET = 'operator-context-fixture-value-32-bytes-minimum'; +const VAULT_TOKEN = 'vault-fixture-token-value'; +const ISSUED_AT = 1_700_000_000; + +const INSTALLATION: PluginInstallationRecord = Object.freeze({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + pluginId: 'example.plugin', + pluginContractVersion: '1.0.0', + manifestSha256: 'a'.repeat(64), + grantedCapabilities: Object.freeze(['event.task.completed']), + status: 'active', + installedAt: '2023-11-14T22:13:19.000Z', + revokedAt: null, +}); + +function response(status: number): PluginVaultHttpResponse { + return { + status, + headers: { get: () => null }, + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }; +} + +class InstallationPort implements PluginInstallationOperatorPort { + async install(): Promise { + return INSTALLATION; + } + + async getInstallation( + context: PluginInstallationContext, + installationId: string, + ): Promise { + return context.workspaceId === WORKSPACE_ID && + context.actorUserId === USER_ID && + installationId === INSTALLATION_ID + ? INSTALLATION + : undefined; + } + + async revoke(): Promise { + return Object.freeze({ + ...INSTALLATION, + status: 'revoked', + revokedAt: '2023-11-14T22:13:21.000Z', + }); + } +} + +class BindingStore implements PluginCredentialBindingStore { + private record: PluginCredentialBindingRecord | undefined; + + async findById(): Promise { + return this.record; + } + + async createIfAbsent( + record: PluginCredentialBindingRecord, + ): Promise { + this.record ??= record; + return this.record; + } + + async revokeActive(): Promise { + return this.record; + } +} + +function environment(): Readonly> { + return Object.freeze({ + INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET, + INTEGRATION_PLUGIN_VAULT_ORIGIN: 'https://vault.example.test', + INTEGRATION_PLUGIN_VAULT_TOKEN: VAULT_TOKEN, + INTEGRATION_PLUGIN_VAULT_MOUNT: 'secret', + }); +} + +function signedHeaders(): Readonly<{ + workspaceId: string; + userId: string; + evidenceId: string; + issuedAt: string; + signature: string; +}> { + const issuedAt = String(ISSUED_AT); + const signature = createHmac('sha256', CONTEXT_SECRET) + .update( + `life-os.integration-operator-context.v1\n${WORKSPACE_ID}\n${USER_ID}\n${EVIDENCE_ID}\n${issuedAt}\nPOST\n/v1/plugins/credential-bindings`, + 'utf8', + ) + .digest('base64url'); + return Object.freeze({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + evidenceId: EVIDENCE_ID, + issuedAt, + signature, + }); +} + +describe('Plugin Vault operator composition', () => { + it('fails closed at composition when operator-owned Vault configuration is incomplete', () => { + const installations = new InstallationPort(); + const bindingStore = new BindingStore(); + const replayGuard: PluginOperatorReplayGuardPort = { + consume: vi.fn(async () => true), + }; + + expect(() => + createPluginVaultOperatorApplication( + { installations, bindingStore, replayGuard }, + { + INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET, + INTEGRATION_PLUGIN_VAULT_ORIGIN: 'https://vault.example.test', + INTEGRATION_PLUGIN_VAULT_TOKEN: VAULT_TOKEN, + }, + ), + ).toThrow(PluginVaultOperatorCompositionError); + }); + + it('binds credential authority through the composed Vault store after signed operator verification', async () => { + const installations = new InstallationPort(); + const bindingStore = new BindingStore(); + const replayGuard: PluginOperatorReplayGuardPort = { + consume: vi.fn(async () => true), + }; + const http = vi.fn().mockResolvedValue(response(200)); + const operator = createPluginVaultOperatorApplication( + { installations, bindingStore, replayGuard }, + environment(), + http, + () => ISSUED_AT, + () => new Date('2023-11-14T22:13:20.000Z'), + ); + + const result = await operator.bindCredential(signedHeaders(), { + credentialBindingId: BINDING_ID, + installationId: INSTALLATION_ID, + credentialName: 'webhook.signing', + secretValue: 'buyer secret fixture', + }); + + expect(result).toEqual({ + credentialBindingId: BINDING_ID, + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + credentialName: 'webhook.signing', + status: 'active', + boundAt: '2023-11-14T22:13:20.000Z', + revokedAt: null, + }); + expect(http).toHaveBeenCalledTimes(1); + expect(replayGuard.consume).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/integration-service/src/plugin-vault-operator-composition.ts b/apps/integration-service/src/plugin-vault-operator-composition.ts new file mode 100644 index 000000000..bd201636a --- /dev/null +++ b/apps/integration-service/src/plugin-vault-operator-composition.ts @@ -0,0 +1,193 @@ +import { + PluginCredentialApplication, + type PluginCredentialBindingStore, +} from './plugin-credential'; +import { + PluginOperatorApplication, + type PluginInstallationOperatorPort, +} from './plugin-operator-application'; +import type { PluginOperatorReplayGuardPort } from './plugin-operator-replay'; +import { + PluginVaultSecretStore, + type PluginVaultHttpClient, +} from './plugin-vault-secret-store'; + +const MINIMUM_OPERATOR_CONTEXT_SECRET_BYTES = 32; +const MAXIMUM_OPERATOR_CONTEXT_SECRET_BYTES = 8_192; +const MAXIMUM_CONFIGURATION_LENGTH = 8_192; + +/** Operator-owned environment values accepted by the Plugin production composition boundary. */ +export type PluginVaultOperatorEnvironment = Readonly< + Record +>; + +/** Durable Plugin authorities that remain owned by Integration persistence/application layers. */ +export interface PluginVaultOperatorDependencies { + readonly installations: PluginInstallationOperatorPort; + readonly bindingStore: PluginCredentialBindingStore; + readonly replayGuard: PluginOperatorReplayGuardPort; +} + +/** Fixed startup/composition failure that never reflects Vault or verifier secret configuration. */ +export class PluginVaultOperatorCompositionError extends Error { + /** Creates the only configuration failure exposed by this composition boundary. */ + constructor() { + super('Plugin operator runtime configuration is unavailable'); + this.name = 'PluginVaultOperatorCompositionError'; + } +} + +/** Terminates composition without reflecting malformed configuration or dependency values. */ +function unavailable(): never { + throw new PluginVaultOperatorCompositionError(); +} + +/** Requires a bounded environment mapping before any configuration field is read. */ +function requireEnvironment(value: unknown): PluginVaultOperatorEnvironment { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return unavailable(); + } + return value as PluginVaultOperatorEnvironment; +} + +/** Reads one mandatory bounded operator-owned setting without trimming or echoing its value. */ +function requireConfiguration( + environment: PluginVaultOperatorEnvironment, + name: string, +): string { + const value = environment[name]; + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAXIMUM_CONFIGURATION_LENGTH + ) { + return unavailable(); + } + return value; +} + +/** Validates the signed-operator verifier key early so hosted composition cannot start half-authorized. */ +function requireOperatorContextSecret(value: string): string { + const bytes = Buffer.byteLength(value, 'utf8'); + if ( + bytes < MINIMUM_OPERATOR_CONTEXT_SECRET_BYTES || + bytes > MAXIMUM_OPERATOR_CONTEXT_SECRET_BYTES + ) { + return unavailable(); + } + return value; +} + +/** Requires the installation port methods needed by both operator and credential authority. */ +function requireInstallations( + value: unknown, +): PluginInstallationOperatorPort { + if ( + value === null || + typeof value !== 'object' || + typeof (value as PluginInstallationOperatorPort).install !== 'function' || + typeof (value as PluginInstallationOperatorPort).getInstallation !== 'function' || + typeof (value as PluginInstallationOperatorPort).revoke !== 'function' + ) { + return unavailable(); + } + return value as PluginInstallationOperatorPort; +} + +/** Requires the durable credential metadata operations before secret storage is composed. */ +function requireBindingStore(value: unknown): PluginCredentialBindingStore { + if ( + value === null || + typeof value !== 'object' || + typeof (value as PluginCredentialBindingStore).findById !== 'function' || + typeof (value as PluginCredentialBindingStore).createIfAbsent !== 'function' || + typeof (value as PluginCredentialBindingStore).revokeActive !== 'function' + ) { + return unavailable(); + } + return value as PluginCredentialBindingStore; +} + +/** Requires durable one-time operator evidence consumption before request authority is granted. */ +function requireReplayGuard(value: unknown): PluginOperatorReplayGuardPort { + if ( + value === null || + typeof value !== 'object' || + typeof (value as PluginOperatorReplayGuardPort).consume !== 'function' + ) { + return unavailable(); + } + return value as PluginOperatorReplayGuardPort; +} + +/** + * Composes authenticated Plugin operator authority with Plugin-owned Vault secret storage. + * + * Vault origin, token, and mount plus the operator-context verifier key are read only + * from the supplied operator-owned environment. Missing/malformed configuration fails + * during composition with one credential-free error. The returned application derives + * tenant/user authority from signed request evidence, consumes replay evidence durably, + * and routes credential plaintext only through `PluginVaultSecretStore`; no secret value + * is added to LifeOS persistence or returned in the credential view. + * + * PostgreSQL pool ownership remains outside this focused slice: callers supply the + * already-constructed Integration-owned installation, credential-metadata, and replay + * ports. A later hosted bootstrap slice can bind those ports to the service-owned pool + * without making Vault or another bounded context a persistence owner. + */ +export function createPluginVaultOperatorApplication( + dependencies: PluginVaultOperatorDependencies, + environmentInput: PluginVaultOperatorEnvironment = process.env, + http?: PluginVaultHttpClient, + nowSeconds: () => number = () => Math.floor(Date.now() / 1000), + now: () => Date = () => new Date(), +): PluginOperatorApplication { + if ( + dependencies === null || + typeof dependencies !== 'object' || + typeof nowSeconds !== 'function' || + typeof now !== 'function' + ) { + return unavailable(); + } + const environment = requireEnvironment(environmentInput); + const installations = requireInstallations(dependencies.installations); + const bindingStore = requireBindingStore(dependencies.bindingStore); + const replayGuard = requireReplayGuard(dependencies.replayGuard); + const contextSecret = requireOperatorContextSecret( + requireConfiguration(environment, 'INTEGRATION_OPERATOR_CONTEXT_SECRET'), + ); + const origin = requireConfiguration( + environment, + 'INTEGRATION_PLUGIN_VAULT_ORIGIN', + ); + const token = requireConfiguration( + environment, + 'INTEGRATION_PLUGIN_VAULT_TOKEN', + ); + const mount = requireConfiguration( + environment, + 'INTEGRATION_PLUGIN_VAULT_MOUNT', + ); + + let secretStore: PluginVaultSecretStore; + try { + secretStore = new PluginVaultSecretStore(origin, token, mount, http); + } catch { + return unavailable(); + } + + const credentials = new PluginCredentialApplication( + installations, + bindingStore, + secretStore, + now, + ); + return new PluginOperatorApplication( + installations, + credentials, + contextSecret, + replayGuard, + nowSeconds, + ); +} diff --git a/apps/integration-service/src/plugin-vault-operator-configuration-ownership.test.ts b/apps/integration-service/src/plugin-vault-operator-configuration-ownership.test.ts new file mode 100644 index 000000000..48bc9de2a --- /dev/null +++ b/apps/integration-service/src/plugin-vault-operator-configuration-ownership.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PluginCredentialBindingStore } from './plugin-credential'; +import type { PluginInstallationOperatorPort } from './plugin-operator-application'; +import type { PluginOperatorReplayGuardPort } from './plugin-operator-replay'; +import { + createPluginVaultOperatorApplication, + PluginVaultOperatorCompositionError, +} from './plugin-vault-operator-composition'; + +const CONTEXT_SECRET = 'integration-operator-context-secret-fixture'; +const VAULT_TOKEN = 'integration-vault-token-fixture'; + +function dependencies(): Readonly<{ + installations: PluginInstallationOperatorPort; + bindingStore: PluginCredentialBindingStore; + replayGuard: PluginOperatorReplayGuardPort; +}> { + return Object.freeze({ + installations: { + install: vi.fn(), + getInstallation: vi.fn(), + revoke: vi.fn(), + }, + bindingStore: { + findById: vi.fn(), + createIfAbsent: vi.fn(), + revokeActive: vi.fn(), + }, + replayGuard: { + consume: vi.fn(), + }, + }); +} + +describe('Plugin Vault operator configuration ownership', () => { + it('accepts only Integration-owned Vault configuration names', () => { + expect(() => + createPluginVaultOperatorApplication(dependencies(), { + INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET, + INTEGRATION_PLUGIN_VAULT_ORIGIN: 'https://vault.example.test', + INTEGRATION_PLUGIN_VAULT_TOKEN: VAULT_TOKEN, + INTEGRATION_PLUGIN_VAULT_MOUNT: 'secret', + }), + ).not.toThrow(); + }); + + it('does not treat generic Vault aliases as service-owned credential authority', () => { + expect(() => + createPluginVaultOperatorApplication(dependencies(), { + INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET, + PLUGIN_VAULT_ORIGIN: 'https://vault.example.test', + PLUGIN_VAULT_TOKEN: VAULT_TOKEN, + PLUGIN_VAULT_MOUNT: 'secret', + }), + ).toThrow(PluginVaultOperatorCompositionError); + }); +}); diff --git a/docs/adr/0004-plugin-vault-operator-composition.md b/docs/adr/0004-plugin-vault-operator-composition.md new file mode 100644 index 000000000..7281e8f01 --- /dev/null +++ b/docs/adr/0004-plugin-vault-operator-composition.md @@ -0,0 +1,58 @@ +# ADR 0004: Plugin Vault operator composition + +- Status: Proposed +- Date: 2026-09-04 +- Bounded context: Integration / Plugin +- Current implementation PR: #243 + +## Problem + +`PluginVaultSecretStore` provides a concrete encrypted-secret boundary, but a provider adapter by itself is not credential authority. Production credential materialization must remain downstream of authenticated workspace/user evidence, one-time replay consumption, active installation authority, and Integration-owned credential metadata. Vault configuration must also be scoped to this service so another process cannot accidentally satisfy Plugin authority through a generic shared environment namespace. + +## Constraints + +- LifeOS owns Plugin installation, credential-binding, replay, and secret-reference truth; Vault owns encrypted secret bytes only. +- Plaintext plugin credentials must not enter PostgreSQL, logs, public errors, model surfaces, or durable review evidence. +- Tenant and user authority come only from the signed Plugin operator context and cannot be supplied by the request body. +- Replay evidence remains service-owned and durable across replicas. +- External Vault origin/token/mount configuration must fail closed before credential use and must not be reflected by error text. +- The composition may consume only Integration-owned ports. It must not copy Calendar secret-store source, query another service database, or depend on a mutable sibling PR head. +- Hosted PostgreSQL pool creation and shutdown are a separate runtime concern; this ADR does not treat an application factory as a deployed runtime. + +## Considered alternatives + +### Let HTTP controllers construct Vault directly + +Rejected. Controller-level provider construction would mix transport, authentication, secret storage, and persistence ownership, make lifecycle cleanup harder to prove, and permit request handling to become the composition root. + +### Use generic `PLUGIN_VAULT_*` environment variables + +Rejected. Generic names weaken service-owned credential boundaries and make accidental cross-service credential reuse materially easier. The selected configuration namespace is `INTEGRATION_PLUGIN_VAULT_ORIGIN`, `INTEGRATION_PLUGIN_VAULT_TOKEN`, and `INTEGRATION_PLUGIN_VAULT_MOUNT`; the operator verifier key remains `INTEGRATION_OPERATOR_CONTEXT_SECRET`. + +### Persist Vault credentials or plaintext in Integration PostgreSQL + +Rejected. PostgreSQL stores only bounded Plugin metadata and opaque provider references. Vault token and plugin secret bytes remain outside service persistence. + +### Compose authenticated Plugin authority over existing Integration ports + +Selected. `createPluginVaultOperatorApplication` validates its configuration and dependency envelopes, creates the Plugin-owned Vault adapter, wraps it in `PluginCredentialApplication`, and supplies that credential authority to the replay-aware `PluginOperatorApplication`. + +## Decision + +The Integration bounded context owns the composition of authenticated Plugin credential authority. Exact service-owned environment names are mandatory; generic Vault aliases are not fallback authority. Configuration failures are reduced to `PluginVaultOperatorCompositionError` without values. The composition continues to use the existing signed method/path/workspace/user/evidence contract and durable replay guard before any credential operation can reach Vault. + +The factory is not yet the hosted production bootstrap. `IntegrationAppModule` remains unmodified in this slice, and callers still supply the Integration-owned installation, credential-binding, and replay ports. Production readiness therefore requires a successor that binds those ports to one service-owned PostgreSQL pool, registers the composed operator before the listener starts, and closes the pool exactly once through the application shutdown lifecycle. + +## Evidence + +- RED `4469c82dd76f936475142c244143c52366f70d2f`: requires authenticated Vault operator composition. +- Initial composition `2b290cc493959957ea96de3bebcbbb4e5c95a901`. +- Non-force parent adoption/restack `d2bb1fc643b533a3cd143ba4aed62def2babb737` onto #242 exact `6cf7e1accec331dc1a06eefe9d088083ef917947`. +- Ownership RED `32b0deffa67ada6ce1214ec416f15b86f8b479bc`: requires Integration-prefixed Vault configuration and rejects generic aliases. +- Causal ownership repair `2c81ed509be937d126e59d8af3043125cd408853` plus fixture alignment `3fc1c3b0d10f7c24872028df2a26e898a0008768`. + +These commits are source/test evidence only. Because #243 is a non-default stacked PR and this execution surface cannot run the workspace package graph, they are not repository-wide GREEN or release evidence. + +## Risks and follow-up + +The remaining highest-risk gap is hosted composition: without it, the standalone service process still starts with the fail-closed module that does not register durable Plugin operator authority. A successor must provide service-owned PostgreSQL runtime composition and exact-head integration/shutdown tests before #243 can be described as a production operator path. Subsequent work must separately own outbound HTTPS SSRF/DNS-rebinding controls, redirect/proxy policy, signing and idempotency, durable delivery attempt/outcome/retry/dead-letter/recovery, revocation fencing, and buyer-visible operator state.