Agent-First Data v0.17: Units in the Key, Structure in the Tag
v0.17 removes the _size config suffix and the parse_size helper — a byte count is a number, and its unit belongs in the field name — and finishes the lint sweep: _bcp47 and _rfc3339 structure are validated, _url must be a single URL, and duration and currency suffixes are checked to be numbers.
AFDATA had two ways to talk about bytes. _bytes held an integer — the honest,
computable form. _size held a string with a unit baked in, buffer_size: "10MiB", meant for humans hand-writing config files.
Two ways to say one thing is one way too many, and this was the wrong one to
keep. v0.17 removes the _size suffix and the parse_size / ParseSize /
parseSize helper from all four SDKs, the spec, the skill, and the machine-
readable registry. It is a breaking release — AFDATA is still pre-1.0 and carries
no compatibility layer.
Why _size had to go
The whole point of AFDATA is that a field name tells an agent everything it needs
to know about the value. _size broke that promise three ways at once.
It collided with names that mean something else. page_size, batch_size,
pool_size are counts, not byte sizes. A suffix that fires on the most common
“how many” field names is a suffix agents apply in the wrong places. Compare
_bytes, _secret, _epoch_ms — nobody writes those by accident, and that is
exactly what makes them safe.
It put a decision on every single write. "10MB" is 10,000,000 bytes;
"10MiB" is 10,485,760 — a 4.8% gap hiding in one letter. "10M" is rejected
outright, and so is a bare 10. Every value written is a fresh chance to pick
the wrong unit or the wrong casing. A numeric _bytes field decides the unit
once, at schema-design time, and never again.
It smuggled a string into structured output. _size passed through
unformatted, so "10MiB" could land in a result payload where the next agent has
to parse units back out before it can compare or sum. _bytes is a number the
whole way down.
You never needed the string anyway
The reason _size existed was human ergonomics — "128MiB" reads better than
134217728 in a config file. But AFDATA already solves that on the output side,
and has for a long time.
_bytes auto-formats in YAML and plain output, while JSON stays exact:
JSON {"max_buffer_bytes": 134217728}
YAML max_buffer: "128.0MiB"
plain max_buffer=128.0MiB
The integer is what the agent stores and computes on; the human reading a YAML
dump still sees 128.0MiB. _size was paying a parse-and-collision tax to buy a
readable form that _bytes was already handing out for free.
The rule underneath
This is the same rule durations have always followed. A timeout is timeout_s: 30, not timeout: "30 minutes". The unit is welded into the key, the value is a
plain number, and there is nothing to parse. Bytes now work the same way, in
config files and outputs alike:
The unit belongs in the field name. The value is just the number.
_size was the one place AFDATA broke that rule. Now it doesn’t.
The other theme: tags stop trusting their strings
A _bcp47 field holds a language tag — content_language_bcp47: "en-US". Until
v0.17 it accepted any string, which let the single most common mistake sail
through: zh_CN, with an underscore. That form is correct in POSIX locales and
Java’s Locale.toString(), so an agent carrying that habit writes it, it looks
right, and nothing complains — even though a BCP 47 tag uses a hyphen, zh-CN.
v0.17 adds is_valid_bcp47 and wires it into afdata lint. It is a structural
check, not a registry lookup: hyphen-separated subtags, a 2–3 letter primary
language.
zh-CN → ok
zh-Hant-TW → ok
zh_CN → rejected (underscore — the POSIX habit)
chinese → rejected (primary subtag isn't 2–3 letters)
zz-ZZ → ok (well-formed but unregistered — see below)
It deliberately stops at structure. Checking that zh and CN are actually
registered IANA subtags is a heavier guarantee, so zz-ZZ passes the structural
gate; a tool that needs the registry validates further on its own. But the change
that matters catches the mistake agents actually make.
The theme is the same as the size change: take meaning an agent would otherwise have to remember and put it somewhere the convention can check.
Finishing the sweep
_bcp47 was the first tag to stop trusting its own value. Once one suffix earns
a structural check, the ones sitting next to it that look validated but aren’t
become the real hazard — they read as safe. So v0.17 finishes the pass: every
suffix that can be checked now is.
_rfc3339 requires its offset. A _rfc3339 field names an instant —
expires_rfc3339: "2026-02-14T10:30:00Z". RFC 3339 makes the trailing offset
mandatory, because a wall-clock reading with no offset isn’t a point in time. But
the field used to check only that the value was a string, so the mistake agents
actually make walked right through: write the date and time, forget the Z.
2026-02-14T10:30:00Z → ok
2026-02-14T10:30:00.5+08:00 → ok (fractional seconds, explicit offset)
2026-02-14T10:30:00 → rejected (no offset — not a real instant)
2026-02-14 10:30:00Z → rejected (space instead of T)
2026-02-14T10:30:00Asia/Shanghai → rejected (an offset is not a timezone name)
is_valid_rfc3339 is composed from the _rfc3339_date and _rfc3339_time
validators that already shipped — a full date, a T, a partial time with
optional fractional seconds, and a real offset (Z or ±HH:MM). The three
RFC 3339 suffixes now share one spine instead of one of them trusting the string.
_url must be a single URL. A _url value is already special — redaction
scrubs credentials inside it. afdata lint now also checks it is a single URL:
no internal whitespace, and no bare user:pass@host connection string wearing a
URL’s field name. A scheme-prefixed URL keeps its userinfo (https://u:p@host is
a real URL); a relative reference (/cb?page=2) passes; a DSN or a value with a
space does not.
Durations and currency are numbers, not strings. This is the _size mistake
in a different costume: timeout_s: "30 minutes" and price_usd_cents: 12.5 are
exactly the “unit smuggled into the value” and “wrong shape” errors from earlier
in this post. Lint used to say nothing about them; now _s/_ms/_minutes/…
must be numeric and _cents/_micro/_jpy must be whole integers.
None of these change the wire format or the registry — the constraints were
always written down. What changed is that afdata lint now enforces every one it
can, so a malformed value is loud at author time instead of silent until some
downstream agent trips over it.
Upgrading
- Rename any
*_size: "10MiB"config field to*_byteswith an integer value:buffer_size: "10MiB"→buffer_bytes: 10485760. - Delete calls to
parse_size/ParseSize/parseSize. If a tool still wants to accept a human string like10MiBat its own CLI edge, that belongs in the tool, not in the shared convention — read the integer from_byteseverywhere the value is stored or emitted. - Point validators at the updated
registry.json; the_sizerecord is gone, so a lingering*_sizestring will simply read as an unrecognized field. - Write
_bcp47values with hyphens (zh-CN), not the POSIX underscore (zh_CN) —afdata lintnow flags the malformed form. - Give every
_rfc3339value an explicit offset —...T10:30:00Zor...T10:30:00+08:00, never a bare...T10:30:00. If you have a wall-clock reading with no zone, it isn’t an instant; use_rfc3339_date/_rfc3339_timeor carry the zone separately. - Keep duration values numeric (
timeout_s: 30) and minor-unit currency values integer (price_usd_cents: 1250); lint now rejects the string and float forms.
The convention got one entry smaller, one collision safer, and every silent mistake louder. Bytes are numbers again, a language tag is a tag again, and a timestamp is an actual instant.