# bundle

Create Bundle artifacts to group multiple artifacts together

Canonical URL: https://docs.paradoc.dev/sdk/artifacts/bundle/

Create Bundle artifacts to group multiple documents, forms, and checklists into a single distributable package. Bundles support conditional includes and can reference content inline, by file path, or by registry coordinate.

## Examples

### Creating a bundle

```ts
const lease = p.form({
  name: 'lease',
  fields: {
    pets: { type: 'list', item: { type: 'text' } },
    vehicles: { type: 'list', item: { type: 'text' } },
  },
})

const disclosureDoc = p.document({ name: 'disclosure' })

// Object pattern
const bundle = p.bundle({
  name: 'lease-package',
  version: '1.0.0',
  title: 'Residential Lease Package',
  defs: { hasPets: { type: 'boolean', value: 'forms.lease.fields.pets.length > 0' } },
  contents: [
    { type: 'inline', key: 'lease', artifact: lease },
    { type: 'path', key: 'addendum', path: './pet-addendum.json', include: 'hasPets' },
    { type: 'inline', key: 'disclosure', artifact: disclosureDoc }
  ]
})

// Builder pattern
const bundle = p.bundle()
  .name('lease-package')
  .version('1.0.0')
  .title('Residential Lease Package')
  .def('hasPets', 'forms.lease.fields.pets.length > 0')
  .inline('lease', lease)
  .path('addendum', './pet-addendum.json', 'hasPets')
  .inline('disclosure', disclosureDoc)
  .build()
```

### Loading from external data

```ts
// Parse and validate unknown input (throws on error)
const bundle = p.bundle.from(jsonData)

// Safe parsing (returns result object)
const result = p.bundle.safeFrom(jsonData)
if (result.success) {
  const bundle = result.data
}
```

### Assembling a bundle

`assemble()` renders a set of filled content entries into their target
layers and returns the output for each. A content entry is either a filled
artifact instance (`DraftForm`, `DraftChecklist`, or `DraftDocument`) or raw
bytes for content nothing in Paradoc produced, like a scanned certificate.

There's no resolver option: each artifact-instance entry already carries the
resolver it was constructed with, so assembly never needs one of its own.

```ts
const assembled = await bundle.assemble({
  contents: {
    lease: filledLease,
    disclosure: preparedDocument,
    checklist: filledChecklist,
    insuranceCert: { kind: 'bytes', content: certBytes, mimeType: 'application/pdf' },
  },
})

// Access assembled outputs, keyed by content key
for (const [key, output] of Object.entries(assembled.outputs)) {
  console.log(key, output.mimeType, output.filename)
  // output.content holds the rendered bytes
}
```

## API

### Object Pattern

```ts
p.bundle(input: BundleInput): BundleInstance
```

#### Parameters

| Name | Type | Description |
| --- | --- | --- |
| `name` | `string` | Unique identifier; must follow slug constraints |
| `version?` | `string` | Artifact version (semantic versioning) |
| `title?` | `string` | Human-friendly name presented to end users |
| `description?` | `string` | Long-form description or context |
| `code?` | `string` | The form number as printed by its issuer (e.g. W-9, ACORD 25), or a self-issued form's own number |
| `language?` | `string` | BCP 47 source language tag; defaults to en when omitted |
| `issuer?` | `string` | Organization that issues the form |
| `edition?` | `ArtifactEdition` | Issuer edition: { key, label, date?, effectiveFrom? } |
| `metadata?` | `Metadata` | Custom metadata map (keys must be alphanumeric with hyphens) |
| `instructions?` | `ContentRef` | Domain or compliance reference content |
| `agentInstructions?` | `ContentRef` | LLM/agent prompts for presentation guidance |
| `defs?` | `DefsSection` | Named typed computed values for conditional includes |
| `contents` | `BundleContentItem[]` | Ordered list of bundle content items |

#### Returns

Returns a `BundleInstance` with the following properties and methods:

