# FlowFile Packaging & Signing — Design Spec

**Date:** 2026-07-28
**Status:** Draft — pending user review

## Purpose

NiFi is used to manage complex workflows with payloads ranging from tiny
files to TB scale. Two related problems need solving:

1. **Performance.** The current pack/unpack path (`MergeContent` +
   `UnpackContent`) is disk-I/O bound under high throughput. Content lives on
   vSAN storage, which is shared, virtualized IOPS across the vSphere
   cluster — so the pain isn't any single slow disk, it's *contention*
   across every VM sharing that storage. The current flow makes multiple
   redundant passes over content on disk: an ingest write, a separate read
   to compute a content hash, a read+write to merge/pack, and a read+write
   to unpack. Each pass competes for the same shared IOPS pool. NiFi runs as
   independent single-node instances (no clustering), each with its own
   logical function, scaled horizontally by adding more instances — so
   there's no cluster-wide load balancing to fall back on.

2. **Integrity & non-repudiation.** When a FlowFile (content + attributes)
   is packed and exported outside NiFi's space, its attributes — which
   already include a hash of the content, so content hashing is not part of
   packing — need to be signed to prove they haven't been tampered with
   and to prove who produced them, in a way that holds up even to a party
   who didn't produce the artifact. Uses for the packed form: storage/
   retrieval to/from S3, delivery to other applications via HTTP or
   filesystem, and selective (date/category) replay from a catalog
   database.

