---
name: catalyst-cross-bounded-context-ports
description: Canonical Hexagonal pattern for cross-bounded-context dependencies in the Aurora Catalyst backend. Applies whenever code from one BC needs to read from or write to another BC. Trigger keywords - "cross-bc", "BC depends on", "iam needs from o-auth", "consume service from another BC", "imports OAuthModule", "OtherModule cross-bc". Also fires as antipattern detection whenever `imports: [OtherBoundedContextModule]` or mass `exports: [...XServices]` appear in new code — both are rejected by this skill.
---

# Cross-Bounded-Context Ports — Aurora Catalyst canonical pattern

## When to use this skill

Apply the pattern **every time** code in one bounded context (BC) needs to access data or services from ANOTHER BC. Examples:

- `iam` needs to read applications of an `OAuthClient` in order to compute `dApplicationCodes` → port `IIamClientReader`.
- `message` needs to validate that the recipient is an active `IamAccount` → port `IMessageAccountReader` declared in message (the BC prefix prevents collisions with iam's own internal account reader).
- `whatsapp` needs to publish an event that `iam` consumes → different pattern (events), out of scope here.

**Do NOT apply when**:

- The access is WITHIN the same BC (handler→service, service→repo) → use plain NestJS DI.
- The access is to an upstream framework abstraction (`AuditingRunner`, `CoreGetLangsService`) → already wired in `SharedModule`.
- The communication is asynchronous via events → use `EventEmitter2` with typed event payloads.

## The architectural rule

> **Nothing under `@app/<bc>/` may import from another `@app/<other-bc>/`. All cross-BC code lives under `@bridges/<bc>/`.**

`@bridges/<bc>/` is **part of that BC** — it's the BC's outward-facing slice (the contracts it declares as a consumer + the contracts it offers as a supplier). `@app/<bc>/` is the BC's internal slice (handlers, services, repositories, models). Together they ARE the BC.

Cross-BC TypeScript imports happen exclusively between `@bridges/<bc>/` directories. The internal `@app/<bc>/` of each BC stays pure — it imports from itself or from `@bridges/<same-bc>/`, never from `@app/<other-bc>/`.

This rule is **enforceable by linter** with a single regex.

## Canonical structure — 4 files per dependency

```
backend/src/
├── @bridges/<consumer-bc>/<feature>/ports/
│   ├── <consumer-bc>-<thing>.port.ts                   ← interface + DTO (consumer's outward face)
│   ├── <consumer-bc>-<thing>.token.ts                  ← Symbol DI token
│   └── index.ts                          ← barrel
│
├── @bridges/<supplier-bc>/<module>/adapters/
│   └── <supplier-bc>-<thing>.adapter.ts  ← implementation (supplier's outward face)
│
└── @bridges/
    ├── bridges.module.ts                 ← composition root (@Global)
    └── <consumer-bc>-<thing>.bridge.ts   ← provider entry that wires port↔adapter
```

### 1. Port (consumer's outward face)

`@bridges/<consumer-bc>/<feature>/ports/<consumer-bc>-<thing>.port.ts`:

```ts
export interface <Consumer><Thing>Result {
  // MINIMAL shape the consumer actually uses.
  // NOT the full model of the supplier.
}

export interface I<Consumer><Thing> {
  <readMethod>(<inputs>): Promise<<Consumer><Thing>Result>;
}
```

`@bridges/<consumer-bc>/<feature>/ports/<consumer-bc>-<thing>.token.ts`:

```ts
export const <CONSUMER>_<THING> = Symbol('<CONSUMER>_<THING>');
```

### 2. Adapter (supplier's outward face)

`@bridges/<supplier-bc>/<module>/adapters/<supplier-bc>-<thing>.adapter.ts`:

```ts
@Injectable()
export class <Supplier><Thing>Adapter implements I<Consumer><Thing> {
  constructor(private readonly <supplierService>: <SupplierService>) {}

  async <readMethod>(<inputs>): Promise<<Consumer><Thing>Result> {
    const raw = await this.<supplierService>.main(...);
    // map the supplier's raw model to the consumer's DTO
    return { /* consumer shape */ };
  }
}
```

The adapter imports the supplier's internal service from `@app/<supplier-bc>/...` — this is **intra-BC** (both `@bridges/<supplier-bc>/` and `@app/<supplier-bc>/` are the same BC). For NestJS DI to inject it, `<SupplierBc>Module` MUST export that single service (curated, not `[...XServices]` wholesale).

> **Curated export trade-off (acknowledged):** exporting the internal service means ANY module that imports `<SupplierBc>Module` can technically `@Inject(<SupplierService>)` and bypass the port. That's a soft leak. It is accepted in this pattern because:
>
> 1. The export list is **surgical**: only the exact services bridge adapters need. Reviewers can spot a wholesale `...XServices` export instantly.
> 2. Code review is the primary defense. The only LEGITIMATE consumers of a supplier's internal service are bridge adapters in `@bridges/<supplier-bc>/`. Any other injection is a port-bypass and must be rejected at review time.
> 3. The alternative (stricter encapsulation) requires registering the adapter inside `<SupplierBc>Module` itself and exporting ONLY the adapter — see "Alternative pattern: zero-leak" near the end of this document for the full trade-off.

### 3. Bridge entry

`@bridges/<consumer-bc>-<thing>.bridge.ts`:

```ts
import { <CONSUMER>_<THING> } from '@bridges/<consumer-bc>/<feature>/ports/<thing>.token';
import { <Supplier><Thing>Adapter } from '@bridges/<supplier-bc>/<module>/adapters/<consumer-bc>-<thing>.adapter';
import { Provider } from '@nestjs/common';

export const <consumer><Thing>Bridge: Provider = {
  provide: <CONSUMER>_<THING>,
  useExisting: <Supplier><Thing>Adapter,
};
```

### 4. Registration in `BridgesModule`

`@bridges/bridges.module.ts`:

```ts
@Global()
@Module({
  imports: [<SupplierBc>Module /*, other supplier modules */],
  providers: [
    <Supplier><Thing>Adapter,
    <consumer><Thing>Bridge,
    // ... other adapters + bridges
  ],
  exports: [
    <CONSUMER>_<THING>,
    // ... other tokens
  ],
})
export class BridgesModule {}
```

**Important**: the supplier module must export the internal service the adapter needs (curated, NOT `[...XServices]` wholesale).

## Port variants: read vs write

Both variants share the identical 4-file shape above. What differs is **the DTO inside the port** — and the DTO's ownership decides **where the port file lives**.

> **The placement rule:** the port lives where its DTO lives.

The canonical structure diagram at the top of this skill is correct for read ports. For write ports with upstream-typed DTOs, the `<consumer-bc>` slot in the port path is replaced by `<supplier-bc>` — see below.

### Read port — the consumer projects what it needs

The port returns a DTO shaped to the consumer's vocabulary, not the supplier's. The adapter translates the supplier's internal model into that DTO — flattening relations, renaming fields, dropping what the consumer does not need. This is the **Anti-Corruption Layer**: if the supplier renames `applications` to `apis` tomorrow, only the adapter breaks; the consumer is shielded.

The DTO is consumer-shaped → **the port file lives in the consumer's territory** (`@bridges/<consumer-bc>/<feature>/ports/`). Hand-write the DTO next to the port, with the `<Consumer><DescriptiveName>` prefix per the naming table.

Canonical example: `IIamClientReader.findByIdWithApplications()` returns `IamClientWithApplications` (`id` + `applicationCodes` only) — fully detailed in the walkthrough below.

### Write port — the consumer relays data to the supplier

The consumer has no domain vocabulary for the resource being written; it is orchestrating data into a supplier that owns it semantically. The DTO IS the supplier's input contract — **reuse it as-is from upstream** rather than re-declaring it locally. Hand-writing a near-duplicate DTO offers no Anti-Corruption value here (there is no projection to do) and introduces drift when the supplier's input contract evolves.

Where to source the upstream type, in order of preference:

1. **`@aurorajs.dev/core-back` framework types** (`SeederBoundedContext`, `SeederPermission`, `RepositoryOptions`). Framework-level, purpose-built for cross-cutting flows. Pick this when available.
2. **The supplier's GraphQL `*Input` type** from `@api/graphql` (e.g. `IamCreatePermissionInput`). Already the shape the supplier's own handlers accept.
3. **The supplier's domain DTO** if one exists. Rare.

The DTO is supplier-shaped → **the port file lives in the supplier's territory** (`@bridges/<supplier-bc>/<resource>/ports/`), next to the adapter under `<resource>/adapters/`. The port is essentially the supplier's driving interface for cross-BC writes — a single contract that any consumer in the project can inject by token. Naming follows the supplier (`iam-bounded-context-seeder.port.ts` under `@bridges/iam/bounded-context/ports/`), not the consumer.

The adapter is a pass-through with no mapping. **Naming exceptions for write ports**:

- Port file/token: **supplier-prefixed** (matches its directory), not consumer-prefixed.
- DTO: imported verbatim from upstream, carries NO `<Consumer>` prefix at all.
- Port interface (`I<Supplier><Thing>` like `IIamBoundedContextSeeder`) and bridge entry still follow their normal naming, anchored on the supplier.

Canonical example — `o-auth`'s bootstrap seeder needs to write iam bounded contexts and permissions:

```ts
// Port — @bridges/iam/bounded-context/ports/iam-bounded-context-seeder.port.ts
import type { RepositoryOptions, SeederBoundedContext } from '@aurorajs.dev/core-back';

export interface IIamBoundedContextSeeder {
  seedAll(items: SeederBoundedContext[], options: RepositoryOptions): Promise<void>;
}
```

```ts
// Adapter — @bridges/iam/bounded-context/adapters/iam-bounded-context-seeder.adapter.ts
@Injectable()
export class IamBoundedContextSeederAdapter implements IIamBoundedContextSeeder {
  constructor(private readonly handler: IamCreateBoundedContextsHandler) {}

  async seedAll(items: SeederBoundedContext[], options: RepositoryOptions): Promise<void> {
    await this.handler.main(items, {
      timezone: process.env.TZ,
      repositoryOptions: options,
    });
  }
}
```

Token, bridge entry, and `BridgesModule` registration follow the canonical pattern — only the DTO source and the port's file location differ.

### Choosing between them

> **Does the consumer have its own vocabulary for this resource?**
>
> - **Yes** — the consumer reduces, flattens, or renames the supplier's model → write a custom DTO in the **consumer's** territory next to the port. **Read port.**
> - **No** — the consumer relays data that semantically belongs to the supplier → reuse the supplier's or framework's existing type, and place the port in the **supplier's** territory. **Write port.**

When in doubt: writes default to **write port** (reuse upstream, port in supplier territory); reads default to **read port** (hand-write DTO, port in consumer territory). Mixing within the same port (e.g. a method that both reads-projecting and writes-relaying) is a smell — split it.

## End-to-end walkthrough: `iam → o-auth`

The first case that established the pattern. Final state after `establish-cross-bc-ports-pattern` was archived:

```ts
// 1️⃣ Port — @bridges/iam/client/ports/iam-client-reader.port.ts
export interface IamClientWithApplications {
  id: string;
  applicationCodes: string[];      // flattened (not `applications: OAuthApplication[]`)
}

export interface IIamClientReader {
  findByIdWithApplications(id: string): Promise<IamClientWithApplications>;
}
```

```ts
// 1️⃣ Token — @bridges/iam/client/ports/iam-client-reader.token.ts
export const IAM_CLIENT_READER = Symbol('IAM_CLIENT_READER');
```

```ts
// 2️⃣ Adapter — @bridges/o-auth/client/adapters/o-auth-client-reader.adapter.ts
@Injectable()
export class OAuthClientReaderAdapter implements IIamClientReader {
  constructor(private readonly findByIdService: OAuthFindClientByIdService) {}

  async findByIdWithApplications(id: string): Promise<IamClientWithApplications> {
    const client = await this.findByIdService.main(id, {
      include: [{ association: 'applications' }],
    });
    if (!client) {
      throw new NotFoundException({
        message: `OAuthClient with id: ${id}, not found`,
        translation: 'o-auth.error.clientNotFound',
        placeholders: { id },
      });
    }
    return {
      id: client.id,
      applicationCodes: (client.applications ?? [])
        .map((app) => app?.code)
        .filter((code): code is string => Boolean(code)),
    };
  }
}
```

```ts
// 3️⃣ Bridge entry — @bridges/providers/iam-client-reader.bridge.ts
export const iamClientReaderBridge: Provider = {
  provide: IAM_CLIENT_READER,
  useExisting: OAuthClientReaderAdapter,
};
```

```ts
// 4️⃣ BridgesModule registration — @bridges/bridges.module.ts
@Global()
@Module({
  imports: [OAuthModule],   // supplier export curated: only OAuthFindClientByIdService
  providers: [OAuthClientReaderAdapter, iamClientReaderBridge],
  exports: [IAM_CLIENT_READER],
})
export class BridgesModule {}
```

```ts
// 5️⃣ Consumer: IamCreateAccountHandler
@Injectable()
export class IamCreateAccountHandler {
  constructor(
    ...,
    @Inject(IAM_CLIENT_READER)
    private readonly clientReader: IIamClientReader,
    ...
  ) {}

  async main(currentAccount, payload, handlerMeta?) {
    ...
    const client = await this.clientReader.findByIdWithApplications(payload.clientId);
    const computedApplicationCodes = client.applicationCodes;   // already flattened
    ...
  }
}
```

```ts
// 6️⃣ IamModule — does NOT import OAuthModule
@Module({
  imports: [SharedModule, SequelizeModule.forFeature([...IamModels])],
  ...
})
export class IamModule {}
```

```ts
// 7️⃣ OAuthModule — CURATED export
@Module({
  imports: [...],
  providers: [...],
  exports: [OAuthFindClientByIdService],   // NOT [...OAuthServices]
})
export class OAuthModule {}
```

## Naming conventions

| Piece | Convention | Example |
|---|---|---|
| Port file | `<consumer-bc>-<thing>.port.ts` | `iam-client-reader.port.ts` |
| Port interface | `I<Consumer><Thing>` (PascalCase with leading I) | `IIamClientReader` |
| Port DTO | `<Consumer><DescriptiveName>` (consumer-prefixed) | `IamClientWithApplications` |
| Token file | `<consumer-bc>-<thing>.token.ts` | `iam-client-reader.token.ts` |
| Token const | `<CONSUMER>_<THING>` (SCREAMING_SNAKE_CASE) | `IAM_CLIENT_READER` |
| Symbol literal | matches the const exactly | `Symbol('IAM_CLIENT_READER')` |
| Adapter file | `<supplier-bc>-<thing>.adapter.ts` | `o-auth-client-reader.adapter.ts` |
| Adapter class | `<Supplier><Thing>Adapter` (supplier-prefixed) | `OAuthClientReaderAdapter` |
| Bridge file | `<consumer-bc>-<thing>.bridge.ts` | `iam-client-reader.bridge.ts` |
| Bridge const | `<consumer><Thing>Bridge` (camelCase) | `iamClientReaderBridge` |

**Why every identifier carries a BC prefix:** Symbol tokens live in a single global DI namespace; without prefix, `CLIENT_READER` from iam and `CLIENT_READER` from message would collide. Once we prefix tokens, prefixing the surrounding type identifiers (interface, DTO) and file names keeps everything self-disambiguating in grep results and import statements. Only the interface name and DTO need the I-prefix for TypeScript interface convention — the file paths already say where they live.

**Write-port variant — the BC prefix flips to the supplier.** For write ports with upstream-typed DTOs, the port lives in the supplier's territory (see "Port variants"). The `<consumer-bc>-` prefix in the table above becomes `<supplier-bc>-` for the port file, the token file, and the token const. The port interface becomes `I<Supplier><Thing>`. The DTO carries no prefix at all (imported verbatim from upstream). Only the bridge file/const stays consumer-prefixed, because the bridge wires a consumer's intent into the supplier's port. Example: `IIamBoundedContextSeeder` lives at `@bridges/iam/bounded-context/ports/iam-bounded-context-seeder.port.ts`; the bridge entry remains `oAuthBoundedContextSeederBridge`-style if registered from a specific consumer — though in practice today's seeder bridges (`iamBoundedContextSeederBridge`, `iamPermissionSeederBridge`) are also supplier-anchored because they serve any consumer.

## Forbidden antipatterns

```ts
// ❌ Directly importing OtherBcModule from your BC module
@Module({
  imports: [SharedModule, OAuthModule],   // ← NO
})
export class IamModule {}

// ❌ Wholesale-exporting every service of the BC
@Module({
  exports: [...OAuthServices],            // ← NO. Export only the services bridge adapters need.
})
export class OAuthModule {}

// ❌ Injecting the supplier's concrete service in a consumer handler
@Injectable()
export class IamCreateAccountHandler {
  constructor(
    private readonly findClientByIdService: OAuthFindClientByIdService,  // ← NO. Inject the port.
  ) {}
}

// ❌ Returning the supplier's full model from the port
export interface IIamClientReader {
  findById(id: string): Promise<OAuthClient>;   // ← NO. Return a DTO with the minimal shape you need.
}

// ❌ Making `@Global()` any module just for convenience
@Global()                                  // ← NO if it isn't a composition root
@Module({...})
export class MyUtilitiesModule {}
```

## Testing

Mock the port directly with an object literal — **much simpler** than mocking the supplier's service:

```ts
beforeEach(async () => {
  const clientReader = {
    findByIdWithApplications: jest
      .fn()
      .mockResolvedValue({ id: CLIENT_ID, applicationCodes: ['APP_A'] }),
  };

  const module: TestingModule = await Test.createTestingModule({
    providers: [
      IamCreateAccountHandler,
      // ... other mocks
      { provide: IAM_CLIENT_READER, useValue: clientReader },
    ],
  }).compile();

  handler = module.get<IamCreateAccountHandler>(IamCreateAccountHandler);
});

it('uses applicationCodes from the port', async () => {
  await handler.main(buildPayload(), buildCaller());
  expect(clientReader.findByIdWithApplications).toHaveBeenCalledWith(expect.any(String));
});
```

You never need to know the signature of `OAuthFindClientByIdService.main(id, constraint, handlerMeta)` or replicate its shape. The port is your contract; mock it directly.

## Explicit decisions — why these are NOT negotiable

- **Adapter on the supplier side, not the consumer side**: the supplier IS responsible for knowing how to translate its own model into the contract that consumers demand (DDD Customer/Supplier). If you put the adapter consumer-side, the consumer imports the concrete service and you reintroduce the coupling you wanted to remove.
- **`useExisting` in the bridge entry, not `useClass`**: `useExisting` reuses the singleton instance of the adapter. `useClass` would create a second separate instance per token injection. Stick with `useExisting`.
- **`@Global()` only on `BridgesModule` and `SharedModule`**: do NOT apply it to any other module out of convenience. Both ARE composition roots; any other use is an antipattern.
- **Minimal consumer DTO for read ports; upstream type verbatim for write ports**: read ports are where the consumer says "this is what I need" — the supplier translates, the adapter does the work, only the adapter breaks if the supplier renames internals. Write ports relay data the supplier owns, so the port DTO IS the supplier's input contract reused as-is (`SeederBoundedContext` from core-back, `*Input` from `@api/graphql`); hand-writing a near-duplicate adds drift without ACL value. See "Port variants" above.
- **Adapter is named after the supplier, bridge is named after the consumer**: the adapter file/class says "I am o-auth's client-reader" (`o-auth-client-reader.adapter.ts` / `OAuthClientReaderAdapter`). The bridge entry says "I wire iam's client-reader to its supplier" (`iam-client-reader.bridge.ts` / `iamClientReaderBridge`). This split lets one supplier adapter serve multiple consumer ports without renaming, and lets each consumer's bridge entry sit in their conceptual side of the wiring.

## Alternative pattern: zero-leak (consider for sensitive supplier services)

The canonical pattern above (curated-export variant) is the simplest first cut: registers the adapter in `BridgesModule` and exports the supplier's internal service through the supplier module so the adapter can inject it. The cost is the leak documented above.

The zero-leak variant **keeps the canonical file location** (`@bridges/<supplier-bc>/<module>/adapters/<supplier-bc>-<thing>.adapter.ts` — `@bridges/<bc>/` is part of the BC) but moves the **registration** from `BridgesModule` to `<SupplierBc>Module`. The adapter then becomes intra-module to its supplier, can inject internal services without any export, and the supplier module exports only the adapter itself.

**File location**: identical to canonical. The adapter STAYS at `@bridges/<supplier-bc>/<module>/adapters/`. Do NOT invent a new `@app/<bc>/<module>/cross-bc/` folder — it fragments the convention.

**Registration**: in `<SupplierBc>Module.providers` (not `BridgesModule.providers`).

```ts
// 1️⃣ Adapter — same file path as canonical
// @bridges/<supplier-bc>/<module>/adapters/<supplier-bc>-<thing>.adapter.ts
@Injectable()
export class <Supplier><Thing>Adapter implements I<Consumer><Thing> {
  constructor(private readonly <supplierService>: <SupplierService>) {}
  // ...
}

// 2️⃣ <SupplierBc>Module — imports the adapter from @bridges/<same-bc>/ (intra-BC) and
// registers it. The internal service `<SupplierService>` is NOT exported anywhere.
import { <Supplier><Thing>Adapter } from '@bridges/<supplier-bc>/<module>/adapters/<supplier-bc>-<thing>.adapter';

@Module({
  imports: [...],
  providers: [
    ...,
    <SupplierService>,                   // private to the module (intra-module DI for the adapter)
    <Supplier><Thing>Adapter,            // hand-written cross-BC adapter, registered here
  ],
  exports: [<Supplier><Thing>Adapter],   // ONLY the port-conforming adapter leaves the module
})
export class <SupplierBc>Module {}

// 3️⃣ BridgesModule — imports SupplierModule (to get the exported adapter) and registers ONLY
// the bridge provider that maps the port token to the adapter via `useExisting`.
@Global()
@Module({
  imports: [<SupplierBc>Module],
  providers: [
    { provide: <CONSUMER>_<THING>, useExisting: <Supplier><Thing>Adapter },
  ],
  exports: [<CONSUMER>_<THING>],
})
export class BridgesModule {}
```

**Differences from the canonical pattern:**

| Aspect | Canonical (curated-export) | Zero-leak alternative |
|---|---|---|
| Adapter file location | `@bridges/<supplier-bc>/<module>/adapters/` | **Same** — `@bridges/<supplier-bc>/<module>/adapters/` |
| Adapter registration | `BridgesModule.providers` | `<SupplierBc>Module.providers` |
| Supplier module exports | Curated internal service(s) the adapter needs | Just the adapter |
| `BridgesModule.providers` content | Adapter classes + bridge mappings | ONLY bridge mappings (adapters provided transitively via supplier imports) |
| External access to internal services | Possible if a module imports `<SupplierBc>Module` (controlled by code review) | Impossible — internals stay private |
| Registration topology | Single composition root | Distributed (each supplier registers its own adapters) |

**When to choose zero-leak:**

- Default for new cross-BC dependencies. Most supplier services don't WANT to be exported as a side effect of having a port; the canonical variant accepts the leak only for simplicity.
- Especially valuable when the supplier exposes PII, secrets, or auth state.
- When the curated export list would grow with more consumers.

**When to stay with the canonical pattern:**

- Quick prototype where the supplier module is throwaway.
- Migration cost too high to refactor existing canonical-pattern code (but consider doing it anyway when touching the BC for an unrelated change).

**Migration policy:** when migrating canonical → zero-leak, do it for the entire BC at once (all adapters at once). Mixing both variants within the same BC fragments the mental model.

**What the Aurora Catalyst repo uses:** zero-leak across all cross-BC adapters as of 2026-05-24 (Phase 2 refactor). Phase 1 (`iam-client-reader`) and Phase 2 (`credential-verifier`, `account-loader`) are read ports. Phase 3 (`iam-bounded-context-seeder`, `iam-permission-seeder`, 2026-05-26) introduced the first write ports, consuming `SeederBoundedContext` / `SeederPermission` / `RepositoryOptions` from `@aurorajs.dev/core-back`. All five follow the zero-leak variant. The "Differences" table is documentation of the migration history, not a live choice.

## When NOT to apply this pattern

- **Within the same BC**: use plain DI. A handler in `iam/account` can inject `IamCreateUserService` (from `iam/user`) directly because both live in the same `IamModule`.
- **For framework upstream abstractions** (`AuditingRunner`, `CoreGetLangsService`): already wired in `SharedModule`, which is `@Global()`. Inject them directly.
- **For asynchronous, fire-and-forget communication**: use events (`EventEmitter2`). Ports are synchronous by design (`Promise<>` with a direct response).
- **For cross-BC writes with transactional consistency across multiple supplier mutations**: ports are synchronous and per-call. They cover one-shot writes (idempotent seeders, single mutations) and reads cleanly, but multi-step distributed transactions (sagas, eventual consistency) warrant a dedicated pattern outside this skill.

## References

- **Change that established the pattern**: `openspec/changes/archive/2026-05-10-establish-cross-bc-ports-pattern/`
- **Normative spec**: `openspec/specs/cross-bc-ports/spec.md`
- **Previous change that introduced the debt**: `openspec/changes/archive/2026-05-10-add-iam-account-create-orchestration/` (its `design.md` declares it explicitly)
