Skip to content

Pack Contract v2

Pack Contract v2 adds a versioned trust and compatibility layer on top of the manifest shape described in Create Your First Pack. Contract v1 (an id/version/tools/policies manifest with no contract_version field) validates a pack’s shape. Contract v2 additionally validates a pack’s trust: who published it, which kernel/host versions it works with, what other packs it depends on, and — the security-critical part — whether its executable code is allowed to run at all.

Add four fields to an otherwise-normal manifest:

contract_version: 2
publisher:
  id: acme
  name: Acme Corp
compatibility:
  kernel: ^6.0.0
  host: ^6.0.0
FieldTypeRequired for v2Description
contract_version2YesMust be exactly 2. Any other value fails with an upgrade diagnostic.
publisher.idstringYesStable publisher identifier (org or account slug).
publisher.namestringYesHuman-readable publisher display name.
compatibility.kernelstring (semver range)YesKernel versions this pack works with.
compatibility.hoststring (semver range)YesHost versions this pack works with.
depends_on{ id, version_range }[]NoOther installed packs this pack requires, and the version range needed.
migrationsMigration[]NoDeclarative upgrade steps from an older version. See Migrations.

depends_on cannot reference the pack’s own id and cannot list the same dependency twice — both fail manifest parsing immediately. Whether a declared dependency is actually installed, at a satisfying version, with no dependency cycle, is checked later by the registration gate, because that check needs visibility into every other installed pack, not just this one manifest.

Contract v1 dynamically imports and executes whatever capability_factory, policy_factory, and tool entry fields a manifest declares, in the kernel host process, with no distinction between “we wrote this” and “a manifest declared this.” Contract v2 closes that gap with a closed trust classification — every pack falls into exactly one of three tiers, and an omitted or malformed declaration always resolves to the least privileged tier, never the most:

Tieradapter_idcapabilitiesIn-process tools / factories
declarative (default)forbiddenforbiddenforbidden
trusted-host-adapterrequiredforbiddenpermitted (sanctioned by the named adapter)
isolatedrequiredrequiredN/A — runs through a capability-confined host

This closure is enforced at manifest-parse time for every contract_version: 2 manifest — a pack cannot, for example, declare trust_tier: declarative while still shipping a tools array. Declaring declarative with any in-process executable entry point (tools, capability_factory, policy_factory, cli_commands, or mcp_tools) fails validation immediately, with an actionable message telling you to pick trusted-host-adapter or isolated instead.

The default tier. A declarative pack carries data only: tools, policies, and factories are forbidden by the closure above. This is what an externally sourced pack gets by default — it cannot trigger arbitrary in-process dynamic imports no matter what else its manifest says.

trusted-host-adapter — vouched for by name

Section titled “trusted-host-adapter — vouched for by name”

A pack whose in-process code a host operator has explicitly agreed to run. The manifest names an adapter_id; that name only means something once the host’s own startup code calls HostAdapterRegistrationPort.register({ adapter_id, tier, pack_id, handle }) for it. There is no path from a manifest field to a registration call — a pack can never self-register as trusted, and the host’s registration binds to a specific pack_id, so a different pack copying a known adapter_id string does not inherit its trust (see Host-adapter registration below).

isolated — capability-confined execution

Section titled “isolated — capability-confined execution”

The most restrictive executable tier. An isolated pack never receives an adapter_id-registered in-process factory; instead it declares capabilities — an explicit resource grant, not an ambient default:

trust_tier: isolated
adapter_id: isolated:my-sandboxed-pack
capabilities:
  filesystem:
    read: ['data/**']
    write: []
  network:
    posture: off # off | allowlist — never "full"; isolated is least-privilege
  env: [] # explicit allowlist of env var names, nothing ambient
  clock:
    readable: false
  memory:
    limit_mb: 256
  cpu:
    time_limit_ms: 30000
  cancellation:
    grace_period_ms: 2000

memory.limit_mb and cpu.time_limit_ms are required, not defaulted, so an isolated pack author must make an explicit resource choice. network.posture deliberately excludes full — the isolated tier is the least-privileged executable tier, so unrestricted egress is not an option this schema can express.

Trust is never inferred from a manifest field alone. Only the host — never a pack, never a manifest — can make an adapter_id resolve to anything:

import { createInMemoryHostAdapterRegistry } from '@hellmai/lumenflow-kernel';

const registry = createInMemoryHostAdapterRegistry();
registry.register({
  adapter_id: 'trusted-host-adapter:my-pack',
  tier: 'trusted-host-adapter',
  pack_id: 'my-pack', // the ONE pack this registration vouches for
  handle: myDispatchHandle, // opaque to the kernel; host-defined shape
});

Registration is:

  • Explicit. There is no register(packId, ...) variant reachable from a manifest. A host operator’s own startup code calls register() for adapters it chose to vouch for.
  • Bound to one pack. pack_id names the pack this registration vouches for. adapter_id strings follow a public, predictable naming convention (trusted-host-adapter:<pack-id>), so without this binding a different pack could declare a legitimately-registered adapter_id in its own manifest and inherit that adapter’s trust. The trust decision checks both the adapter_id and that the registration’s pack_id matches the manifest’s own id — an adapter registered for software-delivery can never sanction an impostor pack, no matter what adapter_id string it copies.
  • Opaque. handle is unknown to the kernel — it never imports, inspects, or invokes it. Only the host’s own dispatch code knows what to do with it.
  • Idempotent by id. Re-registering an adapter_id replaces the prior registration, so a host reload cannot accumulate stale adapters.

