llm-security-commons/CHANGELOG.md
Kjell Tore Guttormsen f0a511369d fix(spec): section 7 stated its own premise and then applied itself beyond it
Section 7 justified the fixture-is-ground-truth ordering with "Two
implementations that return different verdicts" and then stated the rule
with no scope. For signatures/active-content.json there is no second
implementation, and the seed runtime has stated the classification behind
it is calibration it does not freeze. As written, section 7 turned a change
they reserved into a bug on their side.

New section 7.1 keys the scope on a structural property, never on a table
name: a scope only one runtime implements, whose payload that runtime
authored. It creates no fourth verdict - the declaration schema closes
result with additionalProperties:false over four counts, so a fifth would
break every consumer's parser. The case still fails and is still named in
failed_cases; what changes is what the failure licenses concluding.

Two limits are stated rather than left to inference: it does not reach a
third-party implementer of the same table, and it is not a licence for a
runtime to self-declare its own divergence as calibration.

manifest.json 0.6.1 -> 0.6.2 retires the open-question sentence, quoted
rather than dropped. The retirement is partial: "section 7 is NOT amended
by this block" stays true, because the spec was amended by its own release.

Neighbours measured over the whole repository, widened past "ground truth"
to the second paragraph's own wording. CONVENTIONS.md and CLAUDE.md carried
the premise and are changed; SECURITY.md gets a cross-reference only, since
its claim is about a fixture expecting too little and 7.1 does not narrow
that direction; README.md and docs/extraction-plan.md are named as
deliberately untouched.

Breaking in category, minor in number - 0.x, per the reading [0.3.0]
recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pZ2FLQ6xkWvj2VcwgwnQv
2026-08-13 23:31:28 +02:00