| Name | Type | Description |
| --- | --- | --- |
| `kind` | `'bundle'` | Artifact discriminator |
| `name` | `string` | Bundle name |
| `version` | `string \| undefined` | Semantic version |
| `title` | `string \| undefined` | Human-readable title |
| `description` | `string \| undefined` | Description text |
| `code` | `string \| undefined` | The form number as printed by its issuer (e.g. W-9, ACORD 25), or a self-issued form's own number |
| `language` | `string \| undefined` | BCP 47 source language tag |
| `issuer` | `string \| undefined` | Issuing organization |
| `edition` | `ArtifactEdition \| undefined` | Issuer edition |
| `metadata` | `Metadata \| undefined` | Custom metadata map |
| `instructions` | `ContentRef \| undefined` | Domain or compliance reference content |
| `agentInstructions` | `ContentRef \| undefined` | LLM/agent prompts for presentation guidance |
| `defs` | `DefsSection \| undefined` | Named typed computed values |
| `contents` | `BundleContentItem[]` | Bundle content items |

**Methods**

| Name | Type | Description |
| --- | --- | --- |
| `validate` | `(options?: ValidateOptions) => StandardSchemaV1.Result` | Validate the bundle definition |
| `isValid` | `(options?: ValidateOptions) => boolean` | Check if bundle is valid |
| `toJSON` | `(options?: SerializationOptions) => object` | Serialize to JSON (includes the current dated $schema by default) |
| `toYAML` | `(options?: SerializationOptions) => string` | Serialize to YAML (includes the current dated $schema by default) |
| `clone` | `() => BundleInstance` | Deep clone the instance |
| `assemble` | `(options: BundleAssemblyOptions) => Promise<AssembledBundle>` | Resolve contents and render runtime instances |
| `prepare` | `(contents?: RuntimeBundleContents, options?: RuntimeCreationOptions) => DraftBundle` | Create runtime bundle in draft phase. Captures the bundle clock: `options.context.asOf`, or the current instant |

### Builder Pattern

Chain methods to build a bundle incrementally:

Start with `p.bundle()`. Each method returns `BundleBuilder` and can be chained.

| Method | Description |
| --- | --- |
| `name(value: string)` | Set bundle name (required) |
| `version(value: string)` | Set semantic version |
| `title(value: string)` | Set human-readable title |
| `description(value: string)` | Set description |
| `code(value: string)` | Set external reference code |
| `language(value: string)` | Set source language (BCP 47 tag) |
| `issuer(value: string)` | Set the issuing organization |
| `edition({ key, label, date?, effectiveFrom? })` | Set the issuer edition |
| `metadata(value: Metadata)` | Set custom metadata |
| `instructions(value: ContentRef)` | Set domain/compliance reference content |
| `agentInstructions(value: ContentRef)` | Set LLM/agent presentation guidance |
| `defs(defsDef: DefsSection)` | Set all typed computed values |
| `def(name: string, expression: string \| Expression)` | Add a single typed computed value |
| `registry(key: string, coordinate: string, include?: CondExpr)` | Add registry reference from a coordinate: @org/repo/name[/edition][@version] |
| `path(key: string, path: string, include?: CondExpr)` | Add path reference |
| `inline(key: string, artifact: Artifact, include?: CondExpr)` | Add inline artifact |
| `contents(items: BundleContentItem[])` | Set all contents at once |
| `removeContent(predicate: (item, index) => boolean)` | Remove matching content |
| `clearContents()` | Remove all contents |

Terminal methods return `BundleInstance`.

| Method | Description |
| --- | --- |
| `build()` | Build and validate |

### Static Methods

Parse bundles from unknown data sources:

| Method | Returns | Description |
| --- | --- | --- |
| `from(input: unknown)` | `BundleInstance` | Parse unknown input (throws on error) |
| `safeFrom(input: unknown)` | `Result<BundleInstance>` | Parse unknown input (returns result object) |

## Bundle Content Types

Bundles can contain content from three sources:

### Adding content

Use builder methods to add content:

```ts
// Registry reference - published artifacts
.registry('lease', '@acme/forms/residential-lease/2024-03@1.0.0')

// Path reference - local files
.path('addendum', './pet-addendum.yaml', 'hasPets')

// Inline - embedded artifacts
.inline('disclosure', p.document({...}))
```

### Content Item Types

#### InlineBundleItem

Embed an artifact directly in the bundle:

| Name | Type | Description |
| --- | --- | --- |
| `type` | `'inline'` | Discriminator for inline bundle item |
| `key` | `string` | Unique key for this item within the bundle |
| `artifact` | `Document \| Form \| Checklist \| Bundle` | The embedded artifact |
| `include?` | `CondExpr` | Conditional expression for including this item |