The bundled packs use trustedHostAdapterId(packId)`trusted-host-adapter:${packId}` as their naming convention (see @hellmai/lumenflow-host’s host-adapter-registry.ts), registered once at host startup for every bundled pack that genuinely ships executable entry points. knowledge ships neither tools nor factories, so it stays declarative and is not registered here at all — least privilege means a pack that needs no executable authority gets none.

Registration alone only decides whether a pack is allowed to register. Every actual tool dispatch for a trusted-host-adapter pack is checked again, per call, against the runtime-sealed ExecutionAuthority the kernel mints for that call: the dispatch layer reads the authority’s non-forgeable pack.id, looks up that pack’s trust decision, and refuses to invoke the handler unless the decision resolves to allowed: true for the trusted-host-adapter tier. A handler that receives an ExecutionAuthority must keep its filesystem, network, process, git, and lifecycle effects provably within allowed_scopes; in-process handlers cannot inherit ambient cwd, environment, credentials, .git, or .lumenflow authority. This is the same ExecutionAuthority primitive every other in-process tool handler already consumes — Pack Contract v2 does not invent a parallel one.

Before any capability factory or policy factory is resolved for any installed pack, the kernel runs one batch check — validatePackContractV2Registration() — against every pack the host resolved. It collects every diagnostic before throwing:

  • Contract v1 (missing/wrong contract_version) — rejected with an actionable upgrade message.
  • Incompatible compatibility.kernel / compatibility.host ranges against the running kernel/host versions.
  • Missing dependencies, unsatisfied dependency version ranges, and dependency cycles.
  • Duplicate command/state/telemetry namespaces across the installed set.
  • Trust-tier denials — an adapter_id that is not registered, registered for the wrong tier, or registered for a different pack_id.

There is no partial registration: either every diagnostic is collected and the whole batch is refused, or every pack in the batch is valid and registration proceeds. A single unmigrated or misconfigured pack cannot “mostly” install.

A migration describes upgrading a pack’s already-installed state — never arbitrary code:

migrations:
  - from: '^1.0.0'
    to: '2.0.0'
    description: Rename the legacy state namespace and drop the old export tool.
    steps:
      - kind: rename_state_namespace
        from: 'my-pack:jobs'
        to: 'my-pack:job-queue'
      - kind: deprecate_tool
        name: 'my-pack:legacy-export'
        replacement: 'my-pack:export'

Every step is one of a closed set of kinds: rename_state_namespace, rename_event_kind, rename_config_key, deprecate_tool. There is no run_script or module-entry step kind, and adding one requires an ADR — a migration step is data a host-side migration runner interprets mechanically; it is never a pointer to code the kernel or host would dynamically import. A migration’s to version also cannot exceed the manifest’s own version — a migration cannot target a future the pack has not declared.

pnpm pack:author, pnpm pack:scaffold, and pnpm pack:validate consume the same DomainPackManifestSchema and the same validatePackContractV2Registration()/trust-decision engine described above — not a separate or weaker copy. A manifest that would fail live installation fails these commands too.

pnpm pack:scaffold --id my-pack --version 0.1.0 --tool my-tool
pnpm pack:validate --id my-pack

Both commands generate a contract_version: 2 manifest with publisher and compatibility already filled in. If you scaffold or author at least one tool, the manifest also gets trust_tier: trusted-host-adapter and an adapter_id following the same trusted-host-adapter:<pack-id> naming convention the bundled packs use — the manifest is schema-valid and ready to receive a real registration, but no host has vouched for it yet.

pack:validate’s report includes a Contract v2 registration check alongside manifest schema, import boundaries, tool entries, and security lint:

Pack Validation Report
=====================

  [PASS] Manifest schema
  [PASS] Import boundaries
  [PASS] Tool entry resolution
  [PASS] Security lint
  [PASS] Integrity hash
  [SKIP] Publish contract
  [FAIL] Contract v2 registration
         Error: Pack "my-pack" declares adapter_id "trusted-host-adapter:my-pack",
                which is not registered with the host.

Result: VALIDATION FAILED

That failure is expected and correct for a pack no host has vouched for yet — Host-adapter registration is a separate, later, host-side act; no pack:author/pack:scaffold invocation can grant it to its own output. pack:author still writes the generated files and reports this check honestly rather than either silently passing it or refusing to generate the pack at all — it blocks only on checks the author actually controls (manifest shape, import boundaries, tool entries, security lint, integrity).

id: my-pack
version: 1.0.0
contract_version: 2
publisher:
  id: acme
  name: Acme Corp
compatibility:
  kernel: ^6.0.0
  host: ^6.0.0
trust_tier: trusted-host-adapter
adapter_id: trusted-host-adapter:my-pack
task_types:
  - my-task
tools:
  - name: my-pack:do-something
    entry: tool-impl/my-tool.ts#doSomethingTool
    permission: read
    required_scopes:
      - type: path
        pattern: 'data/**'
        access: read
policies: []
evidence_types: []
state_aliases: {}
lane_templates: []