Chronicle.Redaction (chronicle v0.1.2)

Copy Markdown

Central protection policy for event data and Ecto record snapshots.

Protection is applied by field name, recursively, at every nesting depth. A protected field is never written to the ledger in clear text.

Built-in protection

Chronicle protects credential-shaped field names by default. Detection is deliberately generous because an audit ledger is append-only: a value written in clear text cannot be removed later without breaking the hash chain.

A field is protected when its name

  • contains password, passwd, secret, token, credential, apikey, privkey, or passphrase; or
  • has an underscore-separated segment equal to pwd, ssn, cvv, cvc, iban, pin, salt, or jwt; or
  • exactly matches a known credential or financial identifier such as api_key, card_number, or routing_number.

Built-in protection covers credentials, not general personal data. Classify application-specific personal data explicitly.

Configuration

Configured fields extend the built-in list:

config :chronicle,
  redaction: [
    fields: [:internal_note],
    hash_fields: [:email],
    omit_fields: [:raw_response]
  ]

Adding one field never silently disables built-in protection. To take complete ownership of the policy, opt out explicitly:

config :chronicle,
  redaction: [builtin: false, fields: [:password, :token]]

Effect on Ecto record versions

A withheld field cannot be reconstructed, so a snapshot containing one is reported as incomplete and Chronicle.at/2 and Chronicle.revert/2 fail closed rather than inventing a value. mix chronicle.doctor lists the withheld fields of every audited schema so this is a visible decision.

Ecto schemas may instead declare erasable: [field: :privacy_key_id]. Chronicle stores authenticated ciphertext and reconstructs it while the named external key exists. Destroying that key makes later reconstruction fail without invalidating the signed chain.

What name matching structurally cannot see

Every rule here matches on a field name, which means a sensitive value that never sits under a name is invisible to all of them. A secret embedded in a free-text description, a token inside an error message being logged as context, an element of a list whose own key did not match — none of these are protected, and no amount of extending the name lists will reach them.

This is not a gap to be closed later; it is the boundary of what the approach can do. Values that carry their sensitivity in content rather than in naming have to be marked at the point they enter, with Chronicle.secret/1, Chronicle.hash/1, or Chronicle.omit/0. Those markers are the escape hatch precisely because the policy cannot infer what it cannot name.

Summary

Functions

Returns whether a field name is protected by the built-in rules.

Resolves the effective policy once.

Builds the field redactor used for Ecto changes and snapshots.

Applies the policy to one field, returning the value or a Sensitive marker.

Returns the protected fields of an Ecto schema, for diagnostics.

Functions

builtin?(name)

@spec builtin?(String.t()) :: boolean()

Returns whether a field name is protected by the built-in rules.

compile(opts \\ [])

@spec compile(keyword()) :: Chronicle.Redaction.Policy.t()

Resolves the effective policy once.

Options:

ecto_redactor(schema, opts)

@spec ecto_redactor(
  module() | nil,
  keyword()
) :: (atom(), term() -> term())

Builds the field redactor used for Ecto changes and snapshots.

protect_field(policy, field, value)

@spec protect_field(Chronicle.Redaction.Policy.t(), term(), term()) :: term()

Applies the policy to one field, returning the value or a Sensitive marker.

protected_fields(schema, opts \\ [])

@spec protected_fields(
  module(),
  keyword()
) :: [atom()]

Returns the protected fields of an Ecto schema, for diagnostics.