| State | Draft |
| Architectural Significance | MEDIUM |
| Domain | Developer Tooling |
| Document version | 0.2 |
| Related | ADR-0021 · ADR-0017 · ADR-0019 |
Affected code: crates/rto-graph/src/screen.rs#screen_text,
crates/rto-graph/src/screen.rs#Verdict, crates/rto-graph/src/screen.rs#FindingKind,
crates/rto-render/src/okf/read.rs#read_bundle.
Id note. When this was scaffolded, 0023 was held by an unmerged branch
(#749, since merged), so the graph could not see it and spec scaffold proposed
it here. Taken as 0024 by hand. This is the one case that command cannot get
right — an id allocated in a branch is not yet a fact about the repository — and
it is worth recording rather than filing as a bug against the tool.
crates/rto-graph/src/screen.rs#screen_text screens a peer's bundle before its
text can reach a model. Its own module documentation lists six things it
deliberately does not attempt. An external contributor — the author of
okf-guard, which the screen credits as prior art — asked in issue #723 whether
those could be covered.
Four are closed, one becomes a report and a screen, and one stays refused. The two that are not simply closed are the decisions; the rest is work.
The sharpest correction came from outside the exclusion list. It said "a bundle
is markdown", and a bundle is not: okf-core resolves a frontmatter path to any
file, §10's computation: names one, and the four published bundles being
all-markdown is a fact about those four bundles. A peer can hand over a valid
bundle citing a hijacked PDF, and every report we produce would call it clean
without mentioning the PDF exists.
Nothing here shells out, and the reason is the shape of the interface rather than its cost — see Option A, whose first draft got that wrong and said so.
The screen was written against okf-guard's model and inverted its aim: okf-guard protects your pipeline from your sources, and this protects this graph from someone else's finished bundle. That inversion produced a narrower tool on purpose, and the module says so.
Re-reading those exclusions against the question, one of them argues against the wrong thing:
No homoglyph or confusable detection. okf-guard maps 23 Cyrillic letters; the full Unicode confusables table is far larger, and any subset of it fires on legitimately multilingual prose. A peer writing Russian is not an attacker.
That is a correct objection to "contains Cyrillic". It is not an objection to
the rule UTS #39 actually specifies, which is about a single token mixing
scripts — раypal is an attack and a Russian sentence is not. The exclusion
was defending against a strawman of its own construction.
Close four gaps natively, inventory everything a bundle carries, screen whatever can be read out of it, keep refusing semantic judgement, and make the two that can grow configurable but never weakenable.
okfguard at the ingestion boundaryRejected, but the first version of this section rejected it for two reasons that were false, and the correction is worth keeping rather than quietly replacing.
It said the integration would mean "a subprocess per concept over a
9,511-concept bundle" and "a Python runtime as a hard dependency of import".
Neither describes what was proposed. Reading the CLI rather than assuming it:
okfguard scan -r <dir> is one process over a tree, --json is
newline-delimited per file rather than a single aggregate, and a standalone
binary consulted when present is an optional integration point, not a
dependency anyone building Roteiro has to pay for. Both objections were
answered, correctly, on issue #723.
Three reasons survive, and all three are about what the interface can express:
crates/rto-render/src/okf/read.rs#read_bundle
screens all three separately — then deliberately downgrades a Block on the
title or description, because a title that does not survive falls back to the
filename while a body has no such fallback. A per-file verdict cannot express
"block this body, keep this title", which is the outcome the importer actually
produces.clean_text observation the module already credits okf-guard for, and it is
the load-bearing one. [Screened::admit] is what may be stored: byte-identical
on Pass; the prose with invisible codepoints and presentation-hidden regions
removed on a Quarantine with no directive; and None on a quarantined
directive, because the words are the payload and redacting a phrase leaves a
sentence that still reads as one. c.body = body.admit.unwrap_or_default() is
what reaches meta.content. An exit code — and per-file JSON findings too —
give a judgement, not the bytes, so the screen would still have to run to
produce them.ADR-0017's refusal of okf-validator is a related argument about cost, and it
is not this one. It is cited here only to mark the difference: that dependency
was refused because everyone building the tool would pay for it, and this option
is refused although almost nobody would.
Rejected for extraction, but the reasoning that first rejected it was wrong and the correction changed this ADR.
The original argument was "an OKF bundle is markdown, so those adapters cover
formats a bundle cannot contain". That is false. okf-core's own
resolve_path_field resolves a frontmatter path to any file with is_file()
and no extension filter, §10's computation: names a file, and the four
published bundles happening to be all-markdown is a property of those four
bundles rather than of the format.
Checked rather than assumed, on a bundle carrying a PDF:
GET /f/docs/policy.pdf
HTTP/1.1 200 OK
content-type: application/octet-stream
content-security-policy: default-src 'none'; sandbox; base-uri 'none'
x-content-type-options: nosniff
Two things are already sound: the viewer refuses to link a non-markdown file
— only images resolve to /f/, so a markdown link to a PDF renders as refused
text — and roteiro import reads no binary at all.
Serving one is nearly sound, and the gap is worth naming precisely rather than
glossed. The response carries a content type from a closed allow-list, the
file-scoped default-src 'none'; sandbox policy, and nosniff — so an unknown
extension is typed application/octet-stream and cannot be sniffed into
something executable. It does not carry Content-Disposition: attachment.
Most browsers download application/octet-stream rather than rendering it, but
that is convention rather than something the response asks for, and "it is served
as an attachment" was the wrong description of what these headers do.
So the inventory below comes with one header: /f/ will set
Content-Disposition: attachment for anything outside the image allow-list.
The same reasoning as the content type itself — a bundle does not get to choose
how its bytes are presented — and it costs one header on a path that already
decides, by extension, what each file is allowed to be.
What is missing is that nobody is told they exist. okf validate, lint,
trust, links and info all report on a bundle and none of them mentions that
it carries three PDFs. A person reads "0 violations", decides the source is
trustworthy, and the binaries were never in scope of the thing they read. That is
the gap, and it is a reporting gap rather than an extraction one.
Extraction is not refused, and the first draft's reason for refusing it was
false twice over. It said a PDF parser is dependency weight under ADR-0017, and
that the media pipeline is where it would belong "if it is ever wanted".
Roteiro has extracted PDF text since before this ADR: pdf-extract is a declared
optional dependency, gated behind the pdf-text feature, and
crates/rto-graph/src/extract.rs#pdf_text runs it size-bounded at 20 MiB
and panic-guarded, feeding a file node's embeddable content. The trade was made
and paid for; the ADR argued against making it.
The stronger correction is the one that follows from that. Refusing to extract
is refusing to screen. A binary is unscreenable because nothing reads it —
that is a consequence of the decision, not a fact about the format. An inventory
says a bundle carries three PDFs; extraction plus [screen_text] says one of
them carries a hidden instruction, which is the question the screen exists to
answer.
So the inventory is the floor rather than the ceiling: what a bundle carries is always reported, and what can be read is read and then screened like any other text. Which formats those are, and who decides per file, is ADR-0025 — extraction is an ingestion question, and this ADR is about what the screen does with text once it has it.
| gap | decision | rule |
|---|---|---|
| homoglyphs | close | UTS #39 mixed-script within one token, never "contains a script" |
| encoded payloads | close | decode and re-screen, to a configurable depth |
| non-English directives | reshape | control tokens first; phrases via an optional dictionary |
| CSS cascade | close, bounded | same-document <style> only; no specificity, no inheritance |
| semantic judgement | refuse | unchanged — whether text is an attack is not decided here |
| binary files | report always; screen whatever can be read | a bundle can carry them, and nothing said so |
| serving one | Content-Disposition: attachment | a bundle does not choose how its bytes are presented |
A bundle's non-markdown files are counted and listed — by path, size and extension — wherever a bundle is summarised, and surfaced in the consent prompt, which is the moment a person decides whether to trust the source. Nothing is opened, parsed or extracted.
The screen's own principle applies: a finding has to do something. Knowing a bundle carries an unscreenable file and not saying so is the same failure as computing a quarantine verdict and then storing the body anyway.
The original exclusion said "recursive decoding is unbounded", which argues against recursion rather than against depth. Nesting is a real obfuscation, and the cost of following it is small: base64 shrinks 4:3 and hex 2:1, so each level is strictly smaller than the last and five levels cost less than twice the first.
[security.screen]
decode_depth = 5 # 0 disables; default 5
Two guards, because depth is where false positives come from. A run is only decoded if it is long enough to be a payload rather than a word, and recursion only continues while the output is plausibly text — random bytes are not re-screened, which is what stops a chain of accidental decodes eventually matching a directive pattern by chance.
"English only" is a real gap. Translating the phrase list is the obvious answer and the weaker one: five languages of hand-written phrases is a maintenance burden carrying false-positive risk in languages no maintainer here reads.
The higher-signal, language-independent vector is chat-template control
tokens — <|im_start|>, <|system|>, [INST], <<SYS>>, ### System:. These
carry no language at all and are the most direct way to address a model. They go
in first.
Phrases then become extensible without a code change:
[security.screen]
directive_dictionaries = ["docs/screen/directives.de.toml"]
A dictionary may only add patterns. It cannot remove, disable or override a built-in one. This is the load-bearing rule: a configurable screen whose configuration can weaken it is a screen that is off in exactly the repository where somebody found it inconvenient, and the failure would be silent. Adding is safe, subtracting is not, so only adding is possible.
decode_depth = 0 is a supported answer, because a repository that has
measured the cost and does not want it should be able to say so, and a setting
people disable by patching the source is worse than one they disable by name.Each lands separately, with its measurement over the four published bundles:
Content-Disposition — first, because it is the
half of this ADR that is a reporting gap rather than a detection one, and the
only half that answers the scenario which prompted it.decode_depth, defaulting to 5, with the plausibly-text guard.<style> only.Issue #723, from the author of okf-guard. The proposal was to consult their
CLI at the ingestion boundary; the useful part was the question behind it, which
was whether the exclusions were principled or merely unimplemented. Four of them
were unimplemented.
They also corrected this ADR's first characterisation of that proposal, which had invented a per-concept subprocess and a hard runtime dependency out of neither. Option A now records what was actually proposed and rejects it on the interface, which is the argument that was there to be made.
The depth default of 5 and the dictionary mechanism were both directed rather than proposed: the first because one level only defeats the laziest nesting, the second because it makes translations somebody else's contribution instead of a maintainer's backlog.
| Version | Date | Notes |
|---|---|---|
| 0.1 | 2026-09-02 | Draft. Records that "a bundle is markdown" was false — okf-core resolves a frontmatter path to any file, so a peer can cite a hijacked PDF and every report would call the bundle clean without mentioning it; binary files are therefore inventoried in every bundle report and in the consent prompt, and /f/ will set Content-Disposition: attachment for anything outside the image allow-list — the response typed them but never said how they should be presented, and "served as an attachment" described a behaviour no header asked for. Extraction is not refused: the first draft refused it on the grounds that a PDF parser is dependency weight and the media pipeline is where it would belong "if ever wanted", and Roteiro has extracted PDF text since before this ADR — pdf-extract, gated behind pdf-text, size-bounded and panic-guarded in extract.rs. The stronger correction is that refusing to extract is refusing to screen: a binary is unscreenable because nothing reads it, so the inventory is a floor and not a ceiling. Which formats can be read, and who decides per file, moves to ADR-0025. Closes four of the screen's stated exclusions, reshapes the non-English one around language-independent control tokens plus additive dictionaries, and refuses only semantic judgement. decode_depth defaults to 5 and is configurable; dictionaries may only ever add patterns, because a screen whose configuration can weaken it fails silently in the repository that weakened it. Records that the homoglyph exclusion argued against "contains Cyrillic" rather than against UTS #39's mixed-script rule. |
| 0.2 | 2026-09-03 | Option A rejected the external scanner for two reasons that were false, and they are replaced rather than removed. It claimed "a subprocess per concept over a 9,511-concept bundle" and "a Python runtime as a hard dependency of import"; okfguard scan -r is one process over a tree, --json is per-file NDJSON, and a standalone binary consulted when present is optional. Both were answered on #723 before this ADR was corrected, so the record and the public answer disagreed until now. The decision stands on three reasons about what the interface can express: the screen is per field rather than per file, and downgrades a Block on a title because a title falls back to its filename while a body does not; Screened::admit returns admissible text rather than a label, so an exit code cannot supply what lands in meta.content; and a screen consulted only when installed is absent, silently, wherever it is not — subtraction by omission, which is the failure the additive-only dictionary rule exists to prevent. |