1450 lines
94 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.9.0] — 2026-08-13
**A normative rule stated its own premise and then applied itself beyond it.**
`spec/conformance-corpus.md` §7 justified the fixture-is-ground-truth ordering with *"**Two
implementations** that return different verdicts…"* and then stated the rule with no scope at
all. For `signatures/active-content.json` there is no second implementation — the seed runtime
authored both the payloads and the table — and that runtime has stated that the classification
behind it is calibration it does not freeze. §7 as written made a reserved change on their side
into a bug on their side.
**Breaking in category, minor in number.** This changes disposition semantics, which the
versioning note at the top of this file counts as contract. The repository is in 0.x, where a
breaking change is a minor bump by the rules — the same reading `[0.3.0]` recorded: *read the
entry, not the version number*.
### Changed
- **`spec/conformance-corpus.md` — new §7.1, *Where the second paragraph does not hold*.** The
scope is keyed on a **structural property**, never on a table name: a case whose scope is a
table only one runtime implements, whose payload that runtime authored. A rule naming
`active-content` would rot the day a second runtime implements it. §7's own second paragraph
already carried the premise; §7.1 makes it explicit and states the disposition for the case
the premise excludes — the fixture is not rewritten on the divergence alone, the divergence is
recorded against the version pinned, and re-pinning is a separate release. That is the
disposition §5 already applies to a stale `observed_out_of_scope` entry, extended to the one
place where it can reach a verdict.
**It creates no fourth verdict, and that constraint shaped the wording.**
`schema/conformance-declaration.schema.json` closes `result` with `additionalProperties: false`
over four counts plus two arithmetic invariants; a fifth verdict would have broken every
consumer's parser, which is a worse break than the one intended. A case whose expected findings
are not produced still **fails** and is still named in `failed_cases`. What §7.1 changes is what
the failure licenses concluding, not what is reported.
Two limits stated in the section rather than left to be inferred: it does **not** reach a
third-party implementer of the same table — against them the fixture is the contract, exactly
as §7 says, and that is the only thing these cases can prove while one runtime is all there is
— and it is **not** a licence for a runtime to self-declare, since the exemption is carried by
the corpus's provenance record for the scope and not asserted per case by whoever failed.
Superseded text is named rather than edited away, following §6's own pattern: *"Through corpus
version 0.8.1 this section stated the rule above with no scope at all."*
**The competing reading was tested and disposed of**, because it is the one that would have
avoided this release: that §7's existing hatch (*"unless the fixture itself is proven wrong"*)
already covered it. It does not. The hatch's consequence is that **the fixture changes**, and
the manifest field asserts the opposite — pinned, not rewritten, re-pinning a separate
decision. And a runtime recalibrating does not prove the earlier classification wrong: the
fixture measured `de09711` / `0.4.0` correctly, and a later release does not reach back and
falsify an earlier measurement. The case fits neither of §7's two dispositions, which is the
defect.
- **`conformance/manifest.json` `0.6.1``0.6.2`
`active_content_provenance.pins_a_version_not_a_frozen_classification` no longer records an
open question.** The retirement is **partial and it is quoted, not dropped**, per the house
style this field established one release ago (*"a correction that does not say what it corrects
cannot be audited"*). What falls is only the open-question status; the clause *"section 7 …
is NOT amended by this block"* **stays true and is kept**, because §7 was amended by its own
release and not by a data file. Value change only — read back from disk against `HEAD` with a
flattened key diff: `added: 0, removed: 0, changed: 2` (the field and `version`), and the new
string printed and read rather than inferred from the count, since a value edit reports
`changed: 1` whatever it wrote.
Six prose dashes in the new text were written `--` and promoted to `—` before commit: `--` is
the variant-suffix separator token of §6's case-id grammar, and every other occurrence of it in
this file is that token, a real case id, or a CLI flag.
### Neighbours — measured, and the ones left alone are named
A sweep for the retired premise was run over the whole repository, widened past *"ground truth"*
to the second paragraph's own wording (*"one of them has a bug"*, *"two implementations"*), since
a restatement in that phrasing would have survived the first search.
- **`CONVENTIONS.md` — changed.** Carried the rule unscoped and called the proven-wrong hatch
*"the one way that reverses"*. There are now two, and both are listed.
- **`CLAUDE.md` — changed.** The Norwegian restatement that governs sessions in this repository
carried the same unscoped rule; left alone, the next session here would have acted on it.
- **`SECURITY.md` §2 — minimal cross-reference only.** Its claim is about a fixture that expects
**too little**, and §7.1 narrows *who the rule reaches*, not that direction. The conclusion
survives intact, so it was not rewritten.
- **`SECURITY.md` "Why a confirmed defect is usually not fixed here first" — untouched.** Its
*"two implementations answering differently"* is about extracted **data** diverging from its
source, not about fixtures.
- **`README.md` — untouched.** Its conformance row says *"Ground truth"* as a descriptor and does
not restate the disagreement rule, and it already names the asymmetry it would otherwise hide:
the seven active-content cases are *"measured against the one runtime that implements that
table"*. Nothing there became false.
- **`docs/extraction-plan.md` — untouched, and it is supporting evidence rather than a stale
neighbour.** It already records that the calibration file *"inverts this repository's central
rule"* — so this is the second place the unscoped rule was known not to hold, and the first was
documented before this release.
### Not in this release
Whether the seven active-content cases still pass at the seed runtime's `v1.1.0` is **unmeasured**,
and §7.1 is silent on it. No case was minted, no data file touched, no id string proposed.
## [0.8.1] — 2026-08-13
**The field 0.8.0 added to make the exposure precise stated it with a hand-derived count, and the
count was wrong.** Caught in the same session, before any consumer read it, and corrected inside
the field rather than by rewriting it. No measurement changed and no verdict moved.
### Fixed
- **`conformance/manifest.json` 0.6.0 → 0.6.1 —
`active_content_provenance.pins_a_version_not_a_frozen_classification` now enumerates instead of
totalling.** As published it read *"three of the four dimensions they name as calibration cannot
move one of these cases at all. The fourth can: which `active:` ids a payload yields IS the
classification"*. Two defects in one sentence. First, the total was derived by hand over a
taxonomy the field had itself recategorized: the seed runtime's four calibration dimensions are
severities, thresholds, **lexicon entries** and dispositions, and lexicon entries are *not* absent
from these fixtures — `active__data-uri` carries `data-uri:executable` and `active__raw-html`
carries `hybrid-xss:script-tag` in `observed_out_of_scope`, both verified as members of
`lexicon/injection-lexicon.json` and non-members of `signatures/active-content.json`. Second,
*"the fourth"* silently substituted the classification for lexicon entries as the fourth item of
their sentence, which it is not — the classification is what they addressed separately.
- The replacement names three things and totals none of them: severities/thresholds/dispositions
are absent and move no verdict; lexicon entries move no verdict either — spec section 5 forbids
failing a runtime over `observed_out_of_scope` — but a lexicon calibration change **ages** those
two entries as evidence, which is the exposure
`active_content_measurement_0_7_0.movement_sweep.residue_is_the_field_no_test_protects` already
names as a class, and this corpus pins a stale residue entry rather than rewriting it; and the
active-content classification is the one thing that can move a verdict. The retired sentence is
**quoted** in the field's `AMENDED IN 0.6.1` clause, not merely dropped, for the same reason
`scope_planned.$comment` quotes what it retired: a correction that does not say what it corrects
cannot be audited.
## [0.8.0] — 2026-08-13
**The seven active-content fixtures pin a VERSION of the seed runtime, and nothing said so.**
That runtime tagged `v1.0.0` on 2026-08-13 and stated that the freeze covers its exported Python
surface only, excluding detection behaviour: severities, thresholds, lexicon entries and
dispositions are calibration there and move in minor and patch releases. The manifest already
pinned commit and version per measurement block, but nowhere recorded that the thing pinned is a
version rather than a frozen classification. No case is minted, no data file is touched, no id is
proposed.
### Added
- **`conformance/manifest.json` 0.5.2 → 0.6.0 —
`active_content_provenance.pins_a_version_not_a_frozen_classification`.** One field, scoping the
neighbouring `asymmetry` rather than replacing it, and deliberately narrower than the runtime's
own statement. The exposure is bounded by what the fixtures assert, which was read from all seven
rather than assumed: every finding carries `pattern_id` and nothing else — no severity, no
threshold, no disposition — so three of the four dimensions that runtime names as calibration
cannot move one of these cases at all. The fourth can, because which `active:` ids a payload
yields *is* the classification. The field names both pins (six at 0.4.0 / `de09711`, the seventh
at 0.7.0 / `be9759b`) rather than one, since a single version would flatten two measurements into
one header — the defect `superseded_for_one_case` exists to prevent. Their v1.0.0 statement is
**attributed** to their coord message of 2026-08-13T20:40:31Z, not restated as a fact measured
from this side.
- The field also names the disposition of a future divergence, so it is not left to be inferred: a
later 1.x that classifies one of these payloads differently is not a breach by them and does not
make the fixture wrong. The fixture stays ground truth at its pinned version, the divergence is
measured and recorded, and re-pinning is a separate decision — the same disposition this corpus
already applies to a stale `observed_out_of_scope` entry.
### Fixed
- **`docs/secret-egress-divergence.md:75-76` carried the same misquote `conformance/manifest.json`
had corrected in 0.7.3**, named there as a deliberate omission and closed here. The field's value
ends ``ascending `order` `` — the backticks are the field's own. The fix is *not* the one the
omission note implied: those two lines are a single code span delimited by **single** backticks
across a line break, so inserting the field's backticks inside it would have terminated the span
at the first one and rendered the quote broken. The outer delimiter is promoted to double
backticks instead, which is what lets the inner singles survive. The manifest's correction ported
as a literal string because JSON has no backtick semantics; markdown does. Verified by extracting
the span from the file on disk, unfolding the line break, and comparing to the decoded value in
`signatures/secret-egress.json` — equal — and by confirming no backtick run of length ≥ 2 sits
inside the span.
### Not done, and named rather than left silent
- **`spec/conformance-corpus.md` section 7 is untouched.** It states the disagreement rule without
scope: *"The fixture is ground truth. A runtime that disagrees is wrong."* Read against the
active-content scope, whose only implementing runtime has now said in writing that its
classification may legitimately move, that rule would call a calibration change there a bug. The
manifest field records the interaction and explicitly does not amend the spec. Whether the
normative rule needs a scope is a decision for its own release.
## [0.7.3] — 2026-08-13
**The README still argued the premise 0.7.2 retired, and the two files sat on a public remote
disagreeing.** `README.md` opened the egress gap with "It is not an id question at all"; the
blocker it sends the reader to for authority now opens reason (1) with "NO ID SPACE ON THE
COMMONS SIDE. This is the hard blocker." Before 0.7.2 the README was merely out of date. After
it, our own commit had made it contradictory — the same defect class 0.7.1 existed to close. No
data moves, no case is minted, no id is proposed.
### Fixed
- **The README now carries the three measured reasons instead of the retired one.** (1) No id
space on the commons side — the hard blocker, and the only one an answer can resolve; the
answer belongs to the runtimes that own the seeds. (2) Match semantics disagree, and an id
space would not close it. (3) Membership diverges in both directions and the divergence is
inherited: the two sides hold 19 entries and 25, and they are ports of two *different* source
tables in one source repository. The counts survived the falsification; only the causal claim
fell, so `different tables` is kept and "cut at different granularities" is gone. The
paragraph deliberately does **not** restate the outgoing question's status: that is true on
the day it is written, nothing tests README prose, and `conformance/manifest.json` already
carries the date. The standing `entry_points_by_scope` requirement is likewise left out rather
than printed as a fourth reason.
- **`conformance/manifest.json` 0.5.1 → 0.5.2: the blocker misquoted the contract it cites.** It
rendered the field as `match_semantics: "… evaluated in ascending order"`; the value in
`signatures/secret-egress.json` ends ``ascending `order` `` — the backticks are the field's
own. A blocker that misquotes the semantics it is blocking on invites a consumer to implement
the wrong one. The data file is
unchanged and was never wrong — only the quotation of it was, which `scope_planned.$comment`
now records. Verified by reading the edited file back from disk and matching the decoded
string against the data file that owns it; `json.tool` passes on wrong escaping.
Known and deliberately left: `docs/secret-egress-divergence.md` renders the same value without
its backticks. That document is `Status: informative` and was outside this release's scope.
## [0.7.2] — 2026-08-13
**`scope_planned.blockers` named the premise that `docs/secret-egress-divergence.md`
falsified.** The blocker read "19 entries … 25 at different cut points" — one table cut at two
granularities, waiting on a reconciliation of two ports. Measured 2026-08-13: they are ports of
**two different source tables** in the same source repository, so no reconciliation of the ports
was ever going to close it. No data moves in this release, and no case is minted — only the
recorded reason a case cannot be.
### Fixed
- **The egress blocker now carries the three measured reasons, kept independent.** (1) Commons
has no id space for this table: seed A (`hooks/scripts/pre-edit-secrets.mjs`) carries a name
and a pattern per entry and nothing else, so entries are keyed by human-readable `name` while
the guard emits `egress:<id>`, and a fixture names labels. This is the only one of the three
an answer can resolve, and it is the outgoing question. (2) Match semantics disagree:
`first match wins` with `ordering.normative: true` here, against `finditer` over all 25
patterns there — one witness, an `Authorization` header holding a three-part JWT, produces
**one** label under commons' declared contract and **two** from the guard. (3) Membership
diverges both ways and is inherited from two different seeds (seed A 19 entries, seed B 33,
the guard ported 25, 8 unported), so re-measuring either port cannot close it. The blocker
points to `docs/secret-egress-divergence.md` for the method behind every number.
- **Two hand-carried numbers in the retired text are corrected in the same string.**
`aws-access-key-id` was called "the one clean one-to-one": measured, only **2 of 19** commons
patterns are byte-identical to a guard pattern after unescaping, and AWS is not among them —
the guard anchors the same run as `\bAKIA[0-9A-Z]{16}\b`. `GitHub Token` was called "four ids
there": measured on witnesses it maps to **three**, and leaves `ghu_` and `ghr_` covered by no
guard id. Both were transcription, not measurement. What is retracted is quoted in place; the
full retired text stands in git at `conformance/manifest.json` 0.5.0.
- **`scope_planned.$comment` said "a distinct unresolved question" — singular.** Left alone it
would tell a reader the case becomes mintable when an answer arrives, which is true of one
reason in three. Amended alongside the blocker rather than after it, since the two are read
together.
### Measured
- **The standing requirement was measured here, not transcribed from the document.**
`entry_points_by_scope.scopes` carries **no entry at all** for `signatures/secret-egress.json`
— the three declared scopes are the lexicon, active-content and carriers. Entry point,
findings accessor and fixture presentation must be filled for both runtimes before a first
egress case, independently of the three reasons. It is recorded as a requirement, not as a
fourth reason: it would stand even if all three were resolved tomorrow.
- **No id string is proposed, in this file or anywhere else.** Checked against the two outgoing
coord messages of 2026-08-13 rather than assumed: both state in as many words that no id is
being proposed. Naming an id in a shared space is the exception `carrier:*` established, it
requires both runtimes asked first, and both are unanswered.
`conformance/manifest.json` 0.5.0 → 0.5.1. No case directory, no `expected.json` and no
signature table changed; `git status` shows one file besides this changelog.
## [0.7.1] — 2026-08-13
Two loose ends from `0.7.0`, neither of which changes a contract.
### Fixed
- **The README still named the manifest as the authority for the variant rule.** It read
"see `case_id_derivation.variant_suffix` in the manifest" — true until `0.7.0`, when the
rule became normative in `spec/conformance-corpus.md` §6 and the manifest's block became
the *measurement* behind it rather than the contract. Left alone it would have reproduced
in one line the same defect `0.7.0` closed: a reader sent to the wrong authority.
### Measured
- **The `__` half of the derivation was re-measured too, not just the `--` half.** `0.7.0`
made a point of re-measuring `--` rather than copying the manifest's `0.3.0` numbers
forward, while the adjacent sentence asserting that `__` "does not occur anywhere in the
ratified id space" was inherited untested — a claim about this release's own soundness that
the release did not check. Measured now across all five published id spaces: the 83 lexicon
ids, the 7 `active:` ids, the 3 `carrier:` ids, the 7 malware rule ids and the 19
secret-egress entry names carry **neither** `__` nor `--`. The one-to-one transform holds.
No text changed; the sentence was true. It is now true *and* measured.
## [0.7.0] — 2026-08-13
**The normative spec forbade, in as many words, a case the corpus has shipped since
`0.3.0`.** `spec/conformance-corpus.md` §6 read *"Such a payload MUST NOT be given a
discriminated case id; the derivation rule is the contract, and a suffix would break the
reverse transform"* while `conformance/manifest.json` defined `case_id_derivation.
variant_suffix` and `conformance/hybrid-xss__script-tag--src-no-close/` sat on disk under it.
The manifest was the correct party; the spec was simply never updated when the derivation was
extended. **No data moves in this release — only the normative text that describes it.**
### Fixed
- **§6's derivation block now states the rule the corpus actually uses.**
```
before case_id = pattern_id with ":" replaced by "__"
pattern_id = case_id with "__" replaced by ":"
after case_id = pattern_id with ":" replaced by "__",
optionally followed by "--" and a variant slug of [a-z0-9-]
pattern_id = case_id truncated at the first "--" if present,
then "__" replaced by ":"
```
The `--`-absence measurement moves into the spec as the reason the reverse transform stays
**lexical**: a runtime MUST be able to recover a `pattern_id` by splitting the string, and
MUST NOT need a lookup against the published id list to find where the id ends and the
variant begins. Re-measured at this commit rather than copied from the manifest's 0.3.0
numbers: `--` occurs in none of the 83 lexicon ids, none of the three `carrier:` ids, none
of the `active:` construct ids, none of the seven malware rule ids and none of the 19
secret-egress entry names — and in exactly one of the 94 case ids, the variant itself. It
does occur inside *pattern* values (`<!--\s*(?:AGENT|AI|…)`, `-----BEGIN … PRIVATE KEY-----`),
which is why the claim is scoped to id spaces and not to the data files as a whole. The
spec states the property, not the counts, which is what keeps it from going stale the way
"verified collision-free across all 90" in the manifest did.
- **The predicate for minting a variant was wrong, and fixing only the permission would have
legalised the shipped case under a rule that still forbids it.** §6 reasoned that if two
payloads' in-scope finding sets are equal, the second "cannot fail in any way the first does
not." The shipped variant falsifies that: it expects the *same* single finding, in the same
scope, under the same `match`, and still gates something its base cannot — the base input
matches `hybrid-xss:script-tag` under both the published form and the stricter form that
preceded it, so reinstating the stricter form leaves it passing, while the variant input
matches only the published form and fails. The distinguishing signal is *inside* the scope,
in the form of the scoped rule itself, which is exactly what a finding-set comparison cannot
see.
The `MUST NOT` is replaced by a predicate that admits the shipped case and still excludes
the omitted one:
> A variant case MAY be minted when the second input can fail, **within the case's scope**,
> under a change to a scoped data file that the first input would pass. Where no edit to a
> published table separates the two inputs, the second case cannot fail in any way the first
> does not, and it MUST NOT be minted.
Checked against `omitted_payloads`: the guard's seventh active-content payload is
distinguished from the case already built only by `entropy:base64-blob`, and this repository
publishes no entropy table, so no edit to any scoped file separates the two inputs. It stays
omitted, on the one ground the manifest already records as standing. **That verdict is
unchanged by this release** — the manifest's own note that the derivation ground lapsed in
`0.3.0` remains the only part of it that has moved.
- **The manifest's `constraint` is now normative rather than metadata.** A variant case MUST
be scoped and matched exactly like its base case and MUST expect the same `pattern_id`; the
suffix distinguishes inputs, never findings. Two cases at one pattern id expecting different
findings within the same scope are not a variant pair.
### Unchanged, deliberately
- **`conformance/manifest.json` stays at `0.5.0` and no case directory was touched.** The
corpus version tracks the corpus; no case, no id, no expectation and no measurement changed
here. Bumping it would date 94 fixtures to a commit that only edited prose.
- **The spec carries no version of its own, and none was added.** The "Through version 0.1.1"
reference in §4 is the *corpus* version (`conformance/manifest.json` went `0.1.1` → `0.2.0`
in the `0.2.0` release), not a spec version — verified before acting, because the session
brief said otherwise. Normative specs in this repository are versioned by the repository
tag, exactly as in `0.2.0`, which rewrote §4 and §6 prose under the same mechanism.
- **§6's stable-id paragraph is untouched.** "A case id is a stable identifier. Changing one
is a BREAKING change" is a separate rule that sits in the same section; it was read, not
edited.
- **Minor, not major, and the reason is uncomfortable enough to state:** a consumer whose
reverse transform is `--`-naive has been broken since `v0.3.0`, when the case shipped. This
release documents that break; it does not create it. Nothing here changes a key, a case id
or a disposition.
### Verification
Mechanical, in scratchpad, never in the repository (charter). The amended derivation was
transcribed out of the prose into a checker that reads all 94 cases back from disk and, for
each: asserts `case_id` equals the directory name, applies the reverse transform, round-trips
it forward, and asserts the derived `pattern_id` is one the case expects. All 94 reproduce.
The variant's scope, `match` and findings were asserted identical to its base case — the new
MUST, executed rather than eyeballed — and the checker also asserts that the withdrawn
sentences are gone and that the three stable-id sentences are still present verbatim (the
diff carries them as context lines, not as edits).
## [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
`<a href="https://evil.test/collect?d=stolen">t</a>`, 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 `<a href>` is the
carrier split; the closing `</a>` 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.**
`<a>`/`<area>` move out into `active:raw-html-link` (HIGH → MEDIUM, and a different label);
tags whose whole affordance is a URL they do not carry (`</a>`, `<Frame>`, `<video />`,
`<img alt=…>` 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
`<a aria-label="ignore all instructions">link</a>` 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 `<a>` and `<area>` only, with
`<a onclick=…>` 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.080.66 ms and 0.101.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 `<script>` and two corpus cases with it, chose `[^><]*`, and asked commons to carry
it. Recorded in a new `source_fidelity.owner_directed_changes` block — deliberately **not** in
`post_extraction_drift`, which would have said the source moved and commons followed, when the
source now reads commons.
Not decided by majority. The guard reached `[^><]` first and independently (`cff0437`), so all
three runtimes now agree, but a 3-of-3 count is not what moved this value and would not have
been sufficient. The justification is the same one that kept commons on `[^>]` through `0.7.0`:
this file tracks its declared source, and the declared source chose.
Accepted cost, stated plainly: content carrying a literal `<` between the tag name and the `>`
(`<script <x>`, `<script<div>`) stops matching. Measured across **all 90** conformance cases,
not only the four that cite these ids: zero lost a match, zero gained one. The dropped class is
real and unwitnessed by the corpus.
- `signatures/secret-egress.json` `0.2.0` → `0.3.0` — **the one-entry staleness disclosed in
`0.2.0` is closed by re-extraction, 18 → 19 patterns.** `OpenAI Legacy API Key`
(`\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b`) enters at `order` 17, second to last; `JWT
(three-part token)` moves to 18 and stays last, which `ordering.last_entry_is_load_bearing`
requires.
Read out of the module text at a pinned public commit, never transcribed from the coord message
that reported it — the message contained the regex, and that is exactly the path
`evidence_limits` had ruled out. `refs/heads/main` on the public remote is `47905da`; `088e458`
carries the entry and was confirmed an ancestor with `git merge-base --is-ancestor` rather than
read off their log.
A side effect worth more than the entry: **all 19 positions were compared against the module** —
name, regex source, flags, order — with 0 divergences. Positions 016 came from a 2026-08-09
transcription whose module fidelity had stood recorded as llm-security's assertion rather than a
reproduced result. It is now reproduced, and that `evidence_limits` bullet is retired along with
the staleness one.
- `conformance/manifest.json` `0.3.1` → `0.3.2` — the `secret-egress` blocker prose said its note
would stand until the re-extraction landed. It landed, so item (2) (`openai-api-key-legacy` is a
real hole here) is marked closed, and the entry count moves 18 → 19. **The blocker itself does
not close**: 19 entries against the guard's 25 at different cut points is still a table
reconciliation nobody has performed, and one closed hole is not that reconciliation.
- `docs/lexicon-port-divergence.md` (informative) — the `[^>]` vs `[^><]` row gets the number
it never had for **this** side, and then gets closed. The guard disclosed that their `[^><]` is a
measured ReDoS fix (`cff0437`), not a preference, and asked commons to measure its own form
rather than take their word. Measured here in Node and Python: `<script\b[^>]*>` is **quadratic**
in scan position on input that denies it a `>` — ×4.0 per doubling, 6.7 s at 256 000 chars
against 0.41 ms for the guard's form.
Counted across the whole lexicon rather than assumed from the one row: 8 of 83 patterns carry
a bare `[^>]`, six of them bounded (`{1,256}`, measured linear) and **two unbounded**. The
second unbounded one, `hybrid-xss:iframe-src`, had not been named by any party — 16.9 s at
256 000 chars.
The finding was routed to the owning runtime, which is what `SECURITY.md` promises an outsider
would happen — the first time that route was walked from the inside. It came back as a decision
the same day, and the document now records the closure with the invariant intact: the
measurement travelled, the authority did not. Had `llm-security` declined, both rows would still
read `[^>]*` and this file would record a permanent divergence instead.
## [0.3.1] — 2026-08-11
**No pattern changed value. One shipped table is disclosed as stale, and the repository gains
the reporting route it did not have.** Nothing in `patterns`, `expected.json` or any id moved,
so a runtime that passes `0.3.0` passes `0.3.1` unchanged. Read the first entry anyway if you
vendor `signatures/secret-egress.json`: it now says, in the file, that it under-matches its own
source by one entry.
### Added
- `SECURITY.md` — the reporting route for a repository whose attack surface is **data**. It
answers the question an ordinary security policy does not have to: how to report that a
*detection-table entry is wrong*, and why a confirmed defect in extracted data is decided in
the runtime it was extracted from before it is changed here. Names what is in scope (a silent
false negative, a fixture that sanctions a miss, an unsafe normative clause, a secret in the
history, data gone stale against its source), what is a documented boundary rather than a
vulnerability, and the two classes that skip the routing — a real secret, and data authored
here rather than extracted. States plainly that fix latency is bounded by the owning runtime's
schedule and the consumer's pull, not by this repository's.
Written because `org-ops` recorded the file as missing against the org standard on
2026-08-11, and because four files here are detection data where a mistake is a detector that
looks like it works. `CONVENTIONS.md`, recorded in the same message, is not in this release.
- `README.md` — a short **Reporting a wrong entry** section pointing at it. Without it the
policy is a file nobody looking at the front page would know to open.
### Changed
- `signatures/secret-egress.json` `0.1.0` → `0.2.0` — **a staleness disclosure, not a data
change.** All 18 patterns are byte-identical to `0.1.0`; one entry is added to
`provenance.evidence_limits`. `llm-security` reports having taken the source `SECRET_PATTERNS`
from 18 to 19 by adding `OpenAI Legacy API Key`. That is recorded as their report and
explicitly **not** reproduced here — the commit carrying it is not on their public remote,
which was measured at `b1ba1fb` on 2026-08-11. What *was* measured here: none of the 18
patterns matches a legacy `sk-…T3BlbkFJ…` key shape. So a consumer vendoring this file
under-matches the seed hook by one entry, on a live credential shape, and now reads that in the
file rather than inferring it. It will be closed by re-extraction from a pinned public commit,
never by authoring the entry here from a message.
- `conformance/manifest.json` `0.3.0` → `0.3.1` — the `scope_planned.blockers` text for
`signatures/secret-egress.json` is corrected. Through `0.3.0` it ended by naming
`gcp-service-account-json` and `openai-api-key-legacy` together as ids "absent here". They are
two different kinds of fact, and one of them was misleading.
Measured 2026-08-11, by running this file's own 18 patterns in `order` over a service-account
document, against the guard at commit `e671edb`: a **complete** GCP service-account key file
*is* matched here, at order 11 (`Private Key PEM Block` — its `(?:RSA |EC |DSA |OPENSSH )?`
prefix group is optional, so the bare PKCS#8 header such a file carries matches). The same
document with `private_key` removed matches nothing here while the guard's marker pattern still
fires. That is a **cut-point** difference — the guard detects the document marker, this table
detects the key material — which is what the blocker is about, and not a missing entry.
`openai-api-key-legacy`, by contrast, is a real hole here today, and is now recorded as one.
The correction is folded into the existing blocker string rather than added as a sibling key:
`blockers` is a map from table path to text, and a second key under a table path would read as
a second table to anything iterating it.
- `docs/lexicon-port-divergence.md` (informative) — the residual `[^>]` vs `[^><]` row gains a
fuller witness set. `llm-security` measured the three forms as **totally ordered** by what they
match, each a strict superset of the next, and named two input classes the guard's narrower
class drops (`<script a="<" >x`, `<script<div>`) beyond the one commons had recorded.
Reproduced here independently, including the strict-superset property in both directions,
before being written down.
Their argument — that the narrower class buys an empty false-positive surface, since anything
reaching `[^>]`-and-not-`[^><]` already contains a literal `<script` tag — is recorded as
theirs and explicitly **not** what decided commons' form. Commons carries `[^>]` on provenance,
and would have carried `[^><]` had the source been the guard's. Also records that they asked to
hear the guard's reason for `[^><]` before commons shipped and commons shipped first, with why
that order was deliberate.
No data file touched; `v0.3.0` is unaffected.
## [0.3.0] — 2026-08-11
**A detection pattern changed value. That has not happened in this repository before, and it
is the reason this is a release.** `v0.2.0` changed what a runtime must *declare*; this one
changes what a conforming runtime *finds*. A consumer that vendors `0.3.0` and re-runs its
suite will see a finding on inputs that produced none under `0.2.0`. In 0.x that is a minor
bump by the rules; read the first entry below before upgrading, not the version number.
The lexicon `0.6.0` entry previously sitting under *Unreleased* is folded in here — it was
committed but never tagged, and `0.7.0` supersedes its central claim.
### Changed
- **`lexicon/injection-lexicon.json` (`0.5.1` → `0.7.0`) — `hybrid-xss:script-tag` converged
on `llm-security`'s current form.**
```
0.6.0 and earlier <script\b[^>]*>[\s\S]*?<\/script> closing tag REQUIRED
0.7.0 <script\b[^>]*> opening tag only
```
Byte-identical to `llm-security`'s `RegExp.prototype.source` at `b1ba1fb`
(`scanners/lib/injection-patterns.mjs:170`), verified by compiling both. They dropped the
closing-tag requirement in `90f576f` (2026-08-10) because it was a recall hole:
`<script>alert(1)` and `<script src=x.js>` both returned no finding.
**This is re-extraction, not revision, and the distinction is the whole justification.**
This repository's behaviour-preservation invariant forbids commons from *correcting* seed
data it believes is wrong — that rule stands and was not weakened. It does not forbid
re-reading the source after the source itself moved and its owner asked. The lexicon's
declared provenance is `llm-security`'s injection table, and being loadable verbatim by
that table's owner is the one thing it exists to do; the standing alternative was a
per-pattern override in `llm-security`'s own loader, i.e. a published core its source
repository could not load as published.
**Direction matters for what it cannot break:** the new form matches a strict superset of
the old one, so relative to `0.6.0` it can add matches and cannot introduce a false
negative. The reverse change would not have been adoptable on the same reasoning.
Operator decision, 2026-08-11, on `llm-security`'s blocking request. Explicitly **not**
decided by the 2-of-3 majority across the three ports: a count of implementations is not a
mandate over detection data, and the provenance argument would hold with the guard on
either side.
Measured collateral: **none.** The full corpus was run under both patterns — 84/84
lexicon-scoped cases pass under `0.7.0`, and exactly one case's finding set differs between
the two forms (the new one below). The widening added no finding to any other case's input.
`source_fidelity` restructured to keep its numbers coordinate-bearing:
`patterns_byte_identical_to_source` keeps its key and its value (83) and gains the field it
was missing, `byte_identical_against_commit: b1ba1fb`. Against the original extraction
commit `b0de0ca` this file is now 82/83, recorded as `count_against_extraction_commit`.
`post_extraction_drift` — added in the folded-in `0.6.0` to record the then-open divergence
— is now marked `status: resolved in 0.7.0 by re-extraction` and carries the before/after
pattern text, so a consumer diffing against either commit has a coordinate for what it
finds.
- **`conformance/manifest.json` (`0.2.0` → `0.3.0`) — `case_id_derivation` extended with an
optional variant suffix.**
```
before case_id = <pattern_id, ':' → '__'>
after case_id = <pattern_id, ':' → '__'> [ '--' <variant-slug> ]
reverse truncate at first '--', then '__' → ':'
```
No existing case id moved, so this is additive. `--` was measured absent from all 83
ratified pattern ids and all 89 pre-existing case ids, which keeps the reverse transform
purely lexical — no lookup against the id list — the property the original one-to-one rule
was protecting.
The `one_case_per_pattern_id` key is **removed**, superseded by
`case_id_derivation.variant_suffix.supersedes`, which quotes its text. It was documentation
of the constraint, not data a consumer matches on, but it is called out here because a
removed key is normally a breaking change in this repository.
`omitted_payloads` gains `derivation_ground_withdrawn_in_0_3_0`: the guard's seventh
active-content payload was omitted on two grounds, and this change retires one of them. The
other stands, so the payload stays omitted — on one ground instead of two. **It was not
added back**; that is a separate decision, not a consequence of this one.
### Added
- **`conformance/hybrid-xss__script-tag--src-no-close/` (89 → 90 cases)** — input
`<script src=x.js>`, 17 bytes, expecting `hybrid-xss:script-tag`. The regression gate for
the change above, and the reason the corpus could not previously see it: the existing
`hybrid-xss__script-tag` input `<script>steal()</script>` 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 `<script <x>` matches commons and
`llm-security` and not the guard. Measured by compiling all three forms, not reasoned from
the character classes; neither side has claimed it.
## [0.2.0] — 2026-08-11
The contract gained a normative MUST, which is why this is a release rather than a
metadata commit: **a runtime that conformed to `v0.1.0` does not conform to this one until
it declares the set of commons data files it implements.** In 0.x that is a minor bump by
the rules, but it is breaking in substance, and a consumer reading only the version number
should learn that here rather than from a failing suite.
Everything below this heading was previously listed as unreleased.
### Retracted
- **The claim that the Python guard's port cites `severity.mjs` for hybrid severity.** It is
false. It was carried in three places — `lexicon/injection-lexicon.json`
(`families[hybrid].severity_provenance.not_from`), `docs/lexicon-port-divergence.md`
(§ *Severity: the 8 hybrid patterns*), and the `[0.1.0]` entry below — and it was never
measured here. It restated an assertion received from `llm-security` (coord message
`20260809T201048Z`) as a commons finding.
Measured against the guard's own tree, which `llm-ingestion-pipeline-security` asked for
twice before this was checked: `severity.mjs` has **never** appeared in
`src/llm_ingestion_guard/injection_lexicon.json` at any point in that file's history
(`git log -S` returns no commits), and at `0bf0729` — the commit
`conformance/manifest.json` pins — the only tree-wide occurrence is `docs/PLAN.md:114`,
correctly attributing the *report* module to `output.mjs` + `severity.mjs`. The guard's
only source statement for the lexicon is the `note` at `injection_lexicon.json:3`, and it
names `injection-patterns.mjs`.
**No detection data moves.** `families[hybrid].severity` is still `high`, still sourced to
`injection-patterns.mjs:274-281`, re-verified at `b0de0ca`; `severity.mjs` still contains
zero occurrences of `CRITICAL_PATTERNS`, `HIGH_PATTERNS`, `MEDIUM_PATTERNS` and
`HYBRID_PATTERNS`, re-measured the same day. Only the sentence about the *other* repository
falls.
The retraction is marked in place rather than edited away, and it is worth naming why this
one survived review: the claim arrived bundled with a correct measurement of the same
question, from a repository that had done its half properly. The correct half carried the
incorrect half past the check — which is precisely the defect
`severity_provenance.not_from` was written to warn about, one level up.
### Added
- **`not-applicable`, a third conformance verdict** (`spec/conformance-corpus.md` §1.1). A
runtime now declares the set of commons data files it implements; a case whose `scope`
names a file outside that set is `not-applicable` rather than failed. §1 alone would have
reported an architectural difference as a defect — one seeding runtime has no
active-content table and never will, and 7 permanent failures say nothing a reader can use.
The verdict is fenced so it cannot become an exit: it attaches to a **table**, never to a
case (per-case opt-out is the silent skip §1 forbids), a declared set MUST NOT be narrowed
to convert failures into `not-applicable`, and such cases MUST still be enumerated rather
than dropped from the denominator. §8 now states the consequence: a pass count is
unreadable without the declared set beside it.
- **Six active-content conformance cases** — `active__markdown-image`, `active__markdown-link`,
`active__reference-link`, `active__autolink`, `active__raw-html`, `active__data-uri`. The
corpus goes 83 → 89, and `scope_covered` gains `signatures/active-content.json`.
Generated from measurement, not written: payload strings were extracted from the seed
runtime's `coverage.py` by AST — evaluating each `_scan_case` argument in that module's own
namespace rather than retyping detection data — then run through its public output gate.
The fixtures were then re-read from disk by a separate checker that re-computed every
digest, re-scanned the bytes and applied `exact-within-scope` independently of the
generator, because a generator agreeing with itself proves nothing: 6 cases, 0 failed
checks.
**These six prove less than the 83, and the manifest says so.** Their payloads come from
the only runtime implementing the table, so no second implementation's agreement could be
measured. They pin one runtime's behaviour as a contract a future implementer can be held
to — which is less than cross-runtime agreement and more than nothing.
- `signatures/active-content.json` **0.1.0 → 0.2.0** — a `pattern_id_space` block. Unlike the
lexicon's, nothing was constructed: `label_format` and the `constructs` keys were already
extracted verbatim, and their concatenation *is* what the seed runtime emits. The block
states an id space the file already had implicitly, and records that it is ratified by
**one** runtime rather than two.
### Changed
- `lexicon/injection-lexicon.json` **0.5.0 → 0.5.1** — provenance metadata only; no pattern,
id, alias, family or severity value changes.
- `conformance/manifest.json` **0.1.1 → 0.2.0** — the six cases, `scope_covered`,
`count_by_scope`, separate provenance and measurement blocks for the active-content half
(a different source structure at a different commit; one pin must not stand for two
measurements), and `scope_planned.blockers`.
- **`spec/conformance-corpus.md` §4 no longer claims scoping "asks a question both can
answer."** That held only while every case was scoped to the one table both runtimes
implement, and stopped being true the moment a case was scoped to a single-runtime table.
Scope narrows *what* is compared; it does not make every runtime a valid addressee. The
superseded sentence is named in place rather than edited away.
### Fixed
- **§4 now states that "belongs to a data file" means published there, never "shares its
prefix."** The distinction has a live witness: the seed runtime emits `active:oversize-input`,
a self-safety flag about its own scan cap, which carries the `active:` prefix but is no
construct in `signatures/active-content.json`. A prefix-matching runtime would pull it into
the comparison and fail a case over a finding the corpus makes no claim about. Recorded in
that file under `pattern_id_space.not_every_active_label` as well.
- **§6 now states the derivation's cost.** `case_id` derives from `pattern_id` alone, so a
single-finding scope holds at most one case per pattern id — there is nowhere in the name
for a second. The seed runtime's matrix drives *two* payloads at `active:markdown-image`;
measured, their in-scope finding sets are identical, and the second's only distinguishing
signal (`entropy:base64-blob`) falls outside every table this repository publishes. It was
dropped rather than given a discriminated id, which would have broken the reverse
transform, and it is named in `conformance/manifest.json` under `omitted_payloads` so that
6 built from 7 offered reads as a decision rather than a miscount.
### Measured, not shipped
- **The remaining four cases are blocked on two distinct unresolved questions**, now recorded
under `scope_planned.blockers` instead of the earlier blanket "no runtime has agreed to an
id space". That framing was wrong for both:
- **Carriers (3).** No adoptable id space, and a second problem underneath. The guard emits
two *stage-coupled* labels for one carrier — `sanitize:zero-width` on input,
`output:zero-width-present` on output, same split for bidi and unicode-tag — while
llm-security emits prose titles. A commons id must be invented stage-neutral, which no
other id space here required. And since `exact-within-scope` compares a finding *set*, an
id aliasing both labels makes the verdict depend on which entry point the runtime was
measured through — an entry-point dependence the lexicon cases do not have.
- **Secret egress (1).** Not an id-naming question at all. The two runtimes carry
**different tables**: 18 entries here against the guard's 25, cut at different
granularities (this file's single `GitHub Token` is four ids there, `Private Key PEM
Block` three, `Database connection string` four), with membership diverging both ways.
`aws-access-key-id` is the one clean 1:1 — which is why exactly one egress case was ever
offered. That number was a symptom, not modesty. A shared id space presupposes a table
reconciliation nobody has done.
## [0.1.0] — 2026-08-10
Initial extraction. Runtime-neutral detection data, the finding contract, and a conformance
corpus, extracted from the `llm-security` Node implementation and a Python guard **without
behaviour change** — that invariant is the release, not a caveat on it.
What the tag is worth resting on: seven of the eight JSON artefacts were rebuilt from the
commons file alone and diffed against their source implementation, three of them against the
source module at a pinned commit. The eighth says `verified: false` about itself. The corpus
holds 83 cases on which both seeding runtimes were measured agreeing exactly.
What it is not: `spec/decode-pipeline.md` does not exist, and the corpus constrains one of
the seven data files. Both absences are named in *Not included* rather than papered over.
### Added
- `conformance/` — **83 cases, one per injection-lexicon pattern**, plus `manifest.json`.
Each case is a directory holding `input.txt` (the exact bytes, no trailing newline) and
`expected.json` (the findings, named by commons pattern `id`).
Both seeding runtimes were measured producing the **same lexicon finding set on all 83**,
through their public entry points — `scanForInjection()` at `b0de0ca` and
`scan_output(source=OUTPUT)` at `0bf0729` — with labels mapped to commons ids through the
lexicon's own `aliases` block. Not through rebuilt regex tables: a table-level comparison
yields a number that describes neither runtime, which is the mistake the divergence
document had to retract.
**The 13 divergent patterns are in, unmarked, and that is the substantive result.** Their
divergence was measured on witness inputs — an attribute run padded past 256 characters,
an interior `<`, an unclosed `<script>` — and none of those shapes occurs in a corpus
payload. All 13 agree on their own case input. Nobody had to pick whose recall cost
becomes the contract, because the question was never reachable from these inputs. A
per-case caveat would have asserted a doubt the measurement disproves.
Inputs are the guard's `coverage.py` payloads, reproduced verbatim. One runtime authored
them; what makes them a cross-runtime corpus is the measurement through the other, and the
manifest records the asymmetry rather than averaging it away.
- `spec/conformance-corpus.md` — **normative.** How a case is read: `input.txt` is bytes and
is not to be trimmed or re-encoded, `expected.json` names findings by `pattern_id` only
(severity and OWASP anchor are looked up in the lexicon, never restated), and
`exact-within-scope` requires equality **restricted to the data files the case names**.
The field is `pattern_id`, not `id`, because this repository already publishes an unrelated
finding `id`: `schema/finding.schema.json` defines it as `DS-<scanner>-<counter>` from a
process-global counter — stable across neither runs nor processes. Two normative documents
using one word for a stable rule identity and a volatile per-emission sequence number would
have produced runtimes failing every case for reasons unrelated to detection. §3.1 states
the distinction and publishes the bridge a runtime actually needs: its own label maps to a
`pattern_id` through the lexicon's `aliases` object, and a runtime absent from that object
has no published way to be compared at all.
Scoping is what makes exactness safe — the two runtimes do not implement the same set of
tables, so a whole-report comparison would fail for reasons unrelated to the pattern under
test. Exactness is what makes the corpus worth running — a contains-only corpus is passed
by a runtime that flags everything. `observed_out_of_scope` is evidence, never expectation,
and an absent runtime key means **unmeasured**, not measured-empty.
The document also states the one place this repository's "every JSON file carries a
top-level `version`" convention does not apply: fixtures are versioned as a corpus, in
`conformance/manifest.json`. Stated rather than left to be discovered.
- `schema/finding.schema.json` — the finding contract plus the SARIF output profile.
Normative. Closed against the producer in 0.2.0; the JSONL profile is `not applicable`.
- `signatures/active-content.json` — the EchoLeak class (CVE-2025-32711): 17 patterns,
severities, opacity floors and pass order, from the Python guard.
- `lexicon/injection-lexicon.json` — 83 prompt-injection patterns in four families
(21 critical, 32 high, 22 medium, 8 hybrid).
- `codepoints/carriers.json` — six carrier tables: zero-width characters, the Unicode Tags
block, the Supplementary Private Use Areas, BIDI controls, the Cyrillic presence set and
the 28-entry fold-to-Latin homoglyph map.
- `signatures/secret-egress.json` — the 18 fixed credential and token shapes. Array order
is normative.
- `mapping/owasp-map.json` — four taxonomy maps (LLM, ASI, AST, MCP) over one shared
16-prefix key set.
- `calibration/calibration.json` — risk-score tier constants, verdict thresholds, risk-band
cutoffs, posture grade thresholds.
- `signatures/malware-signatures.json` — the known-bad-identity table for the `SIG` class:
seven signatures over four families (`webshell`, `reverse_shell`, `cryptominer`,
`hacktool`), reproduced verbatim from `knowledge/signatures.json` at `b0de0ca`, key order
included, with the source file's byte length and SHA-256 pinned in `provenance`.
The rules were the easy half. The file's substance is the line between the table and the
engine, drawn in `engine_behaviour_not_data`: **no rule carries a `flags` field**, because
the engine compiles every pattern with `i` unconditionally — so a consumer that compiles
these case-sensitively silently under-matches all seven. Each pattern is also run against
five decode variants, not just raw bytes; rules are filtered by an enabled-families policy;
a rule fires once per file; operator rules are merged at scan time; and the loader defaults
four missing fields rather than rejecting a rule. None of that travels with the data, and
all of it changes what a consumer sees.
Two honesty notes are in `evidence_limits` rather than in prose. Seven signatures are not
malware coverage — a clean `SIG` result is not "no malware", and the seed runtime's own
header calls the table "deliberately tight". And three of the seven match on **names**
(`xmrig`, `mimikatz`, `meterpreter`), so a document *discussing* those tools matches; the
seed runtime papers over this by excluding `knowledge/`, `tests/`, `docs/` and
`node_modules/` from the scan, which is engine behaviour and does not come with the table.
Verified: 7/7 rule objects field-identical to source including key order, no non-ASCII
bytes, and all seven compile in Node bare, `i` and `iu` (21/21) and in Python `re` (7/7).
Note the exact family spellings — `reverse_shell`, not `reverse-shell`, and `cryptominer`,
not `miner`; they are policy keys, and the working note that seeded this file had both wrong.
### Verification
Every file above except `calibration.json` was proven rather than transcribed: the data was
rebuilt **from the commons JSON alone** and diffed against the source implementation. Each
file records its own result and its own limits.
`calibration/calibration.json` carries `verified: false`. Its source arrived as a prose
summary rather than as code, so no differential check was possible, and the file names the
checks that were not run instead of attaching a caveat to a pass.
The corpus was verified the same way the data was — by a harness that does **not** share the
generator's knowledge. It reads only the case directories, re-runs both runtimes on the bytes
it finds there, and checks every field of every `expected.json`, digests included: **83
cases, 0 failures**. Two further checks, because a corpus that cannot fail is not evidence:
commons' family severity matches the severity the guard emits per finding, **83/83**; and
deleting the middle third of each input breaks **76 of 83** expectations. The 7 survivors are
the shortest payloads, where the mutation leaves the trigger intact — that is a weak
mutation, not a weak fixture, and it is recorded as such rather than rounded up.
- `docs/lexicon-port-divergence.md` — informative. A differential comparison of the two
ports of `injection-patterns.mjs` (this repository's and the Python guard's): 83/83
patterns correspond, 64 are byte-identical, 6 differ only by escaping and are proven
equivalent, and **13 behave differently**, with a witness input for each and misses on
both sides. The cause is two different ReDoS mitigations of one table. **No data file was
changed** — behaviour preservation holds and the finding is reported to the owning
repositories.
**Revised 2026-08-09 with one retraction.** The document claimed that *neither runtime
misses an attack*, on the grounds that every witness payload still produced a finding. It
does miss. That measurement ran the payloads against the **union of every pattern table
this repository holds**, and the rescuing hit came from `active-content.json` — the Python
guard's table. `llm-security` has no active-content table at all, so a union of commons
tables was read as a statement about each runtime separately. Re-measured through
`llm-security`'s own `scanForInjection()` at `b0de0ca`, all three witness payloads return
**`found: false`** — no finding whatsoever — while controls in the same run behave
normally. Three confirmed recall holes, which `llm-security` attributes to its v7.8.3 #24
ReDoS hardening and has logged as a v8.x task.
Also corrected: one of the 13 divergences does not reach report level, because the guard's
`hybrid-xss:javascript-uri` fires on the same witness at the same severity and anchor. The
report-level number is **12**. And the `hybrid` severity question that the document reported
rather than resolved is now closed — the reported hint was right, the citation behind it was
not.
**Revised again 2026-08-10.** The document said 13 was the number blocking `conformance/`,
since a fixture names labels. It blocks a fixture written over a **witness** input, and the
corpus contains none — all 13 agree on their own case input. The divergence itself stands
unresolved and unchanged; what was wrong was the claim about what it blocked.
Corrections are marked in place rather than edited away.
### Changed
- `schema/finding.schema.json` **0.1.0 → 0.2.0** — the schema is **closed**. It was seeded
from `sarif-formatter.mjs`, which *consumes* findings, so its property list could only ever
be a lower bound and `additionalProperties` had to stay open. The producer is now known —
`finding()` in `scanners/lib/output.mjs`, line 32 — and it returns an object literal with
**exactly ten keys and no spread**: `id`, `scanner`, `severity`, `title`, `description`,
`file`, `line`, `evidence`, `owasp`, `recommendation`. `additionalProperties` is `false`,
and the two keys the old schema never knew about (`id`, `evidence`) are added.
`id` gets its own definition: `DS-<prefix>-<counter>`, pattern `^DS-[A-Za-z]+-[0-9]{3,}$`.
The `{3,}` is deliberate — `padStart(3, '0')` is a minimum, so a run emitting more than 999
findings produces four digits. The id comes from a process-global counter, so it is stable
neither across runs nor across processes, and the definition says so before someone keys on it.
Nullability is now evidence rather than convention. Five keys are emitted as `null` rather
than omitted (`opts.x || null`), so a serialised finding always carries all ten. The
exception is the four assigned straight from `opts`: omit `description` and the key is
`undefined` and vanishes from the JSON. Verified by calling the real producer — ten keys in
memory, nine after serialisation.
**`owasp` is a string, not an array, and not one code.** Multiple codes are joined with
`, `. Measured across the seed runtime: 31 distinct values over 157 emission sites, 13 of
them multi-code, and **four mix taxonomies inside a single value** (`LLM06, ASI02` and
friends) with no discriminator saying which is which. That sharpens the edition problem
`mapping/owasp-map.json` already records, and it has a consequence nobody had written down:
`sarif-formatter.mjs` builds `tags: [f.owasp]`, so a finding anchored to two taxonomies
produces **one** SARIF tag with a comma in it. Nothing filtering on `LLM06` will match.
Reproduced end to end through the real `finding()` and `toSARIF()`, and logged as
`known_lossiness.owasp-tag-not-split` — consumer behaviour in `llm-security`, not data, so
it is reported rather than fixed here.
The **JSONL profile is `not applicable`, not `unspecified`** — the distinction is the point.
`unspecified` would claim a profile exists and merely has not been written down. No
finding-JSONL exists: findings are emitted only inside a single JSON envelope
(`output.mjs:140`). The one module that does write JSONL, `audit-trail.mjs`, writes *audit
events* under a different schema — where `owasp` is an **array**. Same field name, different
type, same repository. A consumer reading both through one code path will be wrong about one
of them, so the profile records the trap instead of leaving a TODO.
Verified: the schema is valid Draft 2020-12, every finding built by the real producer
validates against it, and four negative controls (extra property, missing `id`, malformed
`id`, unknown severity) are all rejected.
One new open question, unpatched by design: the producer's JSDoc lists **seventeen** scanner
prefixes including `IDE`, while all four maps in `mapping/owasp-map.json` are keyed on
**sixteen** without it. An `IDE` finding has no taxonomy mapping in any map. Adding the key
would be inventing detection data.
- `lexicon/injection-lexicon.json` **0.4.0 → 0.5.0** — the last null in the file is filled and
the id space is ratified. Two blockers close, no detection data moves.
`families[hybrid].severity` was `null`, deliberately, because the seed dump did not supply
it. It is **`high`** — and the interesting part is where that is written. The hybrid family
has no severity field anywhere; the engine assigns one by pushing `HYBRID_PATTERNS` matches
straight into the `high` bucket at `injection-patterns.mjs:274-281`. Both this repository
and the Python guard had first looked in `severity.mjs`, which contains no injection-family
severity at all. The guard's port holds the right value behind that wrong citation, so
`severity_provenance.not_from` records the miss explicitly: a wrong citation to a right
value is the harder defect to catch later.
> **Correction 2026-08-10 (see Unreleased):** the two sentences about *the guard's* citation
> are false and were never measured here. The guard's port cites `injection-patterns.mjs`,
> the right file. Everything above about `severity.mjs` containing no injection-family
> severity, and about where the value actually lives, stands and has been re-measured.
`pattern_id_space.not_yet_confirmed` is replaced by `ratification`. Both seeding runtimes
agreed on 2026-08-09 — `llm-security` ratified the 0.2.0 proposal as-is and treats an id
change as breaking on the same terms, and the guard confirmed the space its own port
supplied. `id` is now a cross-runtime contract, which is what `conformance/` was waiting
on to be able to name a finding.
`alias_evidence.llm_security` is sharpened rather than upgraded. All 83 alias strings were
confirmed equal to the module's `label` field, in order — so the alias is certainly the
pattern's name **in the table**. It is still not established that a finding carries it: the
producer is `output.mjs:finding()`, which emits `title` and has no `label` key at all.
Verified at table level, one level short of where it would matter. Match on `id`.
- `lexicon/injection-lexicon.json` **0.3.0 → 0.4.0** — verified against the source module
instead of against the dump it was transcribed from, and **two false provenance claims
retracted**. The source is now pinned: `b0de0ca` on the public remote, imported in Node
and compared entry by entry on `source`, `flags` and `label`.
The result is **83/83 byte-identical to source**, which is not what the file previously
claimed. It said two patterns had been rewritten from raw code points into `\uXXXX`
escapes; the module already writes them escaped, so nothing had been rewritten. The stored
pattern text was right the whole time — only the account of where it came from was wrong.
The dump had rendered the module's escapes as the characters they denote, and this
repository re-escaped them, arriving at the correct bytes by way of an incorrect story.
The same inversion ran the other way in `multi-lang:french`, which carried the class
spelled with a raw accented Latin `e` where the module writes it as the escape
`\u00e9` inside the same character class.
That was the one pattern of 83 not byte-identical to source,
and it is corrected. The two spellings are the same regular expression — verified in Node
bare and under `u`, and in Python `re`, over accented, unaccented, uppercase and
non-matching French input, with identical match offsets — so **no behaviour moved**. No
pattern in the file contains a non-ASCII byte now, matching the module, whose regex
literals are pure ASCII throughout.
Structurally: `normalisations` is now `[]` with a `normalisations_note`, matching the
convention already used in `signatures/secret-egress.json`, and a new `source_fidelity`
block carries the counts, the method, the verified class membership, and both retractions
in full. Retracted claims are recorded rather than deleted — the earlier equivalence
evidence (692 Node comparisons, 236 Python) remains true, it is simply no longer
load-bearing.
- `lexicon/injection-lexicon.json` **0.2.0 → 0.3.0** — the two aliases are no longer presented
as equally backed. `pattern_id_space.alias_evidence` now records each one separately:
`llm_ingestion_guard` is **verified** (the guard's coverage matrix asserts on that exact
string, so it is demonstrably what a guard finding carries), while `llm_security` is
**not** — it is the pattern table's own name, and the finding producer was never supplied,
with the known Node finding shape using `title` rather than `label`. Averaging the two into
one file-level claim would have repeated the defect this repository corrects per-table
elsewhere.
Also: `normalisations[].affects` now keys on `id` with the prose names kept beside it as
`affects_labels`. An internal cross-reference on label was a second identity space inside
the file the id was added to unify.
- `lexicon/injection-lexicon.json` **0.1.0 → 0.2.0** — every pattern gains a commons-owned
`id` and an `aliases` object naming what each seeding runtime calls it, plus a top-level
`pattern_id_space` block explaining the field. This exists because a `conformance/`
fixture has to name a finding and the two runtimes do not name the same pattern the same
way.
The id was **adopted verbatim from the guard's port**, which already carried both names,
rather than invented here. Matching was by `label` ↔ `desc` with em-dash normalised to
hyphen: 83/83, one-to-one, ids unique.
**No detection data moved.** Labels, patterns and flags are byte-identical in sequence,
no `flags` key was invented (78 before, 78 after), and stripping the three new fields
reproduces the previous committed file byte for byte — 23 566 bytes, identical. All 83
patterns still compile in Node bare and under `u` (166/166) and in Python `re` (83/83).
Neither `llm-security` nor the guard has ratified this id space yet; both were asked by
coord on 2026-08-09, and the file says so rather than implying agreement.
### Not included
- `spec/decode-pipeline.md` — needs the decode implementation. A normative spec inferred
from a data dump would be worse than an absent one.
- **Conformance for the other four tables.** The corpus covers the injection lexicon only.
The carrier, active-content and secret-egress tables have 11 convertible cases waiting in
the guard's matrix, and no ratified cross-runtime finding id between them — writing those
fixtures would mint a contract unilaterally, in the same stroke as the tag. Named in
`conformance/manifest.json` under `scope_planned`.
- The 29 non-convertible cases of the guard's 134 assert a runtime's **API surface** — that
a Python call raises `OKFPathError`, that a disposition engine composes two findings a
particular way. This repository does not own an API, so those belong to the guard's suite.
`spec/decode-pipeline.md` is named in the README as planned rather than linked, so nothing
in the repository points at a file that does not exist.