pleach

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 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

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/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

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

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:

    // 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 page.

  5. Cut a 0.1.0 of 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.

  • Plugin contract — the other one-way type boundary in @pleach/core (host → core via HarnessPlugin).
  • 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-core definition.

On this page