Agent-First Slug v0.7: A Config That Describes Its Output

by Agent-First Kit Contributors

Three rules let a SlugConfig describe something other than what came out of it: case mapping ran after filtering, a delimiter could be a character the filter kept, and a fallback was validated against the target surface but never against the configuration it stood in for. All three are fixed, and two of them change generated slugs.

A SlugConfig is a promise about the output: these characters, this delimiter, this case, at most this many scalars. Three separate rules let that promise be broken, each in a way that only shows up on inputs a caller does not think to try. This release closes all three. Two of them change slugs that were already being generated, so there is an upgrade note at the end.

The alphabet that did not describe the alphabet

Case mapping ran after filtering. That reads as harmless — lowercase what survived — and it is, right up until a case mapping is not one scalar for one scalar.

U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE lowercases to i followed by a combining dot above. The filter had already run and had no say, so the combining mark went straight into the slug:

"İstanbul"  ->  "i̇stanbul"      # v0.6, with a combining dot after the `i`

No character set in this crate would have kept that mark. It was in the output anyway, which means allowed_character_set had stopped describing the output — a slug could pass validation while violating the configuration that generated it. Downstream, a caller who had checked “ASCII only” against the config and built the rest of a system on it was wrong for exactly one class of input.

Case mapping now runs before the filter, so whatever it produces is judged by the same rule as everything else:

$ afslug slugify "İstanbul"
{"kind":"result","result":{"changed_from_input":true,"code":"slugify","slug":"i-stanbul"},"trace":{}}

The combining mark is a filtered run like any other, and becomes a delimiter. Every scalar in a slug is now one the character set admits.

A delimiter you could have typed

The replacement_delimiter does three jobs at once. It marks a run of filtered characters, it is the sentinel trimmed off both ends, and — if the filter would keep it — it is also a character the caller can simply type. Those three only stay distinct while the third is impossible.

Set it to a and watch two different inputs become one slug:

"alpha beta"  ->  "lphabet"
"lpha beta"   ->  "lphabet"

The boundary before beta was never inserted, because the output already ended in a; then the trim ate the leading and trailing a off real words. Two inputs collapsed onto one identifier, and characters the caller wrote silently disappeared.

This is not something to catch downstream in validation, because by then the damage is a plausible-looking slug. It is now refused before any input is read:

$ afslug slugify "hello world" --delimiter a
{"error":{"code":"slug_error","hint":"pass a --delimiter this configuration filters out, such as `-` or `_`","message":"replacement delimiter `a` is ambiguous: the allowed character set keeps it from the input","retryable":false},"kind":"error","trace":{}}

Three ways a configuration can claim its own delimiter, all rejected: the character set keeps it, a dot policy preserves it (. under PreserveAllDots), or lowercasing changes it — an uppercase delimiter would be the one uncased character in an otherwise lowercased slug. -, _, ~ and every other character the filter removes are unaffected.

A fallback validated against nothing in particular

EmptyOutputPolicy::UseFallbackSlug covers the case where the input has nothing a slug can be made of. The fallback was inserted as written and then validated — but only against the target surface, the local-path or URL-path rules. It was never checked against the configuration that produced the empty slug it was standing in for.

So an ASCII-only, eight-character configuration would return an arbitrary-length mixed-case Unicode string and report it as validated. A caller reading “validated” as “matches my SlugConfig” was wrong in precisely the case they had reached for a fallback to avoid.

A fallback is now held to the same grammar the pipeline would have produced — character set, delimiter placement, dot policy, case, and max_slug_chars:

$ afslug slugify "!!!" --charset ascii-alphanumeric --max-chars 8 --fallback "Ünïcode"
{"error":{"code":"slug_error","hint":"pass a --fallback this configuration could itself have produced, or --fallback-verbatim to insert the value as written","message":"fallback slug does not satisfy this configuration: it contains a character this configuration would have filtered out","retryable":false},"kind":"error","trace":{}}
$ afslug slugify "!!!" --charset ascii-alphanumeric --max-chars 8 --fallback untitled
{"kind":"result","result":{"changed_from_input":true,"code":"slugify","slug":"untitled"},"trace":{}}

The waiver is explicit rather than implicit. A caller whose fallback has to match a value already stored — a legacy identifier that cannot be regenerated — says so, and gets the old behavior under a name that admits what it is:

$ afslug slugify "!!!" --charset ascii-alphanumeric --max-chars 8 \
    --fallback-verbatim "Legacy Ünïcode Name"
{"kind":"result","result":{"changed_from_input":true,"code":"slugify","slug":"Legacy Ünïcode Name"},"trace":{}}

UseVerbatimFallbackSlug checks the target surface and nothing else. The point is that the result may not satisfy the configuration that produced it, and the caller chose that.

What is still the caller’s

Two things this crate deliberately does not do are now stated in the skill, because both are load-bearing for anyone treating a slug as a stable identifier.

A slug is not an identifier. It carries no uniqueness, no authorization, and no atomic-creation guarantee. Different inputs legitimately produce the same slug, and the default configuration can produce an empty one. When uniqueness matters, allocate an authoritative ID and treat the slug as a human-readable suffix; resolve collisions where the thing is created.

Normalization is the caller’s, and it is part of the contract. Nothing here normalizes. A precomposed é and e plus a combining acute are canonically equivalent and produce different slugs, because the combining mark is filtered and the letter is not. Normalize before calling if stability across input methods matters, and record which normalization form and which Unicode version your stored slugs were generated under — those are inputs to the identifier, the same as the delimiter is.

Breaking changes

Check these against a corpus before upgrading; running v0.6 and v0.7 over your stored inputs and diffing is the honest test.

Also in this release: Agent-First Data moves to 0.34.0, CI runs the pinned ShellCheck version rather than whatever the runner image ships, and the release workflow verifies it is building the tag it was asked for, asserts the built binary reports that tag’s version, and never replaces bytes already attached to a release.

Getting it

$ brew install agentfirstkit/tap/afslug
$ cargo install agent-first-slug