A **future** requirement extends this to TB-scale files that must be
broken into pieces, tracked, transited through NiFi (for routing and
content-assurance controls — all content must pass through NiFi), and
reconstructed at the destination — including across a **one-way network
boundary** — referred to throughout this doc as **MR**, the named relay
bridging the two NiFi host chains within the overall system referred to as
**SA** (see [Deployment Topology](#deployment-topology)) — with no return
channel for retransmission requests. Forward error correction for that hop
is assumed to be available via an existing HTTP service, with ~1GB as the
target chunk size fed to it.

## Non-goals

- Redesigning NiFi's clustering model. Instances remain independent,
  single-node, horizontally scaled by function.
- Automated recovery signaling across the true one-way diode boundary — by
  construction there is no return channel; recovery there is necessarily
  operator-mediated (see [Error Handling](#error-handling)).
- Building or operating the forward-error-correction transport itself —
  assumed to already exist as an HTTP service outside this design's scope.
- A general-purpose NiFi improvement library. This design covers exactly the
  packaging, chunking, signing, delivery, and replay-indexing concerns
  described above.

## Relationship to the "Phat Files" design

A separate, already-approved spec
(`2026-07-23-phat-files-design.md`) covers low-side-S3-to-high-side-S3
transfer of TB-scale files across a one-way boundary: fixed-size blocks,
resend-vs-duplicate handling, staging by block index, server-side assembly
via `UploadPartCopy`, stall monitoring, and cleanup. That spec is treated
here as a **source of proven concepts, not a fixed dependency** — this
design is not an extension of it and does not modify it. The following
ideas are adopted (generalized beyond S3-to-S3):

- Streaming byte-range reads; never buffer a whole file.
- Chunk descriptor attributes (index, total, size, checksum).
- Server-side assembly via `UploadPartCopy` from independently-staged
  per-chunk objects, rather than a live multipart-upload session spanning
  the transport.
- Explicit resend-vs-duplicate distinction via a flag, not inferred from
  bytes.
- Stall detection + missing-chunk report, for hops with no return channel.
- Testing convention: pure logic extracted to statically-testable methods.

What's **changed**: packaging moves from stock FlowFile Package V3 to the
custom manifest-trailer format below (needed for signing and
attribute-independent reads); staging/monitoring/cleanup responsibilities
move to an external service rather than NiFi timer-driven processors;
completion tracking is dual-written to a durable marker *and* a catalog
database instead of an S3-only marker object.

## Architecture

### Deployment Topology

The whole system described in this doc is referred to as **SA**: a single
system containing two chains of independent, single-function NiFi hosts
linked by Site-to-Site (per the existing horizontal-scaling model — no
clustering, just chained instances), bridged in the middle by **MR** — the
named one-way relay this design has been built around (FEC via an HTTP
service, ~1GB chunks, per [Purpose](#purpose)). Content flows in one
direction through SA: external S3/S2S in, through the first chain, across
MR, through the second chain, external S3/S2S out. The Replay DB branches
off **before** MR, from node 1 of the *first* chain — i.e. at the true
ingest point into SA, not after delivery.

```
   +------------------------------------ SA -------------------------------------+
   |                                                                                |
   | S3 --\                                                                /-- S3     |
   | S2S --+-->[1]--S2S-->[2]--S2S-->[3]--HTTP-->[MR]--HTTP-->[1]--S2S-->[2]--S2S-->[3]--+-- S2S |
   |           |                                                                        |
   |         "copy"                                                                     |
   |           v                                                                        |
   |     +-----------+                                                                  |
   |     | Replay DB  |                                                                  |
   |     +-----------+                                                                  |
   |      /          \                                                                  |
   |     v            v                                                                  |
   | S3 (large files) XFS (small files)                                                  |
   +------------------------------------------------------------------------------------+
```

Chunking is applied uniformly at ingest (below) specifically so the same
chunked representation transits **both** the intra-chain S2S hops **and**
the MR relay — there's no separate chunking scheme per transport.
Hashing happens at this same ingest point — node 1 of the first chain, the
true entry into SA — consistent with the single-pass design below.

### Per-FlowFile Pipeline

```
                              INGEST (custom NiFi processor;
                                       replaces ListenHTTP for HTTP,
                                       or first-in-flow processor
                                       right after the S2S Input Port)
                                         |
                                         v
                     Single streaming pass: write content to NiFi's
                     content repo (required — all content must transit
                     NiFi for routing/content-assurance) WHILE computing
                     the content hash and emitting a chunk boundary every
                     ~1GB. No pre-ingest size check or path branching —
                     chunk count falls out of content length; a small file
                     is just the one-chunk case.
                                         |
                                         v
                     Ordered attribute list (incl. content hash, chunk
                     descriptors) is clear-signed in-process (Bouncy
                     Castle OpenPGP, key via Controller Service — see
                     Signing) and stored as a new attribute.
                                         |
                                         v
                     FlowFile(s) proceed through normal NiFi routing /
                     content-assurance flow logic.
                                         |
                                         v
                     DELIVERY: per-chunk content streamed once (single
                     content-repo read) to destination staging.
                                         |
                                         v
                 +-----------------------------------------------+
                 |                  DESTINATIONS                    |
                 |  S3: stage per-chunk objects, assemble via        |
                 |       UploadPartCopy (no re-transfer)             |
                 |  filesystem: pwrite chunks at offset, preallocated|
                 |  HTTP: deliver assembled object to receiving app  |
                 |  diode: chunks -> FEC HTTP service (~1GB each)    |
                 +-----------------------------------------------+
                             |            |
                     write marker    upsert row
                     (durable,        (fast index,
                      per-dest)        rebuildable
                             |          from markers)
                             v            v
                 +-----------------------------------------------+
                 |     EXTERNAL SERVICE (post-NiFi only):            |
                 |     assembly (UploadPartCopy), stall monitoring,  |
                 |     cleanup, catalog DB maintenance                |
                 +-----------------------------------------------+
                             |
                             v
                 +-----------------------------------------------+
                 |     CATALOG / REPLAY DATABASE                    |
                 +-----------------------------------------------+
```

A prior iteration of this design considered a sidecar process fronting
ingestion (to bypass NiFi's content repository entirely for large
payloads, via a claim-check pattern). That's rejected: **all content must
go through NiFi** for routing and content-assurance controls, which removes
the main justification for a front door outside the JVM. The external
service's role is narrowed to post-NiFi work that never needs to see raw
content: assembly, monitoring, cleanup, and catalog indexing.

## Packaged Artifact Format

This is the format used for filesystem/XFS delivery always, and for S3
delivery when the attribute set is too large for S3 user object metadata
(see [Storage Backend Handover](#storage-backend-handover)). Content and
its manifest travel as **one object**, not two, for end-to-end
loss protection: two separate objects (content + manifest) have independent
failure/lifecycle/replication paths that can diverge — e.g. content lands
but the manifest write fails, or a lifecycle rule reaps one and not the
other. A single completed write (one `PutObject`, or one completed
multipart upload) can't end up in that split state.

```
   PACKAGED OBJECT (single PutObject, or single completed multipart upload)
   +---------------------------------------------------------+
   |   CONTENT (1 object, or N ~1GB parts via multipart)       |
   +---------------------------------------------------------+
   |   MANIFEST TRAILER (final part -- direct UploadPart, not  |
   |   UploadPartCopy, so it can be arbitrarily small)          |
   |   = JSON document, e.g.:                                   |
   |    { "format_version": ...,                                |
   |      "clearsign": "-----BEGIN PGP SIGNED MESSAGE-----      |
   |                     ...ordered attribute list...           |
   |                     -----BEGIN PGP SIGNATURE-----...      |
   |                     -----END PGP SIGNATURE-----" }         |
   |   The clearsign field is self-contained and human-         |
   |   verifiable via `gpg --verify` independent of the JSON     |
   |   wrapper around it.                                       |
   +---------------------------------------------------------+
   |   FOOTER (fixed size, e.g. last 12 bytes: magic + 8-byte   |
   |   big-endian trailer length) -- self-describing, storage-  |
   |   agnostic; works identically on S3, filesystem, and HTTP  |
   +---------------------------------------------------------+

   Optional S3 object tag (attr-size = trailer byte length) as an
   accelerant: lets the catalog DB or a reconciliation scan jump straight
   to the trailer's byte range without reading the footer first.
```

Manifest is written **after** content by construction — the ingest pass is
single-streaming, so the final content hash isn't known until content is
fully read, ruling out a header-first format (which would require either
buffering to know its size upfront, or a second pass to fix it up).

**Attribute-only reads** (replay filtering by date/category, signature
verification, routing) use a suffix-range read — `Range: bytes=-N` where
`N` is the trailer size (from the catalog DB, the S3 tag, or the footer) —
which never touches the (possibly TB-scale) content bytes and completes in
one round trip when the DB already knows the offset.

For chunked files, S3 multipart uploads mix `UploadPartCopy` parts (the
staged content chunks) with one ordinary `UploadPart` (the manifest
trailer — no 5MB minimum applies to the last part), so `
CompleteMultipartUpload` produces one final object with the same
content-then-trailer-then-footer shape regardless of size.

## Signing

- **Format:** OpenPGP detached-style trailer via `gpg --clearsign`
  semantics — clear-sign the **canonical ordered attribute list** (sorted
  by attribute key, including the content hash and any chunk descriptors),
  producing a self-contained ASCII-armored block (plaintext + embedded
  signature). The result is stored as a new FlowFile attribute, then
  becomes the `clearsign` field of the JSON manifest trailer at pack time
  (see [Packaged Artifact Format](#packaged-artifact-format)) — the JSON
  wrapper carries packaging metadata (format version, chunk descriptors)
  alongside the signed block, without altering the signed bytes themselves.
- **Why clear-sign / OpenPGP over a bespoke signature scheme:** an operator
  on the far side of the one-way diode boundary can verify an artifact by
  hand with stock GnuPG and a public key — no custom SDK needed. This
  matters because that boundary already has a manual-operator recovery
  model (missing-chunk reports carried by hand, per Phat Files);
  OpenPGP fits that operational reality directly.
- **Where signing runs:** in-process, via Bouncy Castle's OpenPGP API (not
  shelling out to the `gpg` CLI per FlowFile — too slow at throughput). No
  external signing service — this was evaluated and explicitly rejected in
  favor of keeping the architecture self-contained within NiFi. The private
  key is loaded via a Controller Service (same pattern as the existing
  `AwsCredentialsProviderService`), plain in-JVM key material protected by
  NiFi's sensitive-property encryption and OS-level file permissions.
- **Non-repudiation trade-off, stated explicitly:** non-repudiation depends
  on the private key being exclusively controlled by one identity. Moving
  signing in-process (vs. a network-isolated external service) means the
  key now lives on the same host as ingestion, so custody depends on
  host-level protections rather than network isolation. Mitigations: only
  the NiFi instances actually responsible for packing/export have the
  signing Controller Service configured (not every instance in the
  deployment); NiFi's provenance repository gives an audit trail of which
  flow/processor invoked signing and when, partially substituting for the
  audit log a dedicated signing service would give for free. This is not a
  one-way door — the Controller Service abstraction means a PKCS11/HSM
  token could be swapped in later as a stronger key-custody option without
  changing processor logic, if ever required.
- **Algorithm:** Ed25519 preferred for speed and small signature size at
  throughput, unless a compliance requirement (e.g. FIPS mode) forces
  RSA/ECDSA instead — not yet confirmed as a constraint.
- **Tamper-detection vs. non-repudiation:** a symmetric HMAC alternative
  was considered and rejected. HMAC would detect unauthorized modification
  (any party without the shared secret can't forge a valid tag) but cannot
  prove authorship to a third party, since every verifier necessarily holds
  the same secret and could in principle have produced it themselves. The
  stated requirement is non-repudiation, which needs an asymmetric scheme.

## Storage Backend Handover

Final storage backend is chosen by size, separate from the ingest-time
chunking decision (which is uniform, no threshold — see Architecture):
large files land in S3, small files land on a local/shared XFS filesystem.
This split exists because S3's per-request overhead and cost make it a
poor fit for very high volumes of tiny objects; XFS is cheaper and faster
for that case. **Open question, not yet resolved:** if S3 can be shown to
serve small files efficiently enough (e.g. via batching, a different
bucket/storage class, or acceptable cost at the actual small-file volume
expected), the XFS tier could be dropped and S3 used uniformly — this
needs validating against real small-file throughput numbers before
implementation, see [Open Items](#open-items-for-implementation-planning).
The JSON-trailer packaged artifact format is shared by both backends, but
S3 additionally supports an existing metadata-based mechanism for small
attribute sets that avoids the trailer entirely — see below.

### S3 path

**Attribute handover on S3 follows an existing, established handover
interface specification already used elsewhere in this environment — this
design integrates with it rather than replacing it.** Under that spec:

- Attributes are written as S3 user object metadata (`x-amz-meta-*`
  headers) when small enough to fit. A regular expression (defined by that
  existing spec, not this one) identifies which user metadata keys get
  turned into NiFi attributes on read — so consuming the attributes is a
  single `HeadObject` call, no content touch, not even a suffix-range read.
- A flag (also part of that existing spec) records whether the attribute
  set exceeded S3's ~2KB user-metadata limit. When it does, the object
  falls back to **this design's** JSON manifest trailer (Packaged Artifact
  Format) instead of metadata.
- The exact regex pattern and flag key/tag name live in the existing
  handover interface spec document, not reproduced here — see
  [Open Items](#open-items-for-implementation-planning).

So for S3 specifically, the JSON trailer is the **fallback path** (oversized
attribute sets), not the only mechanism — small-attribute objects never get
a trailer at all. Filesystem/XFS delivery, which has no equivalent native
metadata mechanism, always uses the JSON trailer.

```
   NiFi (per chunk FlowFile, content already in repo from ingest)
        |
        |  PutObject  (single read of the content-repo claim --
        v   the only disk touch on the delivery side)
   S3 staging:  staging/<generation-id>/<chunk-index>
        |
        |  (all N chunks staged; external service detects
        v   completeness via catalog DB or S3 listing)
   UploadPartCopy x N  -- server-side, zero bytes touch NiFi or the
        |                external service
        v
   Attributes fit in 2KB?
     yes -> PutObjectMetadata / CompleteMultipartUpload with attributes
            as x-amz-meta-* headers (no trailer)
     no  -> + UploadPart (JSON manifest trailer), oversized flag set
        v
   Final object at <key>
        |
        v
   write durable completion marker + upsert catalog DB row
        |
        v
   delete staging/<generation-id>/*  (cleanup)
```

### XFS path (small files, below the size threshold)

No staging/assembly needed — small files are single-chunk by construction
(Architecture), so NiFi writes the packaged object (content + JSON trailer
+ footer) directly to its final path in one `write()` call. The external
service still writes the durable completion marker (a sibling file, or an
xattr, depending on implementation preference) and upserts the catalog DB
row, same as the S3 path, so replay and reconciliation behave identically
regardless of backend.

Chunk upload failures for **plain S3/filesystem/HTTP delivery** are
ordinary NiFi FlowFile retry — Phat Files' resend/stall-report machinery is
reserved for the actual one-way diode hop, where there's no return channel;
applying it to destinations that do have bidirectional connectivity would
be unnecessary complexity.

## Catalog / Replay Database

One row per packed artifact, written alongside the durable completion
marker (same write-marker-then-upsert-row ordering as Section
"Architecture"). Indexed for date/category replay queries: which backend
(S3 or XFS), object location (S3 key or filesystem path), the clear-signed attribute list
(so filtering never touches the object), completion status, and
generation/chunk-set id for large files.

The database is explicitly a **rebuildable index, not a source of truth**
— the durable marker plus the trailer/footer on disk or S3 is authoritative,
and a reconciliation job can rebuild the catalog by scanning objects and
reading trailers if the database is ever lost, corrupted, or migrated.
Replay is: query by date/category → get object locations → re-inject as
FlowFiles via the same ingest path used for first-time delivery.

## Error Handling

| Condition | Handling |
|---|---|
| Checksum mismatch on chunk arrival | Route to failure, never staged |
| Resend vs. unrequested duplicate | Explicit flag on the FlowFile, checked against the durable completion marker (adopted from Phat Files) |
| Diode hop: chunks stop arriving | No return channel by design; stall detection produces a missing-chunk report an operator carries back manually (adopted from Phat Files). Loss tolerance in transit is handled by the external FEC HTTP service, out of scope here. |
| Non-diode destination delivery failure (S3/filesystem/HTTP) | Ordinary NiFi FlowFile retry — no resend/duplicate machinery needed, a return channel exists |
| Signature or trailer corruption on read | Rejected outright — never treated as a valid artifact for replay or delivery, same handling as a checksum mismatch |
| Catalog DB unavailable or inconsistent | Non-blocking for delivery (DB is an index, not authoritative); reconciliation job rebuilds it from durable markers/trailers |

## Testing

Following the convention established in Phat Files: pure logic extracted
to package-private static methods, unit-testable without a live NiFi/S3
connection —

- Canonical attribute-list ordering and serialization (must be
  deterministic for clear-sign to produce consistent output for the same
  attribute set).
- Chunk-boundary computation from a content stream (byte offset math, ~1GB
  target size).
- Footer encode/decode (magic + length) and suffix-range calculation.
- Missing-chunk diffing (expected range vs. staged contents) — adopted
  from Phat Files.
- Resend-vs-duplicate decision logic — adopted from Phat Files.

End-to-end behavior (ingest → sign → chunk → stage → assemble → catalog
row → attribute-only read via suffix range → replay) verified against the
lab's MinIO instance, consistent with prior processors in this
environment.

## Open Items for Implementation Planning

- Exact NAR/module layout for the new ingest processor(s), Controller
  Service (signing key), and the external post-NiFi service.
- Concrete canonical attribute-list serialization format (sorted
  `key: value` lines vs. canonical JSON) — functionally equivalent, pick
  one for implementation.
- Confirm whether Ed25519 is acceptable or a compliance constraint (e.g.
  FIPS) forces RSA/ECDSA.
- Concrete default values: chunk size (default ~1GB, confirm whether it
  should differ between diode-bound and non-diode-bound chunked delivery),
  stall timeout, cleanup retention window.
- Catalog database technology selection (not yet specified).
- Whether HTTP delivery to receiving applications needs the same suffix-
  range attribute-only read support, or always transfers the full object.
- **S3-vs-XFS size threshold.** Needs validating against real small-file
  volume/throughput numbers — if S3 can serve small files efficiently
  enough at the actual expected volume, the XFS tier could be dropped
  entirely and S3 used as the sole backend, simplifying Storage Backend
  Handover to one path instead of two.
- Exact JSON trailer schema beyond the `clearsign` field (format version,
  chunk descriptor list — whether chunk descriptors are duplicated at the
  JSON top level for easy programmatic access, or only present inside the
  signed attribute list and require parsing the clearsign block to read).
- **Get the existing S3 handover interface spec document** referenced in
  Storage Backend Handover: the exact regex used to map `x-amz-meta-*`
  keys to NiFi attributes, and the exact flag/tag name and value convention
  used to signal the >2KB fallback to the JSON trailer. This design assumes
  that spec's behavior but doesn't reproduce its details.
