Cycle-break contract packages
Why Pleach extracts a zero-dependency `@pleach/<sku>-contract` package when a sibling SKU's type contract needs to be referenced from inside `@pleach/core`. The pattern, the audit gate, and when to use it.
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
@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
Only types that cross the SKU boundary. For
@pleach/compliance-contract that's:
- The
ComplianceRuntimeinterface — the shape@pleach/coreaccepts onSessionRuntimeConfig.complianceRuntime?:. - The
ComplianceProfileliteral union —"hipaa" | "gdpr" | "soc2" | "pci-dss". - The structural
Scrubbermirror — 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
ComplianceRuntimeruntime class — implementation, not type. - Helper utilities that aren't on the cross-SKU surface.
The audit gate
CI enforces the boundary with
audit: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/coreitself (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
Extract @pleach/<sku>-contract when all three are true:
@pleach/coreneeds 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).- The type is stable enough to ship under semver — every change to the contract package is a coordinated breaking change across all consumers.
- The cycle is load-bearing — without the contract, either core's
tsc --buildfails or every downstream consumer'stscflakes on stale.d.ts.
Do not extract a contract package for:
- Types that only flow
core → sku(e.g.,HarnessPluginlives 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
When extracting a new contract package:
-
Author
packages/<sku>-contract/mirroringcompliance-contract:package.jsonwith zerodependencies+ zeropeerDependencies,FSL-1.1-Apache-2.0license, type-only emit.src/index.tsexporting only the types that cross the boundary.tsup.config.tswithdts: true,format: ["esm", "cjs"].
-
Add the new package name to the allowlist in
scripts/audit/no-cross-sku-type-import-in-core.mjs. -
Update the consumer SKU to re-export contract types for back-compat:
// packages/compliance/src/index.ts export type { ComplianceRuntime, ComplianceProfile } from "@pleach/compliance-contract"; -
Add a row to the table at the top of the boundary rules page.
-
Cut a
0.1.0of the contract package and bump the consumer to depend on it.
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
- Plugin contract — the other one-way type
boundary in
@pleach/core(host → core viaHarnessPlugin). - Language-agnostic contract — the cross-runtime version of the same idea (TypeScript → Python via JSON schema).
- Audit gates — the
audit:no-cross-sku-type-import-in-coredefinition.