#### PathBundleItem

Reference an artifact by file path:

| Name | Type | Description |
| --- | --- | --- |
| `type` | `'path'` | Discriminator for path-based bundle item |
| `key` | `string` | Unique key for this item within the bundle |
| `path` | `string` | File path to the artifact definition |
| `include?` | `CondExpr` | Conditional expression for including this item |

#### RegistryBundleItem

Reference a published artifact by address, with an optional edition and version. The builder takes a coordinate, `@org/repo/name[/edition][@version]`, and stores the three parts separately:

| Coordinate                     | Resolves to                                                                               |
| ------------------------------ | ----------------------------------------------------------------------------------------- |
| `@org/repo/w-9`                | The current edition, latest version                                                       |
| `@org/repo/w-9@1.0.0`          | That version of the current edition. Fails if absent; never falls back to another edition |
| `@org/repo/w-9/2024-03`        | Latest version of that edition                                                            |
| `@org/repo/w-9/2024-03@1.0.0`  | Full pin                                                                                  |
| `@org/repo/w-9/current@latest` | The same as `@org/repo/w-9`                                                               |

A two-segment value such as `@acme/disclosure` throws.

| Name | Type | Description |
| --- | --- | --- |
| `type` | `'registry'` | Discriminator for registry-based bundle item |
| `key` | `string` | Unique key for this item within the bundle |
| `slug` | `string` | Registry address of the artifact: @org/repo/name, with no edition or version |
| `edition?` | `string` | Edition key. Omitted means the registry's current edition |
| `version?` | `string` | Exact semantic version. Omitted means the latest version of the edition |
| `include?` | `CondExpr` | Conditional expression for including this item |

### Conditional Includes

All item types support conditional inclusion using defs. Item keys start with a letter and contain only letters, digits, `_`, and `-`:

```ts
const bundle = p.bundle()
  .name('lease-package')
  // Define typed computed values
  .def('hasPets', 'forms.lease.fields.pets.length > 0')
  .def('hasVehicles', 'forms.lease.fields.vehicles.length > 0')
  // Always include
  .inline('lease', lease)
  // Include only if hasPets evaluates to true
  .path('pet-addendum', './pet-addendum.yaml', 'hasPets')
  // Include only if hasVehicles evaluates to true
  .path('parking-addendum', './parking.yaml', 'hasVehicles')
  .build()
```

## Assembly Options

The `assemble()` method accepts `BundleAssemblyOptions`. `contents` is
required: there is no default set of contents, and no `resolver` option —
each content entry is an artifact instance that carries the resolver bound
when it was constructed.

| Name | Type | Description |
| --- | --- | --- |
| `contents` | `Record<string, BundleAssemblyEntry>` | Content to assemble, keyed by bundle content key |
| `renderers?` | `RendererRegistry` | Custom renderers keyed by MIME type; layers with built-in support render automatically |
| `context?` | `{ asOf?: string \| AsOf }` | The clock include conditions read for `today()` and `now()`. Defaults to the current instant |

A `BundleAssemblyEntry` is a filled artifact instance, bytes supplied
directly, or a bundle nested in this one:

| Name | Type | Description |
| --- | --- | --- |
| `DraftForm \| DraftChecklist \| DraftDocument` | `artifact instance` | Rendered through its target layer |
| `{ kind: 'bytes', content, mimeType, filename? }` | `AssemblyBytesEntry` | Content that already has its final bytes, like an annex the packet includes but Paradoc did not produce |
| `DraftBundle` | `nested bundle` | Contributes every one of its parts, keyed as a folder under its content key |

`assemble()` returns an `AssembledBundle`: the original `bundle` plus
`outputs`, a `Record<string, { content, mimeType, filename }>` keyed by
content key. A nested bundle's parts are keyed as a folder under the
parent's content key: a bundle nested as `nested` with contents `docA` and
`docB` gives `nested/docA` and `nested/docB`, with the files
`nested/docA.pdf` and `nested/docB.pdf`. Content keys cannot contain `/`, so
these names never collide with a sibling key. A runtime bundle's `render()`
names its outputs the same way.

## Sealing a packet

