Redaction

Seamward's privacy posture is structural, not best-effort: the observation envelope has no field for payload values or request headers, so shipping them is not a configuration mistake you can make. This page explains what does leave your process, what the redaction policy actually controls, and how to verify both.

What never leaves

  • Payload values. The collector derives the payload's structure (field names and types) and its fingerprint, then discards the body. Every envelope records storage: "none".
  • Request and response headers. They are never read into the envelope at all.
  • Raw identifiers. Anything you pass as sourceEventId, idempotencyKey, or businessObjectId is converted to a keyed sha256: hash inside your process. See The hash key for whose key that is.

What ships

For the payload {"candidateId": "cand_812", "status": "hired"} the envelope carries only:

Code example

{  "storage": "none",  "schemaFingerprint": "sha256:...",  "schemaShape": {    "kind": "object",    "fields": {      "candidateId": { "kind": "string" },      "status": { "kind": "string" }    }  },  "valueProfile": {    "version": "1",    "paths": {      "$.candidateId": { "formats": ["other"], "stringLengths": [5] },      "$.status": { "formats": ["other"], "stringLengths": [5] }    }  },  "redactionPolicyVersion": "seamward-default-v1"}

Field names remain visible because contract-drift detection needs them; field values are gone before the observation is queued. The valueProfile describes each value with a label and a band, as Value profiles explains; a collector that does not profile omits it. If a field name itself is sensitive in your domain, rename it in your handler before observation, because structure is what Seamward sees.

The policy

Code example

policy: {  version: "candidate-api-v1",  dropFields: ["full_name", "email", "phone_number"],  hashFields: ["candidate_external_id"],}

Be precise about what each part controls:

  • version is the part that ships. It is stamped into every envelope as redactionPolicyVersion, so evidence is traceable to the exact configuration that produced it. Change the version whenever you change the policy.
  • dropFields and hashFields name your sensitive fields. A field named in either is left out of the value profile entirely, with everything nested under it. They also configure optional local helpers: redactPayload and redactHeaders in Node.js, or Redactor::payload() and Redactor::headers() in PHP, for your own logging or storage pipelines. They are not what keeps values out of envelopes; the schema already guarantees that. The helpers always drop the five credential headers (authorization, proxy-authorization, cookie, set-cookie, x-api-key) regardless of policy.
  • In Node.js, a custom policy replaces the default (seamward-default-v1: drops password, access_token, refresh_token, authorization, cookie, set-cookie) rather than merging with it; spread the defaults in if you want both. PHP and Laravel default to shape-only-v1 with empty dropFields and hashFields lists, so pass the complete lists you want when constructing a RedactionPolicy.

Value profiles

A shape cannot see a change that keeps the type and moves the meaning, such as pennies becoming pounds. To catch those, the collector describes each field's value without sending it. Upgrading turns this on at the structural level without any change to your configuration. Set the level only to turn it down or up:

Code example

// Node.jsconst seamward = createSeamwardCollector({  connectionKey: process.env.SEAMWARD_CONNECTION_KEY!,  ingestToken: process.env.SEAMWARD_INGEST_TOKEN!,  valueProfile: "structural",});

Code example

// PHP$collector = Collector::connect(    connection: $connection,    transport: $transport,    valueProfile: 'structural',);

Code example

// Laravel: config/seamward.php, per connection'value_profile' => env('SEAMWARD_VALUE_PROFILE', 'structural'),
LevelWhat leaves your process
offNo profile. Envelopes are exactly as before
structuralThe default. Per field: a format label from a fixed list, power-of-ten bands for numbers, and length bands
fullEverything in structural, plus keyed hashes of short free-form string values. Requires your own hash key

What a structural profile can and cannot reveal:

  • It reveals that amount is a whole number in the thousands, that created looks like an ISO date-time, and that note is between 20 and 49 characters long.
  • It cannot reveal the amount, the date, or the note. A label or a band is shared by every value that fits it.
  • The exact labels and bands are listed in the envelope reference. The envelope schema rejects anything else, so there is no property in which a value could travel.

A field named in dropFields or hashFields is not profiled at any level: no label, no band, no hash, and nothing for the fields nested under it. Matching is by field name, in any letter case, wherever the name appears. In Laravel the same lists are the drop_fields and hash_fields settings of a connection.

A profile is optional detail. If it cannot be built, the observation is sent without it and the profileErrors counter in the collector's stats increments.

Value hashes

At full, the collector also sends keyed hashes of short string values, so that a status your code has never seen, or a status that stopped appearing, can be noticed. Only strings of 64 characters or fewer whose format is other or currency-code are hashed. Emails, URLs, dates, UUIDs, and numeric strings never are. A field with more than 20 distinct values in one payload is marked as omitted instead.

Code example

const seamward = createSeamwardCollector({  connectionKey: process.env.SEAMWARD_CONNECTION_KEY!,  ingestToken: process.env.SEAMWARD_INGEST_TOKEN!,  valueProfile: "full",  hashKey: process.env.SEAMWARD_HASH_KEY!,  valueProfileExcludeFields: ["tier"],});

Understand the trade before turning it on. A hash of a field with five possible values can be reversed by anyone who holds the key and tries all five. That is why full is opt-in, why it requires a key only you hold, and why valueProfileExcludeFields exists: a field named there keeps its labels and never has its values hashed.

Without your own key, no value is hashed, in any collector:

  • Node.js and PHP refuse to start: createSeamwardCollector and Collector::connect throw.
  • Laravel keeps the connection observing at structural, because its connections are resolved while your application is serving requests. php artisan seamward:doctor reports it.

In PHP the options are valueProfile: 'full' and valueProfileExcludeFields on Collector::connect, with hashKey on the RedactionPolicy. In Laravel they are value_profile, value_profile_exclude_fields, and hash_key on the connection.

The hash key

Keyed hashes use a key that stays in your process. Which key depends on your configuration:

  • A key you supply. Pass hashKey (at least 16 characters) to createSeamwardCollector, or set hashKey on the PHP RedactionPolicy or hash_key in the Laravel configuration. Seamward never receives it and cannot compute any of your hashes.
  • The default. With no key supplied, the collector derives one from your ingest token. Seamward issues that token and receives it with every request, so this is a key Seamward could work out. It protects identifier hashes in practice because an identifier cannot be guessed, and it is not strong enough for values with few possibilities, which is why value hashes require your own key.

Changing the key changes every hash, so events hashed with the old key and the new one are not correlated with each other. Set hashNamespace in your policy to a label for the key generation, such as customer-key-2026-10, so the change is visible in the evidence.

Verify redaction

Trust, then verify, with your own traffic:

  1. Send a safe test payload containing representative sensitive fields through the instrumented handler.
  2. Open the resulting observation in Seamward and inspect its structural evidence.
  3. Confirm you see field names, types, and value-profile labels and bands only: no values anywhere, and identifiers only as sha256: hashes.
  4. If your domain treats certain field names as sensitive, confirm how they appear and adjust your handler before production traffic.

Do not disable redaction or log request bodies to troubleshoot delivery; the troubleshooting guide covers every delivery failure without either.

Next steps