Agent-First Data v0.13: Redaction Fails Closed
v0.10 scrubbed secrets inside URLs; v0.11 made redaction a policy. v0.13 closes the seams where a secret could still slip through — a marked container that leaked its non-secret siblings, a key collision that reverted to raw values, a schemeless connection string that no longer looked like a URL. When redaction is unsure, it now redacts.
A redaction rule is only as good as its worst case. The happy path — name a
field api_key_secret, watch it become *** — has worked since the start. What
matters for a credential is what happens at the edges: the value that is almost
a secret, the field that is almost a URL, the collision that almost prints
the raw bytes. v0.13 is about those edges. Everywhere redaction used to make a
hopeful guess and pass a value through, it now fails closed.
A marked container leaked its siblings
_secret redacts a field. But a field can be an object:
{"credentials_secret": {"api_key": "sk-123", "endpoint": "https://h", "user": "app"}}
The old behavior traversed a marked container: it walked inside, redacted any
nested _secret field, and left the rest. Here, nothing inside ends in
_secret, so the whole object — key, endpoint, username — printed in the clear,
even though the author had explicitly marked the container as a secret.
That is backwards. If you name something _secret, you are not asking AFDATA to
go hunting for secrets inside it — you are telling it the whole thing is one. In
v0.13 a _secret value is replaced wholesale, whatever its shape:
{"credentials_secret": "***"}
Scalar, object, array — a _secret value becomes the scalar string ***. A
container you marked can no longer leak a single byte of its contents through any
format.
A collision could revert a redacted value
YAML and Plain strip suffixes from keys, and when two keys collide after
stripping (response_ms and response_bytes both → response) the formatter
backs off and restores both to their original key and raw value. Necessary
for correctness — but if one of those keys was _secret, “raw value” meant the
real secret.
The fix is ordering. Redaction now runs before collision handling, so the
value the fallback restores is already ***. There is no path — no suffix
clash, no format quirk — where the collision logic can hand back a secret it was
supposed to hide.
A connection string stopped looking like a URL
The _url suffix scrubs secrets inside a value that is a URL — userinfo
password, secret-named query parameters — and leaves anything that isn’t a clean,
scheme-prefixed, whitespace-free URL untouched. That no-op rule is what keeps it
from mangling prose. But it had a dangerous blind spot:
{"database_url": "app:hunter2@db.internal:5432/prod"}
A schemeless connection string. No scheme://, so the surgical span logic has no
anchor — and under the old rule, “not a clean URL” meant “pass through
unchanged.” The password printed in full.
v0.13 makes that case fail closed. A _url value that isn’t a clean
scheme-prefixed URL but still carries a credential signal — an @ userinfo
sigil or internal whitespace — is now redacted wholesale to *** rather than
waved through:
{"database_url": "***"}
Benign schemeless values still pass through: a relative URL like /cb?page=2
has no @ and no whitespace, so it is left alone. The rule only tightens around
values that look like they are hiding something.
The same secret list now reaches your logs
secret_names lets you redact legacy field names you can’t rename — exact-match,
no globbing, the same opt-in list everywhere. In v0.13 “everywhere” finally
includes the logging modules. A secret_names entry now redacts the matching
field by name inside emitted log lines across all four languages, not just in the
output_* helpers. The redaction policy you configure for output and the one
your logs obey are the same policy.
The honest caveat, stated loudly
Failing closed does not mean magic. _url scrubs the userinfo password and
parameters that are named as secrets — suffixed _secret or listed in
secret_names. It does not know that ?access_token=, ?api_key=,
?code=, or ?sig= are sensitive. Those pass through unchanged unless you say
otherwise, and v0.13 documents that in bold in the spec and skill rather than
letting anyone assume a _url field makes an arbitrary URL safe to log.
The rule is still: when you own the URL, rename the parameter to the _secret
suffix; when you don’t, list it in secret_names. No built-in table of
“sensitive-looking” names — inventing one would be exactly the guessing the
convention exists to replace. What changed in v0.13 is that the structural
cases AFDATA can be sure about — a marked subtree, a credential-bearing
connection string — stop guessing in the unsafe direction.
Also in v0.13
- Two new string-format conventions.
_bcp47marks a BCP-47 language tag (language_bcp47: "zh-CN");_utc_offsetmarks a fixed offset whose canonical form is"UTC"or±HH:MM(timezone_utc_offset: "+08:00"). Both are semantic names, not formatting suffixes — they pass through readable output unchanged — and_utc_offsetis deliberately not an IANA timezone name. Use a separatetimezone_namefield if you needAsia/Shanghai. - A clearer logging envelope. Every log line now carries
code: "log"plus a separatelevel(debug/info/warn/error), so a log event can never collide with a terminalcode: "error". - Tighter logfmt. Quoting and escaping now cover the full set of line-breaking and whitespace characters — tab, form feed, vertical tab, NBSP — so a record always stays one physical line.
As always, identical in Rust, Go, Python, and TypeScript, pinned by shared fixtures. The theme of the release is a single sentence: when redaction is unsure, it redacts.