`sealBundle(bundle, options)` turns a bundle into one signable PDF packet. It seals each part that declares signature slots, renders the rest, flattens and merges every part's pages in bundle order, and returns one signature map in packet page space.

```ts
import { sealBundle } from '@paradoc/core'

const packet = await sealBundle(bundle, {
  contents: {
    lease: filledLease,
    addendum: filledAddendum,
    insuranceCert: { kind: 'bytes', content: certBytes, mimeType: 'application/pdf' },
  },
  signers: {
    // The same person signs both parts.
    'lease/tenant-signer': 'jane',
    'addendum/tenant-signer': 'jane',
  },
})

packet.pdf               // the merged packet
packet.canonicalPdfHash  // 'sha256:...' — what a signer signs
packet.signatureMap      // every slot, on packet pages
```

| Name | Type | Description |
| --- | --- | --- |
| `contents` | `Record<string, AssemblyContentEntry>` | One runtime artifact or byte entry per bundle content key |
| `renderers?` | `RendererRegistry` | Renderers keyed by layer MIME type, used by every part's render and seal |
| `adapter?` | `SealAdapter` | Converter for a part whose layer is neither PDF nor drawn by a registered renderer |
| `locate?` | `SealLocator` | Locator for anchor placements |
| `signers?` | `Record<string, string>` | Maps `<part>/<signerId>` to a packet signer id. Without an entry, each part signer is its own packet signer |
| `context?` | `{ asOf?: string \| AsOf }` | The clock include conditions read for `today()` and `now()`. Defaults to the current instant |

The result, a `SealedBundle`, carries `bundle`, `pdf`, `canonicalPdfHash`, `packetHash` (a digest over the merged PDF's hash and every part's), `signers`, `signatureMap`, `parts`, and `warnings`. Each part reports its `kind` (`'sealed'`, `'rendered'`, or `'annex'`), its page range in the packet, and its own digest.

Part signer ids are scoped as `<part>/<signerId>`, because two parts that both say `signer-1` are not evidence that one person signs both. Map them with `signers` when they are. A failure throws `BundleSealError`, an `Error` with a `problems: string[]` list and an optional `part: string` content key. Its `cause` preserves a lower-level rendering or sealing failure when one exists.

## Lifecycle Types

Bundles follow a three-phase lifecycle similar to forms (since bundles can contain forms that require signatures):

| Type             | Phase    | Description                         |
| ---------------- | -------- | ----------------------------------- |
| `DraftBundle`    | Draft    | Mutable contents, configure signers |
| `SignableBundle` | Signable | Frozen data, capture signatures     |
| `ExecutedBundle` | Executed | Fully frozen, ready for archival    |

```ts
// Prepare bundle with runtime contents (DraftBundle)
const draft = bundle.prepare({
  lease: filledLeaseForm,
  disclosure: preparedDocument,
  checklist: filledChecklist,
})

// Prepare for signing (SignableBundle)
const signable = draft.prepareForSigning()

// Capture a signature on the underlying form, then update the bundle's copy
const leaseForm = signable.getContent('lease') as SignableForm<Form>
const signedLease = leaseForm.captureSignature('tenant', 'tenant-0', 'john', 'sig-1')
const updated = signable.updateContent('lease', signedLease)

// Finalize (ExecutedBundle)
const executed = updated.finalize()

// executed.executedAt === ISO timestamp
```

Only included members change phase. An excluded member stays a draft, so it
never blocks signing and reinclusion keeps its answers. `prepareForSigning()`
and `finalize()` throw when a condition is unresolved or an included member
has no content.

### The bundle clock

`prepare()` captures one clock, and every include condition that reads
`today()` or `now()` uses it. A member's own clock never decides inclusion, so
members filled at different instants agree. Pass `{ context: { asOf } }` as the
second argument to fix it. The clock is kept through every phase and in
`toJSON()`.

### Save and reload

`toJSON()` stores the bundle clock and every member with its phase, and the
completion time of a checklist or document. `runtimeBundleFromJSON(json)`
rebuilds it:

```ts
import { runtimeBundleFromJSON, runtimeContentFromJSON } from '@paradoc/sdk'

const reloaded = runtimeBundleFromJSON(JSON.parse(stored))
// Bind a resolver for file-backed layers:
const withFiles = runtimeBundleFromJSON(JSON.parse(stored), (member) => runtimeContentFromJSON(member, { resolver }))
```

