# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versioning note: the repository tag versions **the contract** (file set, key names,
case ids, disposition semantics). Each JSON file additionally carries its own
`"version"` field, bumped when that file changes.
## [0.6.0] — 2026-08-13
**A seventh active-content case, and the whole raw-HTML classifier moves forward with it.
Adopting one new label meant adopting the function that produces it — `active_tag_class` is
one function with three branches, and publishing the label on top of the old predicate would
have shipped a contract that looks complete and is wrong.**
### Added
- `conformance/active__raw-html-link` — the seventh case scoped to
`signatures/active-content.json`, one per published id. Corpus goes 93 → **94**. Input
`t`, taken **verbatim** from the seed
runtime's own coverage matrix (`coverage.py`, extracted from its AST, not retyped), so this
case sits inside the corpus's usual provenance rather than under `authored_payloads`.
Measured at `be9759b` (v0.7.0) through `scan_output(text, source=Source.OUTPUT)`: one
in-scope finding, `active:raw-html-link` at MEDIUM, no residue.
**One input witnesses both halves of the upstream change.** The opening `` is the
carrier split; the closing `` is the no-URL narrowing and is inert, which is why the
finding's count is 1 and not 2.
- `signatures/active-content.json` `0.2.0` → `0.3.0` — the id `active:raw-html-link`,
adopted verbatim. **No name was chosen and neither runtime was asked to choose one**: the
seed runtime shipped the class string, and this file's own derivation rule (`"active:"` +
the `constructs` key) turns it into an id mechanically. Only one runtime implements this
table, so unlike `codepoints/carriers.json` there was no second namer to consult and no
decision to ratify.
New in the same file: `html.algorithm` (the classifier as four ordered steps),
`html.url_affordance_tags`, `html.link_tags`, `html.mutator_tags`,
`html.url_attr_external_test`, and `pass_order.raw_html_pass_is_one_scan_two_buckets`.
### Changed
- **`signatures/active-content.json` is no longer a single-pin extraction.** Everything
outside the raw-HTML classifier stays at v0.3.4 / `0bf0729`; the classifier is re-pinned
to v0.7.0 / `be9759b`. Two pins, stated as two, because one pin would misdescribe both
halves. The v0.1.0 behaviour-preservation invariant covered the *extraction* and does not
forbid tracking the seed runtime forward.
- **The drift was measured field by field, not assumed.** Every regex, severity, tag list and
numeric floor the file publishes was compared against the v0.7.0 module by importing it and
reading the compiled objects — after stripping Python's inline-flag rendering and applying
the file's own declared `redundant-quote-escape` normalisation, so a spelling difference
could not masquerade as drift. **Exactly one published field had drifted**, and it is not
the one this release set out to change:
`html.active_tags` was publishing the **mutator's** name set (23 entries, with `base`)
where the `active_tag` gate means the **scanner's** (22, without). Correct at the 0.3.4 pin,
when the runtime had one set; wrong from the runtime's 0.6.0, when the two diverged. The
full 23 are preserved as `html.mutator_tags` rather than deleted — a consumer that also
defangs needs them, and it is the value this key used to carry.
Everything else — every construct pattern, both gate patterns, all of `url_shape`,
`defang`, `evidence` and the opacity floors — is byte-identical at 0.3.4 and at 0.7.0.
- **`active:raw-html` narrows, and a new `behaviour_change` block in the file says how.**
``/`` move out into `active:raw-html-link` (HIGH → MEDIUM, and a different label);
tags whose whole affordance is a URL they do not carry (``, ``, ``,
`
` with no `src`) go silent; `base` leaves the scanner set; a URL attribute on a
name outside the active set must now point somewhere external. All four **narrow or
downgrade** — no consumer gains a new false positive by upgrading — but the narrowing does
silence findings 0.2.0 raised, so the minor bump is argued in the file rather than asserted.
- `conformance/manifest.json` `0.4.0` → `0.5.0` — `count` 93 → 94, active-content scope
6 → 7, and a new `active_content_measurement_0_7_0` block. Separate from
`active_content_measurement`, which is dated 2026-08-10 at `de09711`; stretching that
header over a 2026-08-13 measurement at `be9759b` would let one pin stand for two.
### Measured
- **The movement sweep covers 93 cases, not the 6 obvious ones.** The narrowing can silence
an `active:` finding anywhere in the corpus, including inside the `observed_out_of_scope`
evidence carried by *lexicon*-scoped cases — a field no runtime's test suite ever checks,
so a stale entry there survives forever. Every committed case was re-run at `be9759b`
through the entry point its own scope pins, and compared against both places a fixture
records a guard label.
**One case moved:** `html-obfuscation__aria-label`. Its input
`link` carries no URL attribute, so both its
tags fall to the narrowing and the guard's `active:raw-html` residue is gone. Its verdict is
unaffected — the case is lexicon-scoped and its in-scope set is unchanged.
**The fixture was deliberately not rewritten.** Its recorded residue is true at the commit
`measurement` pins (`0bf0729`, 2026-08-10), and rewriting this one would leave 82 residue
records at one commit and one at another under a header naming a single commit. The drift is
recorded, dated and pinned in the manifest instead. Re-measuring the whole lexicon corpus at
a newer commit is a separate decision and has not been taken.
The six existing active-content cases did **not** move — checked, not assumed.
- **The published classifier was rebuilt from the JSON alone and differential-tested.** The
independent checker implements `html.algorithm` from the data file and nothing else — no
import from the runtime — and compares it against the runtime's `active_tag_class` over 42
probe tags spanning both classes, the narrowing, the event-handler precedence and the
outside-the-name-set branch. **0 disagreements.** That check, rather than the prose, is what
licenses publishing a classifier as data.
- Verification followed the same rule as the carrier mint: a separate checker re-read the
fixture from disk, recomputed the digest with two tools, re-derived the pattern id lexically
from the directory name, re-resolved membership from the published id space, and applied
`exact-within-scope` independently of the generator. All seven published ids were
additionally reproduced from the runtime's own payloads. **0 failed checks.**
### Not done
- **No `aliases.llm_security` was published anywhere.** `codepoints/carriers.json` still
carries an empty alias slot, waiting on that runtime to name its carrier label. Publishing
an alias is the irreversible act — their suite registers a table if *one* node in the file
carries the key — and this release does not touch it.
- `signatures/active-content.json` still carries no `aliases` block at all, for the reason it
always has: the sole implementing runtime's emitted label **is** the id.
## [0.5.0] — 2026-08-13
**Three cases minted, and the id they carry is the first in this repository that was *named*
rather than adopted. Both consuming runtimes answered the decision request of 2026-08-11, and
they did not ask for the same name.**
### Added
- `conformance/carrier__zero-width`, `carrier__bidi-override`, `carrier__unicode-tag` — the
first cases scoped to `codepoints/carriers.json`. Corpus goes 90 → **93**. Measured through
the guard's input surface `sanitize(text, source=Source.INPUT)` at `be9759b` (v0.7.0),
findings read at `.report.findings`. Each input produces exactly one in-scope finding and no
residue.
- `codepoints/carriers.json` `0.1.0` → `0.2.0` — a `pattern_id_space` block carrying
`carrier:zero-width`, `carrier:bidi-override` and `carrier:unicode-tag`, each aliased to the
guard's label. **These ids name what was OBSERVED, not what was done about it**, and that is a
departure from how every other id space here was built: the lexicon's 83 and active content's
6 were adopted verbatim from a runtime's own labels.
The guard ruled — measured, and correctly — that its `sanitize:` prefix names the producing
module in `Finding.detector` and never an action, offering all three for verbatim adoption.
The other runtime, asked the question directly, answered that the name would make its
conformance result read as a claim about neutralisation it does not perform. Two things
decided it against verbatim adoption. First, the guard's own unprompted correction: prefix ==
detector holds for those six labels and is **no general law** in its runtime (`egress:*`
carries `detector="output"`; decode-and-rescan yields two-part `decoded:lexicon:*`), and a
prefix whose meaning is recoverable only by reading one implementation cannot carry a *shared*
id space. Second, a measurement taken here on 2026-08-13: on the very surface the guard's
ruling pinned, `sanitize()` returns changed text on all three carriers — so the counter-
argument's decisive case, that `scan_output` mutates nothing, does not reach this surface.
This is not a mediation between two runtimes. Neither claimed the shared id must equal its own
label, and the shape is the one the lexicon already had: `override:ignore-previous` carries two
*different* alias strings today, and neither runtime's label is the id.
- `conformance/manifest.json` `0.3.4` → `0.4.0` — `entry_points_by_scope`, stating entry point,
**findings accessor** and **fixture presentation** per scope per runtime. The corpus previously
pinned one entry point per runtime for all cases, which held for exactly as long as every case
was reachable from one surface. Neither pinned entry point reaches carriers. Both runtimes
said independently that the manifest was the right place for this: the guard rather than grow
an API wrapper for this corpus's benefit, llm-security to record that its carrier entry point
is path-based and **cannot measure a bare `input.txt`** — the fixture must reach it as a
discovered file. Also adds `carrier_measurement`.
### Fixed
- `conformance/manifest.json` — the retired carrier blocker asserted a general rule, "THE PREFIX
NAMES THE DETECTOR", about a runtime this repository does not own. The guard corrected it at
its commit `0903785`: true of those six labels, false as a law. The claim is now **scoped to
the six** wherever it is stated, and the general form appears nowhere — checked across
`CONVENTIONS.md`, `README.md`, `spec/` and `docs/`. The retired text is kept verbatim under
`scope_planned.blockers_resolved`, because a record corrected in place cannot be audited.
### Notes
- **`not-applicable` here records a missing name, not a missing capability.** Both runtimes
implement the carrier tables — llm-security builds its zero-width, tag-range and BIDI sets from
this very file — but its carrier findings carry no id yet (`scanner: 'UNI'`, a severity and a
prose title). So `carriers.json` carries **no `aliases.llm_security`**, the file stays outside
that runtime's declared set, and the three cases are `not-applicable` there today. This is
deliberate and stated in the file: publishing that alias is the irreversible act, since it
forces the table into that runtime's declared set by force of its own test suite. The slot is
filled when the label exists and is named to us.
- **Not minted, on purpose.** No artifact-side carrier id (`output:zero-width-present`,
`output:bidi-present`, `lexicon:unicode-tags-present`) — the other runtime has no artifact
surface for carriers, so those cases could only fail there, and a permanently red gate is one a
reader learns to ignore. And no case for the ZWJ exemption: U+200D between two emoji is exempt
on both guard surfaces since v0.6.1, verified here, and the zero-width fixture carries a bare
U+200B between two letters so as not to be failed by design. Whether that exemption is a shared
rule or one runtime's policy has not been put to the other runtime.
- **The guard's v0.7.0 does not move a published fixture.** Re-measured, not inferred: all six
active-content cases produce their committed verdicts at `be9759b`, and the new
`active:raw-html-link` label is confirmed to reach `` and `` only, with
`` still grading `active:raw-html` at HIGH. Adopting that seventh construct into
`signatures/active-content.json` is a separate decision and is **not** taken here.
## [0.4.3] — 2026-08-11
**No fixture, id or `expected.json` moved — a runtime that passes `0.4.2` passes `0.4.3`
unchanged. What changed is a claim this repository made about a runtime it does not own, and the
claim was wrong on the day it was written.**
### Fixed
- `conformance/manifest.json` `0.3.2` → `0.3.4` — the `scope_planned.blockers` entry for
`codepoints/carriers.json` described the guard as emitting two stage-coupled labels per carrier,
"the same split for bidi and unicode-tag". The artifact-side label for tags is
`lexicon:unicode-tags-present`, emitted from `lexicon.py`, and `output.py` carries a comment
saying it deliberately does not repeat it there. **Wrong when written, not stale:** checked at
`e671edb` — the commit the sibling secret-egress blocker was measured against — where
`coverage.py` already asserted that label, and re-measured at `a59184b`.
The correction moves the blocker rather than removing it. The guard's `Finding` carries a
`detector` field beside `label`, and the prefix is that field's value: `detector="lexicon"` on
`lexicon:unicode-tags-present`, `"output"` on `output:zero-width-present`. **The prefix names
the detector, not the pipeline stage** — and for tags a single detector serves both entry
points, which is why there is no sixth `output:` label to find. "A commons id would have to be
invented stage-neutral" was never the problem. Six labels exist to adopt verbatim, the way the
83 lexicon ids were adopted from this same runtime's port.
What blocks adoption is measured and named instead, at llm-security `47905da`: `sanitize:`
asserts a strip that runtime does not perform (`scanners/unicode-scanner.mjs` exports one entry
point, `scan(targetPath, discovery)`, reporting presence with `scanner: 'UNI'`, a prose title
and no id); three of the six name a persist gate it does not have, which the corpus already has
a verdict for — §1.1 `not-applicable`, attaching to a declared **table** — but which
**publishing the alias is what takes away**: that runtime's suite walks each vendored file for
any node carrying `aliases.llm_security` and asserts every registered table is declared, so one
aliased carrier id forces `codepoints/carriers.json` into a declared set of what is today the
lexicon alone, obliging it to run all six cases and converting the three artifact-side ones into
failures; and the entry point pinned for it in `measurement.runtimes` (`scanForInjection`) does not
reach carriers at all, so carrier cases need a per-scope entry point this manifest expresses
nowhere. Both runtimes already build their carrier sets from `codepoints/carriers.json`, so the
divergence is in what a finding is *called* and where it can be *observed*, never in which code
points are carriers.
### Asked, not decided
- The three objections went to both runtimes over coord on 2026-08-11 as a decision request, each
asked the question only it can answer. **Nothing was minted.** A case id is contract surface
consumers pin against, and publishing a single carrier alias is itself irreversible — it widens
another runtime's declared table set by force of that runtime's own test suite. Minting first
would have made a proposal into a fait accompli. The manifest records the request, so a later
reader can tell "asked, unanswered" from "nobody asked".
- **A correction followed the request the same day, on our own error.** The request asserted that
the corpus had no third verdict for a case a runtime cannot reach. It has one — §1.1
`not-applicable` — and this repository wrote that section. The question was put before its own
normative spec was re-read; the follow-up says so to both runtimes and restates the choice as
mint-input-side-only, accept three standing failures, or publish a guard-only id space with no
`llm_security` alias at all.
## [0.4.2] — 2026-08-11
**No data file changed and no pattern moved.** A runtime that passes `0.4.1` passes `0.4.2`
unchanged; there is nothing here to re-measure. What the release adds is the rule set an outside
contributor could not previously read — including the reason the forge surface is shaped the way
it is.
### Added
- `CONVENTIONS.md` — the whole rule set a change here is held to, consolidated. **Not new
policy:** the charter lives in `CLAUDE.md`, the versioning and vendoring rules in `README.md`,
the reporting route in `SECURITY.md`, and the file conventions were visible only in the shape
of the files. Collected because a convention that exists only in the maintainer's head is not
one an outside reader can meet.
Two things in it were previously inferable at best. **Why pull requests are off:** this
repository is vendored into independent runtimes that pin a tag, so a change to detection data
changes what they *find*, and that has to be coordinated with each consumer **before it
exists** — which a merge button cannot do. `org-ops` reached that conclusion on 2026-08-11
from a README line, and the conclusion was right; this file is the ground it was missing.
**When a value may change:** the three mechanisms that have moved one so far — re-extraction,
retraction, and owner-directed authoring — each named with the `source_fidelity` key that
records it, and merit named explicitly as *not* on that list.
It also carries the four offline checks that stand in for the CI this organisation does not
have. Each was confirmed to go **red** on a violation, not merely green on a clean tree: a
JSON file with no `version`, a `spec/` file with no normative marker, and a planted `.sh` were
each detected. A check that cannot fail proves nothing. The checks are shell one-liners rather
than a script because a script would be `.sh`, and check 4 would fail on the tooling meant to
enforce it.
- `README.md` — a short **Contributing** section pointing at it, carrying the pull-request answer
inline so a reader who never opens the file still gets it. Same pattern the
**Reporting a wrong entry** section followed for `SECURITY.md` in `0.3.1`.
The four `v0.4.1` references in the install block and the layout table move to `v0.4.2`.
This closes the second half of what `org-ops` recorded as missing against the org standard on
2026-08-11. `SECURITY.md` was the first half, in `0.3.1`.
## [0.4.1] — 2026-08-11
**No data file changed and no pattern moved. A number this repository published was wrong, and
it was wrong in our favour's opposite direction — the corrected figures are larger.** A runtime
that passes `0.4.0` passes `0.4.1` unchanged.
### Fixed
- `docs/lexicon-port-divergence.md` (informative) — the ReDoS figures for
`hybrid-xss:iframe-src` read **~3× low**, and the Python `script-tag` figure at 32 000 chars
read ~4× low. Flagged by `llm-ingestion-pipeline-security` (coord, 2026-08-11T19:51:55Z), who
measured the row themselves rather than citing ours.
Their diagnosis was measurement surface — their composed `scan_lexicon()` against our
standalone regex. **Checked, and that is not the cause:** our standalone 100 000-char figure
(7.86 s) sits close to their composed 8.95 s, so the two surfaces differ by far less than the
error. Re-measured standalone, Python 3.14.0: `iframe-src` `[^>]*` is 822.7 ms at 32 000 chars
and 51 477.4 ms at 256 000, against the published 119.6 ms and 16 857 ms. The Python
`script-tag` figure at 256 000 chars *does* reproduce (5.44 s published, 5.22 s measured); the
one at 32 000 chars does not (0.021 s against 0.087 s).
The error ratios are not constant, so a single mis-sized input does not explain it, and the
original harness lived in a previous session's scratchpad and no longer exists. **The cause is
recorded as not diagnosable rather than guessed at.** The correction is a box in the document
carrying the re-measured table, and the superseded figures are struck in place rather than
quietly overwritten — a consumer who cited the old number needs to find out that they did.
Nothing about the `0.4.0` decision depends on this. Every corrected figure is larger, the
shape is unchanged (quadratic, ×4 per doubling), and both `[^><]*` forms remain flat under
both engines. The `0.4.0` entry below still quotes the old `iframe-src` figure; it is left as
published, because that section is the record of what was released.
## [0.4.0] — 2026-08-11
**Two detection values changed, by two different mechanisms, and the difference between those
mechanisms is the point of the release.** One pattern table was **re-extracted** from a pinned
upstream commit, the way every value in this repository has moved until now. Two lexicon rows were
**authored here at the source owner's direction**, which has never happened before and required a
reason that is not "we measured it and we were right."
A runtime that vendors this repository will see findings change. Any consumer asserting
byte-identity against `v0.3.0` goes red by construction — `lexicon/injection-lexicon.json` changed
pattern text. Ids, labels, aliases, family membership, case ids and every count are unchanged.
### Changed
- `lexicon/injection-lexicon.json` `0.7.0` → `0.8.0` — **`hybrid-xss:script-tag` and
`hybrid-xss:iframe-src` narrow their unbounded negated class from `[^>]*` to `[^><]*`.** Both
forms are quadratic in scan length on input that repeats the tag prefix and never supplies a
`>`: each occurrence is a match start and `[^>]*` scans to end of input from each one. Measured
in Node v25.8.2 at 16k / 32k / 64k / 128k / 256k chars — script-tag 32.65 / 113.36 / 479.02 /
1988.83 / **7772.25** ms, iframe-src 39.23 / 131.76 / 574.94 / 2469.55 / **9449.94** ms, ×4 per
doubling for both. Under `[^><]*` the same inputs cost 0.08–0.66 ms and 0.10–1.00 ms: flat, not
merely faster.
**Why this is not commons correcting seed data.** The dependency direction inverted. As of
`llm-security` `be14867` their four injection tables are built from this file and hold zero local
regex literals — measured on their published surface at `47905da`, with their vendored copy of
the lexicon confirmed byte-identical to `0.7.0`. So re-extraction was not available as a
mechanism: there is no upstream literal left to re-read. They re-measured the finding rather than
accepting it, rejected `[^>]{0,256}` because a bound is paddable and `[^>]{1,256}` because it
drops bare `` matches the pattern under *both*
forms, so it passes either way. Reverting the pattern to its `0.6.0` form fails this case
and only this case — mutation-verified in both directions across all 90.
**The first case input authored in this repository** rather than reproduced verbatim from a
runtime's payload set, recorded in the new `authored_payloads` block rather than folded into
`payload_provenance`, whose value is precisely the claim that its inputs are verbatim
upstream. That claim stays exactly as strong as it was: 83 of 83. Both witnesses for this
axis were named by `llm-security` on 2026-08-10; this is the first of the two. Findings
measured through the guard's public API at `0dce50f` / `0.5.0`, with the existing case's
committed bytes and digest reproduced by the same harness in the same run as a control.
- **`schema/conformance-declaration.schema.json` (`0.1.0`)** — the shape a runtime publishes
alongside a conformance result, satisfying the §1.1 MUST that `v0.2.0` created and left
without a form. Requested by `llm-security` in those terms (runtime, commit measured,
implemented file paths) with the stated reason that two runtimes publishing free-form
declarations makes `83/83 + 6 not-applicable` unparseable by anyone but its author.
Carries the two arithmetic invariants §1.1 implies but cannot state unambiguously in prose:
the four verdict counts MUST sum to the total, and the total MUST equal the corpus case
count at the commit measured. Requires the enumeration arrays whenever their counts are
non-zero, which turns §1.1's "MUST still be enumerated" from prose into a schema failure.
Keeps `error` and `not_applicable` structurally distinct, per §1.1. Records
`declaration_source` — whether the declared set is derived from the runner's own constant or
hand-maintained beside it — because only the derived form makes the anti-narrowing fence
structural. **Deliberately not a gate:** nothing in this repository runs, and no validation
was asked for. Mutation-tested: the example validates, and five distinct defect classes are
rejected.
- **`spec/conformance-corpus.md` §1.1** — normative pointer to that schema, plus a SHOULD that
a runtime derive its declared set from the constant its runner uses to accept or reject a
`scope`, and record which it did.
### Fixed
- `docs/lexicon-port-divergence.md` — the `hybrid-xss:script-tag` row is closed on the
closing-tag axis, having reversed twice in three days (guard-diverges → commons-diverges →
converged). What remains open is the one-byte span difference: the guard excludes `<` from
its negated class and the other two do not, so `