# Cycle-break contract packages (/docs/cycle-break-contract-packages)



When `@pleach/core` needs to reference a type that belongs to a sibling
SKU — say, the `ComplianceRuntime` interface implemented by
`@pleach/compliance` — a naive `import type { ComplianceRuntime } from "@pleach/compliance"`
creates a cycle: `core` depends on `compliance`, and `compliance` depends
on `core`. The cycle surfaces as `TS5055` / `TS6307` when one of the two
SKUs rebuilds its `dist/` and the other tries to consume a stale `.d.ts`.
It also surfaces as latent peer-resolution flakiness on every downstream
consumer's `tsc` run.

Neither `ai-sdk` nor `langchain` solves this — both ecosystems either
inline the type in core or accept the cycle. Pleach extracts a third,
zero-dependency package — `@pleach/<sku>-contract` — that both sides
depend on **one-way**.

## The shape [#the-shape]

```
@pleach/core
   ├── depends on → @pleach/compliance-contract  (one-way, type-only)
   └── never depends on → @pleach/compliance

@pleach/compliance
   ├── depends on → @pleach/core
   ├── depends on → @pleach/compliance-contract
   └── re-exports types from contract for back-compat
```

`@pleach/compliance-contract` itself has **zero runtime dependencies and
zero peer dependencies**. It's a pure-type package: the published
artifact is `.d.ts` files. The tsup config emits no `.js` (or emits
empty stubs for declaration completeness).

## What goes in a contract package [#what-goes-in-a-contract-package]

Only **types that cross the SKU boundary**. For
`@pleach/compliance-contract` that's:

* The `ComplianceRuntime` interface — the shape `@pleach/core` accepts
  on `SessionRuntimeConfig.complianceRuntime?:`.
* The `ComplianceProfile` literal union — `"hipaa" | "gdpr" | "soc2" | "pci-dss"`.
* The structural `Scrubber` mirror — the minimal shape a scrubber
  implementation satisfies.
* The tenant-scope sentinel constant.

Things that stay **out** of the contract package:

* Scrubber implementations (Luhn, SSN, US driver's license, etc.) — those
  live in `@pleach/compliance`.
* The `ComplianceRuntime` runtime class — implementation, not type.
* Helper utilities that aren't on the cross-SKU surface.

## The audit gate [#the-audit-gate]

CI enforces the boundary with
[`audit:no-cross-sku-type-import-in-core`](/docs/audit-gates#no-cross-sku-type-import-in-core).
The gate scans every file in `packages/core/src/` and fails on any
type-position `import("@pleach/<sku>")` or
`import type ... from "@pleach/<sku>"` **outside the explicit allowlist**.

Current allowlist (2026-06-15):

* `@pleach/core` itself (self-references through the workspace alias)
* `@pleach/compliance-contract`

Adding a new contract package adds one entry to the allowlist; nothing
else changes in the audit script.

## When to extract a new contract package [#when-to-extract-a-new-contract-package]

Extract `@pleach/<sku>-contract` when **all three** are true:

1. `@pleach/core` needs to reference a type defined in `@pleach/<sku>` at
   a public seam (a config-object field, a host-strategy DI callback
   signature, an event-log row column type).
2. The type is **stable enough** to ship under semver — every change to
   the contract package is a coordinated breaking change across all
   consumers.
3. The cycle is **load-bearing** — without the contract, either core's
   `tsc --build` fails or every downstream consumer's `tsc` flakes on
   stale `.d.ts`.

Do **not** extract a contract package for:

* Types that only flow `core → sku` (e.g., `HarnessPlugin` lives in core
  because every SKU depends on core anyway).
* Types that only one SKU references (no cycle).
* Types that are implementation details — those stay in the SKU they
  belong to.

## Authoring checklist [#authoring-checklist]

When extracting a new contract package:

1. Author `packages/<sku>-contract/` mirroring `compliance-contract`:
   * `package.json` with zero `dependencies` + zero `peerDependencies`,
     `FSL-1.1-Apache-2.0` license, type-only emit.
   * `src/index.ts` exporting only the types that cross the boundary.
   * `tsup.config.ts` with `dts: true`, `format: ["esm", "cjs"]`.
2. Add the new package name to the allowlist in
   `scripts/audit/no-cross-sku-type-import-in-core.mjs`.
3. Update the consumer SKU to re-export contract types for back-compat:

   ```ts
   // packages/compliance/src/index.ts
   export type { ComplianceRuntime, ComplianceProfile } from "@pleach/compliance-contract";
   ```
4. Add a row to the table at the top of the
   [boundary rules](/docs/architecture#10-boundary-rules) page.
5. Cut a `0.1.0` of the contract package and bump the consumer to depend
   on it.

## Versioning [#versioning]

A contract package follows **its own semver** independently — that's the
whole point of Changesets. A breaking change to `ComplianceRuntime` is a
major version of `@pleach/compliance-contract`, which forces a major
version of every package that depends on it (both `@pleach/core` and
`@pleach/compliance`).

In practice this means: contract changes are **rare** and **coordinated**.
The contract is the slowest-moving piece of the architecture by design.

## Related [#related]

* [Plugin contract](/docs/plugin-contract) — the other one-way type
  boundary in `@pleach/core` (host → core via `HarnessPlugin`).
* [Language-agnostic contract](/docs/language-agnostic-contract) — the
  cross-runtime version of the same idea (TypeScript → Python via JSON
  schema).
* [Audit gates](/docs/audit-gates) — the
  `audit:no-cross-sku-type-import-in-core` definition.