Loading runs the same checks as `prepare()` and the phase transitions: an
undeclared key, a member of the wrong kind, or a member in the wrong phase
throws.

### DraftBundle

Created by `bundle.prepare()`. Contains runtime instances of all artifacts.

| Name | Type | Description |
| --- | --- | --- |
| `phase` | `'draft'` | Phase discriminator |
| `bundle` | `Bundle` | The underlying bundle definition |
| `context` | `RuntimeContext` | The clock captured by `prepare()`, which include conditions read |
| `executedAt` | `undefined` | Execution timestamp (always undefined for draft) |

**Methods**

| Name | Type | Description |
| --- | --- | --- |
| `getContentKeys` | `() => string[]` | Get all content keys |
| `getContent` | `(key: string) => RuntimeInstance \| undefined` | Get runtime instance by key |
| `hasContent` | `(key: string) => boolean` | Check if content exists |
| `getAllContents` | `() => RuntimeBundleContents` | Get all contents |
| `setContent` | `(key: string, instance: RuntimeInstance) => DraftBundle` | Set content instance |
| `removeContent` | `(key: string) => DraftBundle` | Remove content by key |
| `updateContents` | `(contents: RuntimeBundleContents) => DraftBundle` | Update multiple contents |
| `prepareForSigning` | `() => SignableBundle` | Transition to signable phase |
| `render` | `(options?: RuntimeBundleRenderOptions) => Promise<RuntimeBundleRendered>` | Render all contents |
| `toJSON` | `() => RuntimeBundleJSON` | Serialize to JSON |
| `clone` | `() => DraftBundle` | Create exact copy |

### SignableBundle

Created by `draft.prepareForSigning()`. Contains signable instances.

| Name | Type | Description |
| --- | --- | --- |
| `phase` | `'signable'` | Phase discriminator |
| `bundle` | `Bundle` | The underlying bundle definition |
| `context` | `RuntimeContext` | The clock captured by `prepare()`, which include conditions read |
| `executedAt` | `undefined` | Execution timestamp (always undefined for signable) |

**Methods**

| Name | Type | Description |
| --- | --- | --- |
| `getContentKeys` | `() => string[]` | Get all content keys |
| `getContent` | `(key: string) => RuntimeInstance \| undefined` | Get runtime instance by key |
| `hasContent` | `(key: string) => boolean` | Check if content exists |
| `getAllContents` | `() => RuntimeBundleContents` | Get all contents |
| `updateContent` | `(key: string, instance: RuntimeInstance) => SignableBundle` | Update content (for signature capture) |
| `finalize` | `() => ExecutedBundle` | Transition to executed phase |
| `render` | `(options?: RuntimeBundleRenderOptions) => Promise<RuntimeBundleRendered>` | Render all contents |
| `toJSON` | `() => RuntimeBundleJSON` | Serialize to JSON |
| `clone` | `() => SignableBundle` | Create exact copy |

### ExecutedBundle

Created by `signable.finalize()`. Fully frozen.

| Name | Type | Description |
| --- | --- | --- |
| `phase` | `'executed'` | Phase discriminator |
| `bundle` | `Bundle` | The underlying bundle definition |
| `context` | `RuntimeContext` | The clock captured by `prepare()`, which include conditions read |
| `executedAt` | `string` | ISO timestamp of execution |

**Methods**

| Name | Type | Description |
| --- | --- | --- |
| `getContentKeys` | `() => string[]` | Get all content keys |
| `getContent` | `(key: string) => RuntimeInstance \| undefined` | Get runtime instance by key |
| `hasContent` | `(key: string) => boolean` | Check if content exists |
| `getAllContents` | `() => RuntimeBundleContents` | Get all contents |
| `render` | `(options?: RuntimeBundleRenderOptions) => Promise<RuntimeBundleRendered>` | Render all contents |
| `toJSON` | `() => RuntimeBundleJSON` | Serialize to JSON |
| `clone` | `() => ExecutedBundle` | Create exact copy |

## Related

* [Bundle Schema](/schemas/artifacts/bundle)
* [Document](/sdk/artifacts/document)
* [Form](/sdk/artifacts/form)
* [Checklist](/sdk/artifacts/checklist)
