[redact] policy in connector.toml
names the fields that must not travel, and decides what the world stores in their place. The
policy is one block, and every surface that writes rows reads the same one.
Redaction is optional. A world with no [redact] block stores every field as given, and every write path (data import, batches, session seed, a task’s seed) defaults to --redact off even when a policy exists; you opt in per write with apply or refuse. Each mode costs the
world something — a search route, a join, a format — so pick per field and record the reason.
The four modes
hash is the safe default. The digest is deterministic, so an exact-match lookup still
resolves and a join between two entities still lines up.
preserve exists because hash breaks format-constrained fields. A 15-digit account number
becomes a 71-character sha256: string, and the field’s own length and format bounds then
refuse the row. A shape-preserving digest stays inside the contract. Use it for identifiers —
account and routing numbers, IBANs, phone numbers — and leave free text on hash, since a
non-ASCII character is passed through unchanged.
round keeps the numeric type and the row’s shape and drops only the precision. Coordinates
are the usual case: round = { latitude = 2 } turns 37.774929 into 37.77.
drop is the only mode that answers nothing. A lookup on a dropped field misses rather than
errors, because the field was never written.
Both
hash and preserve walk into lists and redact element by element, so an array stays
an array and the world’s contract still accepts the row. Objects are left alone — name their
fields instead.Which fields a mode applies to
A bare name matches at any depth.hash = ["email"] hashes every email key anywhere in
the row, however deeply nested.
A dotted path matches that place only, with * standing for a list item. drop = ["results.*.raw_record"]
drops raw_record from each item of the top-level results list and leaves a raw_record
elsewhere in the row untouched. Use a path when a bare name would catch a key you need — a
count map whose keys happen to include email, for instance.
There is no entity scope. hash = ["contacts.phone"] is not “the phone of contacts”; it is a
phone key under a contacts object inside a row, which no row has. A bare phone applies to every
entity’s phone. gateway worlds schema check refuses a dotted path that starts with an entity name,
and — in a world without connector.toml, where the policy only ever sees rows — one whose first
segment is no entity’s top-level field. A bare name is never refused: it may be a vendor key the model
drops before the contract sees the row, or sit inside an open nested object. gateway worlds describe
shows which declared fields a policy actually lands on; a name that tags nothing there redacts nothing.
round takes bare field names only, mapped to the decimals kept, 0 through 10.
A field falls under exactly one mode. Naming the same field in two lists is refused when
the connector is parsed, with A field is hashed, dropped, rounded or preserved, never two of those. When a name and a path could both match a value, the modes rank drop, then hash,
then preserve, then round.
The salt is the connector’s slug
A hashed value issha256 of the string "<connector slug>:<value>", written as
sha256:<64 hex>. The slug is [connector] slug from connector.toml, not the world’s
platform slug. The two often differ: the spycloud-world world hashes under spycloud.
The plaintext is hashed exactly as given: no lowercasing, no trimming, no other salt. Applying
the same rule anywhere reproduces the digest the world stored.
Computing a digest by hand with the platform slug produces a value that never matches a
stored row. Read the salt from the world itself:
gateway worlds describe <slug> prints it,
and storedForms in the TypeScript SDK reads it from world.redact.slug.Where the policy runs on its own
Two paths redact without being asked:gateway worlds connector captureredacts each response body before it is written tocaptures/, so a capture on disk is already redacted.gateway worlds data extractredacts the rows it shapes, unless you pass--no-redact. Its stderr summary counts what it did:{"read": N, "rows": M, "unknownFields": {...}, "redacted": {"dropped": n, "hashed": n}}.
data import, a data batch
and a session seed all assume the caller may already hold the rows redacted — an export of
another world, or a hashed extract — because hashing a digest twice would break every join.
Captures hold real records even after redaction, because a field the policy does not name is
stored in the clear. Keep
captures/ out of anything you share.Redact on the way in
Every write path takes aredact mode, and every one of them defaults to off.
What refuse checks
refuse verifies what is verifiable from the stored value alone:
- a hashed field must hold a
sha256:<64 hex>digest, or a list of them; - a dropped field must be absent;
- a rounded number must carry no more decimals than the policy keeps.
refuse lets
it through. Use apply for a world with preserve fields.
apply and refuse both need the world’s policy, which lives in connector.toml. A world
without one is refused up front rather than silently storing plaintext:
redact=apply needs the world's [redact] policy, and this world has no connector.toml.Read a redacted world back
Three surfaces report how a field is stored and what a plaintext value becomes:gateway worlds describe <slug>stamps every field with its policy —[hash],[preserve],[round:n],[drop]— alongside entities, keys and row counts.gateway worlds data query <world> <entity> --where email=jo@acme.test --resolveturns each value you pass into the form the store holds and runs the query on that. See Put data in a world.describeWorldandstoredFormsdo the same programmatically. See Describe a world.
describe before you write them. A field marked hash or preserve
answers a plaintext lookup through resolution; a field in the clear answers it as given; a
field marked drop answers nothing.
Choosing, in practice
For each field the policy touches, run the exact lookup (it must still resolve) and the search route that filters on it (it will not), then record which trade you took. Two options when a search route breaks: keep the field in plaintext because it is not sensitive, or drop the route from the world and write it into the limits. See What makes a world good.Where to go next
- Put data in a world for the rows contract and the import path.
- The files a world is made of for every other
connector.tomlkey. - Mock any vendor API for captures, handlers and conformance.
- Describe a world and look things up by plaintext for the programmatic form.
- Link worlds together for resolution across worlds with different salts.