# 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). ## [1.8.0] — 2026-10-31 ### Added — instructions in plain prose (attacks A08, A09, A16, A19) Four attacks put their instruction in ordinary English, with no markup, no override phrase and no hidden carrier, so they gave 0 findings: a persistence order in a session summary (Unit 42, A08), a memory write on a later user turn (Rehberger, A09), a standing order in a calendar invite (Invitation Is All You Need, A16) and spoiler-hidden page text against a browsing agent (Brave on Comet, A19). | Finding | Severity | What it reports | |---|---|---| | `indirect:prose-instruction` | MEDIUM | a paragraph that combines at least two (`calibration.PROSE_INSTRUCTION_MIN_CLASSES`) of four classes: it speaks to the model (*address*), it is meant to act later (*deferral*), it writes to the model's memory (*memory*), it hides from the user (*concealment*) | *Address* is "Assistant, ...", "instructions for the assistant" (the plural, which the vendored commons row `indirect:instruction-to-ai` misses) or an all-caps pseudo-tag such as ``. *Deferral* is "from now on", "in all future sessions" or "if the user later says". *Concealment* is "never mention this", "do not tell the user" or a `>!spoiler!<`. One class alone is ordinary prose: vendor docs say "Block from now on" and "Instructions for Claude". The paragraph is the unit, so an instruction split over sentences is still one, and two features a blank line apart are not. The rule lives in `output.py`; the vendored lexicon stays byte-equal with commons. MEDIUM sends the paragraph to review, not to a block. A document that quotes a system prompt or specifies a memory feature is itself an instruction to a model and is reported too. What it does not catch, measured: one class alone, a paraphrase outside the word lists, the two classes a blank line apart, and the same instruction in another language. All are in `docs/LIMITATIONS.md`. **If you route on labels:** a document with such a paragraph now goes to `QUARANTINE_REVIEW` under `PRESET_USER_UPLOAD`, not WARN. ### Documented — a task in plain prose with no marker (attack A12) Invariant's toxic agent flow asks, in a public issue, for the README of every one of the author's repositories, "including private ones", to be added to this README. It addresses no one, defers nothing, writes no memory and hides nothing, and a maintainer could file the same request. A rule that caught it would be fitted to the payload's words. It is the seventh documented gap in the coverage matrix and in `docs/LIMITATIONS.md`, and the attack case asserts that the gap still stands. ### Fixed — the ReDoS sweep skipped patterns kept in a dict `docs/redos-sweep.py` walked lists, tuples and dataclasses but not dicts, so the four class patterns were collected by nothing. It now walks dicts too. Only the output table changed: 168 patterns are swept, 0 flagged. A positive control (a pattern quadratic on whitespace, placed in a dict) is flagged at x4.0. **Measured against the guard rule, before and after:** the false-positive populations are identical line for line at 106/198 · 196/2918 · 108/389, and the new label fires on none of their documents. The pinned OKF corpus is identical on import at 47 fail-secure / 31 warn, with 0 firings. Before building, the rule was measured on 5 494 benign documents: the three false-positive populations, 411 npm READMEs, 166 PyPI descriptions and 1 412 skills, agents and commands from two vendor-published plugin collections, which are instructions to an agent by design. 0 fire. With the whole document as the unit instead of the paragraph, 1 of 5 494 fires (a skill file). The coverage matrix is at 144/144 classes and 7/7 documented gaps, 151 cases. Test suite: 1233 passed, 0 expected failures (was 1179 + 5). Cost at the 1 000 000-character cap, measured on a heavily loaded machine: about 0.7 s of CPU in a 9 s scan when the text holds the hint words, and 0.01 s when it holds none (a keyword prefilter skips the patterns). Attack row 3: 17 caught, 2 documented gaps, 0 open, so every in-scope row is caught or documented. ## [1.7.0] — 2026-10-31 ### Added — a run of images whose order carries a value (attack A05, CamoLeak) CamoLeak had the assistant render a secret as a run of images, one per character, from a pre-built URL alphabet. Each URL on its own names a remote image and carries nothing, so each was graded LOW and the gate WARNed. The value is in which image comes where. | Finding | Severity | What it reports | |---|---|---| | `active:markdown-image-sequence` | MEDIUM | at least 4 ordinary external images (`calibration.IMAGE_SEQUENCE_MIN_RUN`), inline or reference-style, on one URL template whose varying tokens are 2 characters or fewer (`IMAGE_SEQUENCE_SYMBOL_MAX`) or digit runs | A run whose only varying parts are numbers that count up in document order (`step-1.png`, `step-2.png`, or `ch1/fig2.png` before `ch2/fig1.png`) is a counter and is not reported. Images on another template are not part of the run, so decoys do not dilute it. Reference-style images (`![a][r]`, `![r][]`, `![r]`) resolve through their definitions. Each image keeps its own `active:markdown-image` grade; the new finding sits beside it. MEDIUM sends the run to review rather than to a block. The rule reads the order from the shape of the URLs, and that shape has benign twins: a table of country flags (`/w20/no.png`, `/w20/se.png`, …) or figures named `1a`, `1b`, `2a` are reported too. What it does not catch, measured: a dictionary of words, symbols of 3 characters or more, a counter whose increments carry the value, a run of 3, and a run that changes template for every symbol. All are in `docs/LIMITATIONS.md`. **If you route on labels:** a document with such a run now goes to `QUARANTINE_REVIEW` under `PRESET_USER_UPLOAD`, not WARN. **Measured against the guard rule, before and after:** the false-positive populations are identical at 106/198 · 196/2918 · 108/389. Those three hold no external markdown image, so the rule was also run over 411 npm READMEs and 166 PyPI descriptions (312 with external images, inline or by reference, badge rows included): 0 firings, and 0 at every run length from 3 to 8. The pinned OKF corpus is identical on import at 47 fail-secure / 31 warn, with 0 firings. The coverage matrix is at 140/140 classes. The ReDoS sweep covers 163 patterns, 0 flagged. Cost at the 1 000 000-character cap on an image-only document: the rule adds about 0.5 s of CPU to a 4.6 s scan. Attack row 3: A05 is caught, leaving 13 caught, 1 documented gap and 5 open. ## [1.6.0] — 2026-10-31 ### Added — hidden markup at the persist gate (attacks A04, A14, A15) Three new findings. Each is caught by `scan_output`, so it fires on both the input-side scan and the persist gate. | Finding | Severity | What it reports | |---|---|---| | `html-obfuscation:tiny-font` | HIGH | an element styled with a font below 4px (`calibration.HIDDEN_FONT_MAX_PX`). ShadowLeak's `font-size:1px` passed with 0 findings, because the vendored commons row `zero-font` only matches a size written with a leading `0` | | `html-obfuscation:invisible-colour` | HIGH | text coloured like the background declared in the same `style` attribute (within 8 per channel), or transparent | | `output:html-comment-present` | MEDIUM | an HTML comment in the artifact outside fenced code and inline code. The input side already stripped comments; the output side never looked, so a CopyPasta comment the model carried along passed | White text on its own is deliberately **not** flagged, because HTML email puts white text on dark buttons. A comment inside code is not flagged, because a renderer shows it as code. Both residuals are in `docs/LIMITATIONS.md`. **If you route on labels:** documents with hidden styling or a hidden comment now get findings they did not get before. Under `PRESET_USER_UPLOAD` that is `QUARANTINE_REVIEW` or `FAIL_SECURE`, not WARN. **Measured against the guard rule, before and after:** the false-positive populations are identical at 106/198 · 196/2918 · 108/389. The new labels fire once in total, on a red-team document that was already non-WARN. The pinned OKF corpus is identical at 6/53 parsed and 47/31 on import. The coverage matrix is at 139/139 classes. The ReDoS sweep covers 161 patterns, 0 flagged. Attack row 3: A04 and A15 are caught, leaving 12 caught, 1 documented gap and 6 open. ### Documentation — every cited catalog on its newest edition, dated and linked No code behaviour changed; one code comment was corrected. - **README maps the persisted store to OWASP ASI06 and MITRE ATLAS v2026.10.** A new subsection pairs ASI06 (Memory & Context Poisoning, OWASP Top 10 for Agentic Applications for 2026) and seven ATLAS techniques with what the guard stops at the write **and what it does not**. Each technique was read in the publisher's v2026.10 data file. ATLAS released v2026.10 on 2026-10-28, after the register had recorded v2026.09. `AML.T0057` (LLM Data Leakage) is gone from v2026.10, so it is not linked. - **Catalog register 1.1.0** (`docs/threat-2026/catalogs.json`): every entry now records the edition the repo cites next to the newest one, re-checked at the publisher on 2026-10-31. CleanBase (arXiv 2605.00460) was cited but unregistered, so it is added: 15 → 16 entries. Sources that cannot be read at their publisher are listed as `candidates` and not counted. A new test, `tests/test_catalog_register.py`, fails when an entry lacks a check date or a link, or cites an older edition than the latest. It also fails when the README names an ATLAS or Agentic entry the register has not verified. It never fails on age. - **Unicode 17.0 → 18.0 in the ZWJ measurement.** Re-measured on 18.0: 1614 RGI sequences, 122/122 neighbouring codepoints covered. The `sanitize.py` comment's "458 unassigned" came from Python's Unicode 16.0 tables; the correct figure for 18.0 is 406. - **BRIEF** no longer dates "AI Agent Traps" to 2025: the publisher (SSRN) could not be read, so the year is not stated. ## [1.5.0] — 2026-10-31 ### Changed — every OWASP number carries the 2026 edition (**filters on the old value lose hits**) OWASP published the Top 10 for LLM Applications **2026** on 2026-08-04 with a new order. Only LLM01 and LLM02 kept their meaning. Every `Finding.owasp` up to 1.4.2 was written against 2025, without a year, so it now reads as a different risk. EchoLeak findings said `LLM05`, which in 2026 means Data and Model Poisoning. From 1.5.0 every number is written the way OWASP writes it, with the year: | Finding (examples) | ≤ 1.4.2 | 1.5.0 | |---|---|---| | injection lexicon, carriers, entropy, fence, decoded injection | `LLM01` | `LLM01:2026` | | `egress:*`, `decoded:egress:*` (secret egress) | `LLM02` | `LLM02:2026` | | `active:*`, neutralize (Improper Output Handling) | `LLM05` | `LLM10:2026` | | `*:oversize-input`, scan caps (Unbounded Consumption) | `LLM10` | `LLM06:2026` | **If you filter, route or alert on `owasp`, update the value.** A filter on `"LLM05"` matches nothing from 1.5.0, and a filter on `"LLM10"` now means a different risk. `Finding.label`, severities and dispositions are unchanged, so no detection behaviour moved. Only the catalog field changed. The exported surface is unchanged: `owasp` was, and is, an optional string. The number now comes from one table keyed by risk name. The vendored lexicon stays byte-equal with `llm-security-commons` and gets its year where the finding is made. A commons number whose meaning moved between editions raises instead of being relabelled by guesswork. **Coverage matrix.** The rows map the same way. 14 rows whose class no edition describes now carry **no number** (`Case.owasp` is `None`, printed `-`): - the un-scannable artifact failing closed (the guard's own failure semantics); - four OKF frontmatter parser-surface refusals; - `resource` URL and cross-link scheme validation of received metadata; - the five OKF path rows, which carried `LLM01` by default. The factual-poisoning gap (PoisonedRAG) moves from `LLM09` (Misinformation) to `LLM05:2026`, whose example 6 is RAG knowledge-base poisoning. 136/136 classes and 6/6 gaps are unchanged. **Held at zero.** `tests/test_catalog_numbers.py` counts every number in the tracked files with `docs/catalog-census.py`. It fails on an older or wrong meaning, and on a missing year anywhere except the vendored lexicon. It also records every finding the coverage matrix drives and fails on one without the newest year. The newest edition is read from `docs/threat-2026/catalogs.json`. Row 2 of the 2026 plan went from **95 sites to 0** (frozen denominator at `6702d7a`, unchanged). ## [1.4.2] — 2026-10-29 ### Fixed — invisible-character smuggling passed with 0 findings Two published 2025 techniques reached the model through every gate untouched. **Variation-selector smuggling** (Butler, 2025-02; Gao et al., 2025-10) hangs one selector per hidden byte off a visible character; **Sneaky Bits** (Rehberger, 2025-03) encodes bits in U+2062 and U+2064. On `1.4.1` both gave **0 findings** on the input path and on the output gate, and every hidden codepoint stayed in the "sanitized" text. Measured more broadly in session 1: 17 of 19 invisible character classes gave 0 findings. The fix starts from a principled denominator instead of a hand-picked list: Default_Ignorable_Code_Point | format characters (Cf) | private use (Co) in Unicode 18.0, **141 674 codepoints**, frozen with the source files' sha256 in `docs/threat-2026/codepoints.json`. The guard knew 142 of them. Every one is now stripped by `sanitize` and flagged by the output gate unless a known legitimate use exempts it **on its context**. One predicate serves both surfaces, and a test checks all 141 674 codepoints, one by one, on both. The exemptions are those the ZWJ fix established: judge by context, never by identity alone. A *run* is a carrier whatever it stands after. - one VS15/VS16 after an emoji base (371/371 of Unicode 18.0's emoji variation sequences): ⚠️ ⏱️ ❤️ 1️⃣ survive byte-identical; - one ideographic selector after a CJK ideograph (registered name variants, and the 1 002 CJK compatibility sequences); - ZWNJ/ZWJ next to an Arabic-script or Indic letter, with neither neighbour invisible. Persian, Urdu, Hindi and Malayalam orthography stops being an any-tier carrier. That closes the ZWNJ false-positive class 0.6.1 parked for want of a corpus to verify a rule against. Measured on 9 Wikipedia articles (fa ×3, hi ×2, ml, ur, he, ar), articles with a carrier finding went from **6/9 to 2/9**, and the two left are real (a ZWSP where ZWNJ belongs; two ZWNJ in a row); - LRM/RLM/ALM in a document that carries right-to-left text; - an Arabic number sign (U+0600–0605 and kin) directly before a digit. Private use is caught in full: an icon-font glyph is blocked rather than leaving a ~17-bit channel per visible gap. That decision, the number-sign rule and the one-selector-per-ideograph rule are the operator's (2026-10-28). What each exemption leaves open, and what is now blocked despite a legitimate use, is in LIMITATIONS. **The lexicon's word-split row** (`unicode:zero-width-in-word`, `\w[ZW]\w`) matched every Persian word that carries its required ZWNJ. It now judges the joiner it matched by the same predicate. The regex itself is unchanged and stays byte-identical with the commons lexicon, and a Latin word split still fires. ### Added — labels `sanitize:variation-selector`, `sanitize:private-use`, `sanitize:invisible-format` and their output-gate counterparts `output:variation-selector-present`, `output:private-use-present` and `output:invisible-format-present`, plus `output:unicode-tag-present`. That last one is the complete tag check on the output side, because the lexicon's narrower one misses U+E0000. All seven are any-tier carriers (`FAIL_SECURE` in every tier), as the zero-width and BIDI labels were. **A consumer filtering on the old labels sees new ones.** Labels are detection behaviour, not the frozen surface. ### Measured — the guard rule, before and after - **Attack cases:** A01 and A02 are green on both arrivals. Row 3 of the threat-2026 plan stands at 10 caught, 1 documented gap and 8 red rows (8 of 31 arrivals, down from 12). - **Test suite:** 1049 passed, 8 strict xfail (was 935 + 12). - **Coverage matrix:** 136/136 caught classes (was 130/130; six carrier cases added); 6/6 documented gaps hold. - **Pinned OKF corpus** (`_okf-upstream` @ `3fcbb9f`): unmoved, 6/53 parse and 47 `fail_secure` / 31 `warn` on import, identical before and after. - **False-positive populations** (`docs/fp-sweep.py`, upload preset, documents disposed non-WARN): unmoved — vendor-harvest 106/198, generated-notes 196/2918, reference-corpus 108/389, identical line for line before and after. Positive control: doubling the VS16 that a reference-corpus document already carries moves it from `quarantine_review` to `fail_secure`. - **Runtime:** the classes are located with one regex pass and judged per hit. A 1 000 000-character document made entirely of carriers costs 1.4 s in `sanitize`, and a clean one 0.05 s. The RTL context is looked up once per document, so a document of directional marks stays linear (pinned). The ReDoS sweep collects 154 patterns (the two new character classes included) and flags neither. ## [1.4.1] — 2026-09-23 ### Fixed — a flow sequence refused `#` and `:` where YAML reads them as text `1.4.0` refused a flow-sequence scalar element carrying `#` or `:` anywhere. okf writes `references: [...]` as a flow sequence of doc links, and an entry may carry a `#fragment` or a `scheme://`. A consumer measured the cost (claude-code-llm-wiki, 2026-09-17): `okf.parse_frontmatter` raised on **2 038 of 5 467** concepts of a bundle okf itself produced, so `okf.import_bundle` returned a parse error instead of a verdict for 37 % of it. Reproduced here on the same bundle before the fix (2 038 / 5 467); after it, **0 / 5 467** (and 0 / 4 466 and 0 / 5 530 on the consumer's two other builds). The two characters are now refused by position, where YAML gives them a meaning, ground-truthed against PyYAML 6.0.3 on every test row: a `#` that opens the element or follows whitespace opens a comment, and a `:` that opens or ends the element, or precedes whitespace, opens a mapping. Both stay refused there. Elsewhere they are scalar characters, so `a#b`, `x://y#z`, `claude-cli://open` and `https://e.com:8443/a` parse as the strings YAML reads. The rest of the refused set (`{ } [ ] , " '`), the scalar-indicator rule and the no-mixing rule are unchanged. The pinned upstream corpus is unmoved at **6 / 53** (`_okf-upstream` @ `3fcbb9f`); its binding constraint is the top-level block mapping, not this rule. The predicate stays linear: CPU time doubles with each doubling of element count and element length (100k → 800k), about 10 % over `1.4.0`. ### Changed — documentation surfaces caught up with `1.3.0` and `1.4.0` README, ADOPTION-BRIEF and BRIEF named `1.3.0` after the `1.4.0` tag. README also said `resource` is not on the frontmatter allowlist and that a `sources` block list of mappings is refused (both false since `1.3.0`), and described the `resource` https-allowlist without saying it covers the top-level `resource` only — `sources[].resource` is never URL-validated. LIMITATIONS listed `tags: [a, b]` among the routes that fail (false since `1.4.0`). All corrected. ## [1.4.0] — 2026-09-08 ### Added — `tags: [a, b, c]`, the one flow-sequence form SPEC §4.1 writes out `okf.parse_frontmatter` refused a flow sequence of plain scalars. That is the form SPEC.md §4.1's own frontmatter skeleton uses for `tags`, and `tags` is present in 53/53 upstream reference concepts, 9/53 of them in exactly this form. Admitted now, parsing to the same value its block-sequence sibling already did: ```yaml tags: [finance, revenue, headline-metric] ``` An element is a plain scalar only if it is non-empty and carries none of `{ } [ ] : , " ' #`, and it then passes the unchanged scalar-indicator rule. The refusals that make that a rule rather than a preference: a quoted element (this parser retains quotes rather than stripping them, so reading one would hand back a value YAML does not), an element carrying a colon or a comma, a sequence inside a sequence, an empty element, an anchor or an alias. A sequence may not **mix** scalars and mappings — the same rule the block list already carries, for the same reason: a consumer iterating the value and reading `entry.get("id")` crashes on the first `str`. The `sources` flow-mapping carrier added in 1.3.0 is unchanged and pinned against regression. **Measured, with the denominator: this takes the pinned OKF corpus from 0/53 to 6/53** (`_okf-upstream/okf` @ `3fcbb9f`, 53 non-reserved documents; the six are all in `acme_retail`). It does not close the corpus. **44/53 still stop on `generated` written as a top-level block mapping** — a form refused on the no-nesting-past-depth-1 rule, which is a security decision and out of scope here. Two neighbouring predicates were measured and deliberately not built: a flush-left block sequence and a folded plain scalar release **0/53** each on their own and leave the corpus at 6/53 when stacked on this one. Detection behaviour is not frozen under semver; the exported surface is unchanged. ### Changed - `docs/LIMITATIONS.md`'s `tags`/`description` entry is rewritten against the measurement. Three of its claims were wrong: the parser *does* have a sequence-value type (since 1.3.0 — what it lacks is the indentation the corpus omits), the figure is 6/53 and not the 1.2.0-era 4/53, and `tags`/`description` are *not* the residual that blocks the corpus. - Self-safety: the new predicate is character-level with no regex, so `docs/redos-sweep.py` cannot see it (it collects compiled patterns). It is measured instead — linear in both element length (exponent 0.86-0.99) and element count (0.97-1.05) over four doublings to 800_000, and pinned by two CPU-clock bounds in `tests/test_okf.py`. ## [1.3.0] — 2026-09-02 ### Added — the `sources` provenance family becomes expressible in both spec carriers `okf.parse_frontmatter` rejected `sources` in every form the OKF spec and its producers actually use. Measured 2026-09-02 by two consumers independently: `sources: [{ id: a, resource: x }]` raised on the `[` indicator (with one entry as with two), and the block sequence of block mappings — SPEC.md §5.1's *own* example — raised `"nested mappings are not supported"`. `resource` is REQUIRED within a `sources` entry (§5.1), so the whole provenance family was unwritable, and a bundle written the way the spec documents it was refused. Admitted now, both parsing to the same value (a list of dicts): ```yaml sources: [{ id: a, resource: https://e.com/a }] sources: - id: a resource: https://e.com/a ``` **`resource` is allowlisted inside a `sources` entry and nowhere else.** 1.2.0 left it off the allowlist on the argument that the parser could not tell `sources[].resource` (§5.1, a citation) from `executor.resource` / `attester.resource` (§10, a pointer to code to be run — the door-C route closed in 1.1.0). That premise was false: the owning key is in scope at every call site and was simply never threaded through. It is threaded now, so the discrimination is structural rather than a judgement about the value, and `executor: [{ resource: skills/run.md }]` and `attester:\n - resource: …` are refused on the allowlist through *every* carrier, including the two this adds. Refusal stays the default everywhere else. A flow sequence of plain scalars (`tags: [a, b]`) still raises — the sequence carrier is opened for the flow mapping element and nothing else. A `sources` entry still admits scalar leaves only, so SPEC §5.1's optional *per-entry* `usage_window` (a mapping inside a mapping) is refused: no nesting past depth 1 is a security property, and it was not spent here. Registered as a conformance gap in `docs/LIMITATIONS.md`. A block list may not mix scalar items and mappings. Off-allowlist keys, anchors, aliases, tags, duplicate keys and unclosed collections raise as before, and a refused mapping still raises rather than degrading into a string. **New residual, registered:** `sources[].resource` is scanned as text (T1) but never validated as a URL. T3's https allowlist cannot be extended to it without over-blocking conformant bundles — §5.1 permits a bundle-relative path or a scope descriptor a consumer cannot follow at all. A consumer that dereferences it must call `okf.validate_resource_url` itself. No exported surface changed and no detector behaviour or calibration changed. Suite 834 → 868 (34 new rows, plus four pre-existing rows updated where this release changed the behaviour they pinned); 130/130 classes, 6/6 documented gaps hold, 45 limitations, ReDoS sweep 0/152 candidates flagged. ## [1.2.0] — 2026-08-23 ### Added — OKF frontmatter can express one mapping form: typed and allowlisted `okf.parse_frontmatter` gave the mapping *class* no expressible form at all. OKF v0.2 writes its whole trust and provenance layer as mappings — SPEC.md @ `62432a09` uses flow form in its own §5.1/§5.2 examples, and §11 carries a hard MUST for consumers ("MUST treat a bare `verified` mapping as a one-element list") that presupposes they parse. A consumer measured **0 of 53** upstream concepts through the gate on 0.3.4, 1.0.0 and 1.1.0. That was a contract collision, not a calibration setting: no threshold would have moved it. Admitted now: a flow mapping (`generated: { by: x, at: y }`), as a value or as a block-list item, whose every key is on a nine-name allowlist (`by`, `at`, `from`, `to`, `id`, `title`, `author`, `usage_count`, `last_modified`) and whose every leaf is a plain scalar run through the *unchanged* dangerous-value and mapping-construct predicates. The form is additive and refusal stays the default. A key off the allowlist, a nested collection, a quoted leaf, a duplicate key, an empty or unclosed mapping, and `{a:b}` (which PyYAML 6.0.3 reads as the *key* `a:b`) all raise, and a refused mapping still raises rather than degrading into a string — the 1.1.0 defect is not reopened. Nested-block (`k:\n sub: v`), dotted (`k.sub: v`) and inline-second-colon (`k: sub: v`) routes to a mapping still raise, each on its own rule. **`resource` is deliberately off the allowlist**, though SPEC.md §5.1 names it inside a `sources` entry. It is a pointer rather than a label and the only key T3 exists for: admitting it would let `executor: { resource: skills/run.md }` carry an executable-code pointer through in typed clothes, which is the door-C route closed in 1.1.0. It costs nothing today — the conformant carrier for `sources[].resource` is a block sequence of block mappings, which this form does not admit either way. Mapping leaves are scanned like every other frontmatter value (T1), so an injection parked in `generated: { by: ... }` reaches `scan_output`. Coverage matrix: 130 classes, up from 129 (the new row is the off-allowlist key). No exported surface changed; no detector behaviour and no calibration changed. ### Changed — the ReDoS sweep now measures on the same clock as the bounds it justifies `docs/redos-sweep.py` timed on `time.monotonic()` while every ReDoS bound in the suite moved to process CPU time (`tests/redos_clock.py`), so the 1.5 ms sensitivity floor and the "~23 s at the cap" figure published in `docs/LIMITATIONS.md` came from a different instrument than the bounds they support. The script now imports `scan_seconds` rather than timing itself. The floor was re-derived on that instrument and **stayed at 1.5 ms**: over twelve full runs of all 2585 arms the median ratio is 1.95-2.03 in every size bucket above 50 µs, but two-point excursions past the 2.6 flag threshold persist at every magnitude (p99 ratio 2.9-3.3 even above 1 ms) — 6.9 flagged arms per run at a 0.5 ms floor, 1.1 at 1.0 ms, 0.33 at 1.5 ms. Descheduling was never what made this sweep noisy; a ratio computed from two points is. Four arms flagged across those twelve runs, each in exactly one of them, and six arms that have ever flagged re-measure at exponent 0.97-1.09 over six doublings — at most 1.2 s at the 1 000 000-char cap. The pattern count the script prints is 152, not the 150 of the 0.3.4 entry below; `docs/LIMITATIONS.md` now carries the current number. No exported surface, no detector behaviour and no calibration changed. ## [1.1.0] — 2026-08-13 ### Fixed — a mapping construct in OKF frontmatter no longer degrades into a string `okf.parse_frontmatter` gives the mapping *class* no expressible form by design (T2). Two routes escaped that: they parsed "successfully" into the wrong **type** instead of raising. Both are closed, and both now `FAIL_SECURE` through `okf.import_bundle` (door C). | route | was | now | |---|---|---| | `sources:`
` - uri: https://e.com/a` | string `'uri: https://e.com/a'` — WARN | `OKFFrontmatterError` — FAIL_SECURE | | `sources:`
` - uri:` | string `'uri:'` — WARN | `OKFFrontmatterError` — FAIL_SECURE | | `attester: resource: attesters/x.py` | string `'resource: attesters/x.py'` — WARN | `OKFFrontmatterError` — FAIL_SECURE | The security consequence was the same in each: a pointer parked in a degraded mapping rides through in a key the `resource` allowlist never inspects, and mode-b `import_bundle` writes the merged concept verbatim. The first route was documented at `docs/LIMITATIONS.md:43`; the inline second colon was **found by measurement while closing it**, and is the reason this release names two routes rather than one. Neither shape is conformant OKF — a well-formed bundle does not produce them; a malformed or hostile one can. **What closed is the type confusion, not pointer-smuggling as a class.** T3 still inspects `resource` and nothing else, so an honest string under another key rides through exactly as before: `attester: attesters/sql_equality.py` is WARN, while the same path under `resource:` FAIL_SECUREs. The string is still scanned like any other frontmatter value under T1. Nothing about that changed here. **The boundary is where YAML puts it**, ground-truthed against PyYAML 6.0.3 rather than reasoned: `": "` and a trailing `":"` are exactly the two shapes where a plain scalar becomes a mapping, and they are refused. A colon carrying neither a space nor a line end opens no mapping — `domain:security` and `https://e.com:8443/a` still parse — and a quoted scalar (`- "uri: x"`) is still a scalar. Quotes are retained rather than stripped; that divergence from YAML is unchanged and now pinned. **This is a behaviour change inside the freeze, not a break of it.** No exported name moved. A document that disposed `WARN` on `1.0.0` may dispose `FAIL_SECURE` here — the `1.0.0` entry says exactly this is a fix, not a break. A consumer whose bundles carry an unquoted `": "` in a frontmatter value will now see those concepts refused at import; quote the value, and it parses. Suite 792 → **802**: 13 rows added (4 rejected shapes, 7 admitted ones, 2 through `import_bundle`), 3 retired (the two that pinned the defect, and the one-key row in the block-list table). 129/129 classes, 6/6 documented gaps, 35 limitations — all unchanged. ## [1.0.0] — 2026-08-13 ### Changed — the exported Python surface is frozen under semver No code changed in this release. `1.0.0` is a governance promise, not a claim that the library is finished: **no name exported from `llm_ingestion_guard` is removed, renamed or given a different meaning without a `2.0.0`.** Measured before the tag, the surface has been stable in form since `0.3.4` — four names added, none removed or renamed — while behaviour moved across five releases (`0.4.0` … `0.7.0`). **Detection behaviour is deliberately outside the freeze.** Severities, thresholds, lexicon entries and the dispositions they produce are calibration, and calibration moves in minor and patch releases. A payload that disposes `WARN` here may dispose `FAIL_SECURE` in a later `1.x`; that is a fix, not a break. Assert on the disposition your policy requires, not on a severity you observed. The behaviour changes this freeze rests on are not repeated here — see `[0.3.0]` for the active-content gate and the OKF adapter, and `[0.3.1]` for the ordinary-link/image calibration that the two consumer promises pin. ### Changed — three limitations are conceded for `1.x` rather than deferred `docs/LIMITATIONS.md` no longer says "deferred" or "pending" about any of them: - `Severity` still carries disposition intent on the detection side. Separating *what was seen* from *how bad it is* changes `Finding` and `Severity`, so it is a `2.0.0` change. Read a finding's `id` for the capability. - The input-cap asymmetry at `MAX_INPUT_CHARS` is permanent in `1.x`: surfaces that return content raise `OversizeInputError`, surfaces that return findings truncate and emit `active:oversize-input`. - The multilingual homoglyph false positive is conceded more narrowly — no fix is promised, but it is calibration, so one may land in any `1.x` release. `SECURITY.md` carries all three as documented boundaries and states the support window for a `1.x` line. ### Known at the freeze, deliberately not blocking it `docs/LIMITATIONS.md` §`:43` — an OKF block sequence with exactly one key per element misparses silently in `okf.import_bundle`, so a pointer can ride through in a key the `resource` allowlist never inspects. Closing it tightens what the adapter admits: behaviour, not form, and shippable in a `1.x` minor. It is recorded here because "we knew, and froze first" is a defensible position and "we forgot" is not. Runtime coverage at the freeze: `llm-ingestion-okf` has measured `0.3.4` and run a `0.3.4`→`0.6.1` differential on its own door across two Python versions; `llm-security-commons` differentially tested its independent reconstruction of the raw-HTML classifier against ours over 42 probe tags with 0 disagreements. **No external consumer has run the `0.7.0` runtime**; the four symbols added since `0.3.4` are additive, so a caller that does not invoke them is unaffected. ## [0.7.0] — 2026-08-13 ### Added — `active:raw-html-link`, a click-required carrier class for raw HTML Raw HTML graded on activity alone: every active tag was HIGH. So the *same URL* was LOW as `[t](https://example.com/guide)` and HIGH as `` — an asymmetry produced by syntax, not by affordance. Following an anchor needs a human, exactly like the markdown inline link that has been MEDIUM since 0.3.1. `` and `` now report as **`active:raw-html-link` at MEDIUM**. Everything a renderer fetches or executes unattended keeps `active:raw-html` at HIGH, and the event-handler test runs *first*, so `` is graded as the execute-class carrier it is rather than downgraded with the anchors. The URL-attribute branch deliberately stays on the HIGH side: a name outside the active set has unknown rendering, and `href` is not the only URL attribute it may carry. Grading `` as a link would be reasoning, not measurement. **This is a new label, and labels are a contract surface consumers pin against.** A document that previously produced one `active:raw-html` finding may now produce two findings, one per carrier class. ### Changed — a tag whose whole affordance is a URL it does not carry is inert ``, ``, `