diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 32771dd..812a95d 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -202,6 +202,7 @@ reader-question-chain) while leaving those rows' every other ruling intact. | Deletion-first answer obligations (#830, 2026-08-20) | Supersedes the answer-shape portions of the whitelist era — #479 Wave B cut 2's single `must_state` list, #823's placement-only D-series, and #825's "relevance is governed, placement and form are free" — while leaving every integrity gate those rows built exactly where it was. `engine/evaluation_challenge.py` emits three lists instead of one: `must_state` (the floor), `may_state` (the concentration family and cash — computed, addressed, owed by default on no call), and `machine_state` (`basis.state_version`, never rendered in any register). Its readers: `schemas/evaluation-challenge.schema.json` (additive; new keys are required because the block is computed fresh per call and never stored, and the `must_state` topic enum narrows to match `TOPICS` because `tests/test_evaluation_challenge.py` holds those two as an ordered equality) ↔ `references/trade-consequence.md` "What the answer owes" (which now states a reason per keep and per delete, and defines *stated*: the fact appears with its correct anchor — inline number, table cell, or end-block line all qualify, so fifteen obligations are not fifteen sentences) ↔ `SKILL.md`'s answer shape ↔ `references/freeform-answers.md` rule 4 ↔ `docs/expression-contract.md` D7 ↔ `tools/ux_receipt.py` ↔ `tests/test_evaluation_challenge.py` / `test_consider.py` / `test_expression_contract.py` / `test_interaction_trajectory.py` / `tests/agent/check_expression.py` / `evals/trade_answers/`. **The deletion is of obligations, never of data**: every number is still computed, still anchorable, still citable, and the user can ask for any of it — which is what makes not saying a number this decision does not turn on different from hiding it. **The two silences that could hurt stayed machine-enforced**: `rule_effects` and `required_coverage` are untouched, so a rule the *user* wrote is still named and still refused-for-dropping while the engine's own default threshold became a may-state. **Volume distribution became expression's business** (D7: a fact lives on exactly one floor — opening body, parenthetical beside its number, one end-block line, or not rendered), which §2 had disclaimed and nothing else had claimed; that gap is why fifteen owed facts became fifteen body sentences with every D rule satisfied. The reading-budget rule proposed as V10 in the issue is **not** adopted: a length cap is what #827 had just deleted. The named risk is the other direction — a deciding consequence omitted — and its backstops are the two hard mandates above plus the owner-live `comprehension` verdict, never a checker. | | Cross-route research baseline (#716, 2026-08-23) | `skills/fomo-kernel/references/research-priors.md` is the single catalogue and the single statement of how far a prior may travel. Its readers: `references/decision-framing.md` (the route that already had it — the baseline is its top sentence) ↔ `references/trade-consequence.md` ("The research baseline, and why it may not fill a gap") ↔ `references/freeform-answers.md` ("The research baseline is available here too") ↔ `docs/expression-contract.md` §6 ↔ `tests/test_research_priors.py`. Four rules. **The catalogue is reachable from every route that answers a decision, not only from the one with no book.** Before this row it was linked by exactly one file, and the user-visible consequence was inverted evidence value: a user with nothing recorded heard that broad diversification is the baseline and that an index label alone does not establish breadth, and the same user, after handing over a book, got weights and concentration and could no longer reach that baseline at all. `test_b_the_catalogue_is_reachable_from_every_route` is what fails when a route's link is dropped, and it walks `SKILL.md → route file → catalogue` rather than asserting the catalogue exists, because a reference nothing names is text nothing loads (the `profile.md` precedent). **An engine fact dominates a prior, and that boundary has one home.** `research-priors.md`'s own "An engine fact dominates a prior" section owns it: a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence **verbatim, not paraphrased**, and name the section it comes from: a paraphrase is how five independent phrasings of answer-first happened (#832), and a pointer alone is a boundary the agent has to open a second file to learn. This is #834's answer applied to a rule instead of an exemplar — the same text in three places, made mechanical rather than forbidden, with `test_e_the_engine_fact_boundary_is_one_sentence_in_three_places` failing on any drift and `test_f_reachability_and_boundary_mutations_are_caught` proving it fails. `decision-framing.md`'s "A missing number is never replaced by a general rule" red line stays where it is and gains a pointer: it is the *no-book* form (no computed weight exists at all), and the new section is the form for the routes that do have one. **It is a claim authority, not a derivation.** It adds no shape parameter to any surface, so `expression-contract.md` §6's derivation column is deliberately **unchanged** and the catalogue appears in the last column instead; `freeform-answers.md`'s empty derivation (#832) stays empty and says so in the new section itself. A prior enters an existing block, is subject to §3's increment gate like any other, and a standing paragraph on the value of diversification is the `default_as_insight` ban with a citation stapled to it. **No runtime surface moved**: no engine file, schema, `.json` payload contract, new field, or new engine vocabulary — #716 §1 says the first version needs no runtime schema, and `SKILL.md` is untouched because it already routes to all three route files, which is the whole point of #507's byte budget on the always-loaded pair. Why the row exists: #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route, so the issue's own §4 — the boundary that makes the other routes safe — had never been written down anywhere. | | The six non-negotiable boundaries reach the installed host (#838, 2026-08-23) | `README.md` installs the product by symlinking `skills/fomo-kernel/` alone, so `AGENTS.md` — and any rule stated only there — does not exist on an installed host. The floor's six boundaries therefore live twice: `AGENTS.md` "## Non-negotiable boundaries" (the checkout floor) ↔ `skills/fomo-kernel/SKILL.md` (each boundary stated in the section that exercises it; the privacy boundary as its own "Private data stays local" section, key sentences verbatim — except the anything-public example list, deliberately re-cast for the installed audience as "a shared card, an example, a bug report" where the floor names the maintainer's issues/PRs/fixtures/receipts) ↔ `skills/fomo-kernel/references/agent-boundaries.md` (the routed may/may-not contract `SKILL.md` names as holding throughout; carries the CLI whitelist, the hand-assembly ban, and the third-party/cloud privacy sentence). No text runtime surface under `skills/fomo-kernel/` (every `.md`/`.json`/`.html`/`.txt`) may cite `AGENTS.md` for a rule — its reader may not have the file; `references/freeform-answers.md` stating the card-privacy default instead of citing "AGENTS.md invariant 4" is the pattern. `.py` comments under `engine/` and `tools/` sit outside that scan deliberately: boundary 1 keeps an installed agent out of engine internals, so those citations are maintainer-facing rationale, not instructions an installed reader follows. `tests/test_installed_skill_tree.py` reads **only** `skills/fomo-kernel/` and fails when a boundary's statement retreats to the repository root — every other suite reads the checkout, which is exactly why #838 shipped unseen. The #507 byte budget split with it: `SKILL.md` has its own ceiling (the whole always-loaded surface of an installed host) and `AGENTS.md` its own (the checkout-only floor), because one budget over a pair that exists only in a checkout let the floor's bytes squeeze the installed contract to zero headroom — the pair sat at exactly 16384 bytes when #838 was found. | +| The installed tree names its authorities and links to none of them (#839, 2026-08-23) | #838's class, second instance: nine files under `skills/fomo-kernel/` linked `../../docs/expression-contract.md`, `docs/output-contract.md`, `docs/output-voice.md`, or `evals/episodes/README.md` — seventeen relative links that resolve to nothing on an installed host. All seventeen were provenance, not routes: the answer shape is `SKILL.md`'s own §3 projection, each surface reference opens with its exemplar (#834), card structure is enforced by `engine/card_renderer.py`, and `ux-receipt.md` already handles the vendored-without-`evals/` case explicitly — so no in-tree rule depended on out-of-tree content. The ruling that keeps it that way: **the installed tree may *name* an authority ("the repository's `docs/expression-contract.md`") but may never *link* out of the tree** — a citation is provenance, a relative link is a promise the file is right there, and on an installed host that promise is false. Distinct from the `AGENTS.md` rule above deliberately: `AGENTS.md`'s content had to move in-tree (the boundaries bind every answer), while the expression/output authorities stay in `docs/` and the tree carries their projections, so naming them is required by the projection discipline ("this is its projection" must say of what). Gated by `tests/test_installed_skill_tree.py::test_no_reference_escapes_the_installed_tree`: every extracted target (inline markdown links with or without titles, reference-style definitions, HTML href/src) and every upward-relative path token in any other syntax must resolve inside the tree — resolution decides, not a directory name list, so in-tree upward hops (`../schemas/`, `../evals/`) stay legal; site-root and absolute targets count as escapes because the tree cannot promise where it is mounted; scanned over every text runtime surface, the same scope as the `AGENTS.md` citation ban. `engine/review.py`'s comment mis-citing "ledger-derived current holdings stay canonical" as boundary 6 was corrected to boundary 2 in passing; the other `.py` AGENTS.md mentions stay, per the scan-scope rationale in the #838 row. | Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence. diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index e161e13..f2e0940 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -41,7 +41,7 @@ The engine computes portfolio consequence; it is not a company-research service. ## Shape of the answer -One shape, every answer (`../../docs/expression-contract.md` §3 owns it; this is its projection, not a second wording). **A fact lives on exactly one floor, and twice is a bug.** *Top:* one sentence — the stance and the reason that decides it (proceed, resize, delay, collect evidence, choose one candidate, no trade). *Middle:* only blocks that add a new decision-relevant fact or judgment — delete one; if the decision does not change, delete it. There live the numbers that would flip the call, every `rule_effects` entry (never optional), a truth-critical denominator, unit, or pricing set beside its number, and a falsifier on any directional call — the counter-case needs no section. *Bottom:* the rest of the inventory stays in the data layer; say once you can expand it. *End:* one compact block for other material limitations; machine anchors and engine narration nowhere. +One shape, every answer (§3 of the repository's expression contract, `docs/expression-contract.md`, owns it; this is its projection, not a second wording). **A fact lives on exactly one floor, and twice is a bug.** *Top:* one sentence — the stance and the reason that decides it (proceed, resize, delay, collect evidence, choose one candidate, no trade). *Middle:* only blocks that add a new decision-relevant fact or judgment — delete one; if the decision does not change, delete it. There live the numbers that would flip the call, every `rule_effects` entry (never optional), a truth-critical denominator, unit, or pricing set beside its number, and a falsifier on any directional call — the counter-case needs no section. *Bottom:* the rest of the inventory stays in the data layer; say once you can expand it. *End:* one compact block for other material limitations; machine anchors and engine narration nowhere. Never manufacture a scenario nobody asked for, restate a system default as insight, hedge in couplets, or make one point twice. Ask only decision-changing questions, then stop. `references/trade-consequence.md` holds the rest. Each reference opens with its exemplar; follow it. diff --git a/skills/fomo-kernel/card-spec.md b/skills/fomo-kernel/card-spec.md index d3dd727..b154fa4 100644 --- a/skills/fomo-kernel/card-spec.md +++ b/skills/fomo-kernel/card-spec.md @@ -1,6 +1,6 @@ # Review card content specification -> Structure authority is [docs/output-contract.md](../../docs/output-contract.md); +> Structure authority is the repository's `docs/output-contract.md`; > execution is `engine/card_renderer.py` plus `references/card-policy.md`. This > file records what the card is for and how to word the honesty sentences the > agent owns. Agents do not assemble or redact cards manually. diff --git a/skills/fomo-kernel/engine/review.py b/skills/fomo-kernel/engine/review.py index 83c1f53..bb00c6e 100644 --- a/skills/fomo-kernel/engine/review.py +++ b/skills/fomo-kernel/engine/review.py @@ -8231,7 +8231,7 @@ def _positions_diagnosis(rows, canonical_held, weights, last_px, max_pos_overrid weights for the same ticker at the same instant depending on which entry point answered -- 28.6% from the FIFO route this function used to read, 37.5% from `consider`'s canonical one, and weight is the number - this product's own rules are built on. `AGENTS.md` boundary 6 -- + this product's own rules are built on. `AGENTS.md` boundary 2 -- ledger-derived current holdings stay canonical -- settles which one wins, and issue #456 already owns the general tension (a considered trade reasons on a different basis than a review's own CSV/FIFO path); diff --git a/skills/fomo-kernel/references/card-policy.md b/skills/fomo-kernel/references/card-policy.md index a6b3b96..ebbaf7c 100644 --- a/skills/fomo-kernel/references/card-policy.md +++ b/skills/fomo-kernel/references/card-policy.md @@ -1,7 +1,7 @@ # Card policy > Section order and section set are governed by -> [docs/output-contract.md](../../../docs/output-contract.md) (keynote + four +> the repository's `docs/output-contract.md` (keynote + four > blocks), which also owns every rule about which figures render as tiles > versus prose. This file keeps only what the agent decides: wording, > redaction, and narrative. diff --git a/skills/fomo-kernel/references/decision-framing.md b/skills/fomo-kernel/references/decision-framing.md index e1efd2b..19df4d6 100644 --- a/skills/fomo-kernel/references/decision-framing.md +++ b/skills/fomo-kernel/references/decision-framing.md @@ -8,7 +8,7 @@ falsifier they could write down themselves. Read it before writing one. It is copied verbatim from `tests/agent/expression-witnesses.json`, scene `no_book_single_name` — the corpus that -[expression-contract.md](../../../docs/expression-contract.md) §3.5 makes the +§3.5 of the repository's expression contract (`docs/expression-contract.md`) makes the binding statement of this shape. Every issuer in it is invented, and `tests/test_expression_contract.py` fails if the two copies disagree. @@ -43,9 +43,9 @@ when it could change the recommendation or unlock the portfolio claim. ## Voice and expression authority -Apply the global [expression contract](../../../docs/expression-contract.md): -the answer's shape through its §3 mother chapter, voice through the -[output-voice contract](../../../docs/output-voice.md) (V1–V9), disclosure +Apply the repository's global expression contract (`docs/expression-contract.md`): +the answer's shape through its §3 mother chapter, voice through its +output-voice contract (`docs/output-voice.md`, V1–V9), disclosure relevance and placement through D1–D7, provenance labelling through C1–C4. They own universal output semantics; this reference owns the no-book facts, questions, and route order below. @@ -198,7 +198,7 @@ Three rules follow, and the third is the one that keeps the first two honest: 1. Select material portfolio facts by salience, not as a checklist. State them as limitations or ask about them according to whichever form best advances the recommendation. -2. A limitation that cannot be turned into a question is stated plainly and once — "I have secondary reporting, not the filing" — when it could change the framing or prevent a false impression of coverage. Put a truth-critical denominator, unit, or pricing set beside its number; place other material limitations where they make the answer clearest ([expression contract](../../../docs/expression-contract.md) D1–D2). +2. A limitation that cannot be turned into a question is stated plainly and once — "I have secondary reporting, not the filing" — when it could change the framing or prevent a false impression of coverage. Put a truth-critical denominator, unit, or pricing set beside its number; place other material limitations where they make the answer clearest (expression contract D1–D2, `docs/expression-contract.md`). 3. A material limitation may never simply disappear. Dropping the narration is a change of shape, not permission to leave a decision-relevant gap unsaid. > **History:** this rule once required per-claim placement, then #823 replaced diff --git a/skills/fomo-kernel/references/freeform-answers.md b/skills/fomo-kernel/references/freeform-answers.md index d941732..e7892cf 100644 --- a/skills/fomo-kernel/references/freeform-answers.md +++ b/skills/fomo-kernel/references/freeform-answers.md @@ -9,7 +9,7 @@ and one end block. Read it before writing one. It is copied verbatim from `tests/agent/expression-witnesses.json`, scene `freeform_positions_view` — the corpus that -[expression-contract.md](../../../docs/expression-contract.md) §3.5 makes the +§3.5 of the repository's expression contract (`docs/expression-contract.md`) makes the binding statement of this shape. Every issuer in it is invented, and `tests/test_expression_contract.py` fails if the two copies disagree. @@ -180,7 +180,7 @@ calculate, interpolate, or silently widen them. ## Rule 4 — limitations follow relevance, not a template -Use [expression-contract.md](../../../docs/expression-contract.md) D1–D7 for +Use the expression contract's D1–D7 (`docs/expression-contract.md`) for every limitation. Keep truth-critical denominator, unit, or pricing-set qualifiers inline. Include other limitations only when they materially qualify the answer; no marker or numeric line cap is required. diff --git a/skills/fomo-kernel/references/research-priors.md b/skills/fomo-kernel/references/research-priors.md index 5fd80dd..37c4f13 100644 --- a/skills/fomo-kernel/references/research-priors.md +++ b/skills/fomo-kernel/references/research-priors.md @@ -35,7 +35,7 @@ this file states. A prior is a claim authority, not a block, a question, or a standing paragraph: it adds no shape parameter to any surface, so the increment gate in -[expression-contract.md](../../../docs/expression-contract.md) §3 governs it +the repository's expression contract (`docs/expression-contract.md`) §3 governs it like every other block. One that changes nothing about what the user should do is deleted, not shortened. diff --git a/skills/fomo-kernel/references/trade-consequence.md b/skills/fomo-kernel/references/trade-consequence.md index bc91735..3df9b88 100644 --- a/skills/fomo-kernel/references/trade-consequence.md +++ b/skills/fomo-kernel/references/trade-consequence.md @@ -9,7 +9,7 @@ valuation gap as one end block. Read it before writing one. It is copied verbatim from `tests/agent/expression-witnesses.json`, scene `consider_three_way_comparison` — the corpus that -[expression-contract.md](../../../docs/expression-contract.md) §3.5 makes the +§3.5 of the repository's expression contract (`docs/expression-contract.md`) makes the binding statement of this shape. Every issuer in it is invented, and `tests/test_expression_contract.py` fails if the two copies disagree. @@ -376,9 +376,9 @@ Under maintainer QA, delivery of these obligations is proven rather than assumed ## Route-specific synthesis -Apply the global [expression contract](../../../docs/expression-contract.md): -the answer's shape through its §3 mother chapter, voice through the -[output-voice contract](../../../docs/output-voice.md) (V1–V9), disclosure +Apply the repository's global expression contract (`docs/expression-contract.md`): +the answer's shape through its §3 mother chapter, voice through its +output-voice contract (`docs/output-voice.md`, V1–V9), disclosure relevance and placement through D1–D7, provenance labelling through C1–C4. Those own how this answer speaks; this section owns only the `consider` route's salience facts and answer slots. @@ -389,7 +389,7 @@ decision-relevant facts without turning available ones into standing copy. ### Derivation from the pyramid -The answer's shape is [expression contract §3](../../../docs/expression-contract.md)'s +The answer's shape is expression contract §3's — one sentence on top, an increment-gated middle, the rest of the inventory behind a single offer, one caliber block at the end — and this file states it nowhere else (#832; before that, the reader's-question-chain paragraph here was diff --git a/skills/fomo-kernel/references/ux-receipt.md b/skills/fomo-kernel/references/ux-receipt.md index 294fdf2..afd0c25 100644 --- a/skills/fomo-kernel/references/ux-receipt.md +++ b/skills/fomo-kernel/references/ux-receipt.md @@ -224,7 +224,7 @@ These owner judgments are product gates, not schema-derived claims: a timing war A run's last act is archiving, and for a long time nothing at that moment asked what it had found. #417 measured the result: eighteen receipts, one archived manifest, and zero replayable assets. `findings_recorded` is the moment that asks, and `verify --require-findings` is what makes it unskippable. -Record it once, and before the owner verdict — `verify` enforces that ordering for the same reason it enforces the card sequence: the verdict is the last act, so a disposition recorded after it was reconstructed rather than observed. Each `--finding` is one miss and its disposition, and there are exactly two honest ones: `episode:EP-NNN` for a miss converted into a replayable episode ([evals/episodes/README.md](../../../evals/episodes/README.md)), or `not-episodable:#NN:` for one that only an issue can hold — whether the card reached the screen, for instance, is a receipt question, not an answer question. +Record it once, and before the owner verdict — `verify` enforces that ordering for the same reason it enforces the card sequence: the verdict is the last act, so a disposition recorded after it was reconstructed rather than observed. Each `--finding` is one miss and its disposition, and there are exactly two honest ones: `episode:EP-NNN` for a miss converted into a replayable episode (the repository's `evals/episodes/README.md`), or `not-episodable:#NN:` for one that only an issue can hold — whether the card reached the screen, for instance, is a receipt question, not an answer question. A converted id is resolved against the bank in this checkout — on the write path *and* again on `verify`, so a hand-authored or later-edited receipt cannot carry a conversion that never happened. Resolution reads each episode file's declared `id`; a file that cannot be read or parsed backs no claim, and cannot stop the gate from running either. Two limits worth knowing: it proves an episode with that id exists, not that the episode is a good one (that is `evals/run_episodes.py`'s job), and where the bank is not reachable at all — a skill directory vendored without `evals/` beside it — the id is checked for shape only. That is the one place gate 7 is on the runner rather than on the tool. The event also rejects any field beyond the dispositions, because this is the row a maintainer is most tempted to paste miss text into and it sits inside the state directory's trust boundary. diff --git a/skills/fomo-kernel/references/weekly-market-read.md b/skills/fomo-kernel/references/weekly-market-read.md index 27c04e5..36921b1 100644 --- a/skills/fomo-kernel/references/weekly-market-read.md +++ b/skills/fomo-kernel/references/weekly-market-read.md @@ -8,7 +8,7 @@ the next-week check closes. Read it before writing one. It is copied verbatim from `tests/agent/expression-witnesses.json`, scene `weekly_read_connection` — the corpus that -[expression-contract.md](../../../docs/expression-contract.md) §3.5 makes the +§3.5 of the repository's expression contract (`docs/expression-contract.md`) makes the binding statement of this shape. Every issuer in it is invented, and `tests/test_expression_contract.py` fails if the two copies disagree. @@ -41,8 +41,8 @@ commitment, or canonical state. A public L1 event, if a host later adds one, must follow `market-lookup.md`: one triggered packet maximum, source/as-of on every public fact, and never infer the user's motive. -How that brief is said is not this file's to decide: apply the global -[expression contract](../../../docs/expression-contract.md) — the answer's +How that brief is said is not this file's to decide: apply the repository's global +expression contract (`docs/expression-contract.md`) — the answer's shape through its §3 mother chapter, voice V1–V9, disclosure relevance and placement D1–D7, provenance labelling C1–C4. Source and as-of on a public fact are C2; the labelled judgment risk is C1. This file owns only what the read may diff --git a/tests/test_engine_units.py b/tests/test_engine_units.py index 401d8a0..7baa6df 100644 --- a/tests/test_engine_units.py +++ b/tests/test_engine_units.py @@ -2050,7 +2050,9 @@ def test_resolve_cash_anchor_input_refuses_a_non_positive_held_mv(): """Fails closed rather than converting against a garbage denominator (#662) -- zero, negative, non-finite, and missing all refuse the same way a missing/incompatible valuation refuses elsewhere (AGENTS.md - boundary 6), instead of silently producing a zero or nonsensical amount.""" + invariant 2: the engine owns every portfolio-derived number and never + fabricates one), instead of silently producing a zero or nonsensical + amount.""" anchor = {"currency": "USD", "percent_of_total": 30, "as_of": "2026-07-30"} for bad_held_mv in (0.0, -1.0, float("nan"), float("inf"), None): try: diff --git a/tests/test_installed_skill_tree.py b/tests/test_installed_skill_tree.py index 7907c42..3b1f4a6 100644 --- a/tests/test_installed_skill_tree.py +++ b/tests/test_installed_skill_tree.py @@ -17,13 +17,17 @@ `docs/`, or any file outside that directory -- and it fails the moment a boundary's statement retreats to the repository root, or a file inside the subtree cites `AGENTS.md` for a rule (a citation an installed reader cannot -follow, because the reader has no such file). +follow, because the reader has no such file), or a markdown link resolves to +a path outside the subtree (#839 -- a relative link claims the file is right +there, and for an installed host it is not). Deliberately independent of `tests/test_doc_language.py` and `tests/test_repo_hygiene.py`: importing either would reintroduce a root-reading suite as a load-bearing part of this one's logic, and the whole point is that this suite proves nothing those two suites already prove. """ +import posixpath +import re import sys import tempfile from pathlib import Path @@ -251,6 +255,71 @@ def citation_violations(sources): ] +# Every syntax a target travels in: inline markdown links (title and +# angle-bracket forms included), reference-style definitions, and HTML +# href/src attributes. A fourth arm below catches upward-relative path +# tokens outside any link syntax -- inline code, prose, fenced commands. +INLINE_LINK_TARGET = re.compile(r"\]\(\s*]+)>?(?:\s+\"[^\"]*\")?\s*\)") +REFERENCE_LINK_DEF = re.compile(r"^\s*\[[^\]]+\]:\s*?\s*$") +HTML_ATTR_TARGET = re.compile(r"(?:href|src)=\"([^\"]+)\"") +UPWARD_PATH_TOKEN = re.compile(r"(?:\.\./)+[A-Za-z0-9_.@/-]+") +EXTERNAL_URL = re.compile(r"(?:https?://|mailto:|data:)\S+") + + +def _escapes_tree(base, target): + """Whether ``target``, referenced from directory ``base``, leaves the tree. + + Lexical, never touching the filesystem, so mutation arms can drive it on + doctored text. A site-root or absolute target counts as an escape: the + installed tree cannot promise anything about where it is mounted, so the + only paths it may resolve are its own relative interior. + """ + target = target.split("#", 1)[0] + if not target or EXTERNAL_URL.match(target): + return False + if target.startswith("/"): + return True + return posixpath.normpath(posixpath.join(base, target)).startswith("..") + + +def escaping_reference_violations(sources): + """``"file:line: reason"`` for every reference that escapes the tree (#839). + + The installed tree may *name* its out-of-tree authorities -- "the + repository's `docs/expression-contract.md`" is a citation, like naming a + book -- because every rule an answer needs is stated in-tree and the + authority reference is provenance for maintainers. What it may not do is + *link or path* there: a resolvable-looking reference promises the reader + the file is right there, and on an installed host that promise is false. + Every extracted target and every upward-relative path token, in any + syntax, must resolve inside the tree -- resolution decides, not a + directory name list, so in-tree upward hops (`../schemas/`, `../evals/`) + stay legal while any escape reddens whatever it points at. + """ + violations = [] + for rel, text in sources: + base = posixpath.dirname(rel) + for number, line in enumerate(text.splitlines(), 1): + targets = [m.group(1) for m in INLINE_LINK_TARGET.finditer(line)] + targets += [m.group(1) for m in HTML_ATTR_TARGET.finditer(line)] + definition = REFERENCE_LINK_DEF.match(line) + if definition: + targets.append(definition.group(1)) + for target in targets: + if _escapes_tree(base, target): + violations.append( + f"{rel}:{number}: link escapes the installed tree: " + f"{target}") + # Outside link syntax entirely. External URLs are stripped first + # so a `/../` segment inside one is not read as a local path. + for token in UPWARD_PATH_TOKEN.finditer(EXTERNAL_URL.sub("", line)): + if _escapes_tree(base, token.group(0)): + violations.append( + f"{rel}:{number}: upward path escapes the installed " + f"tree: {token.group(0)}") + return violations + + def test_every_boundary_phrase_is_present_in_the_installed_tree(): """Every one of the six boundaries' phrases must be readable from `skills/fomo-kernel/` alone -- the entire tree an installed host gets. @@ -290,6 +359,82 @@ def test_no_file_under_the_installed_tree_cites_agents_md(): ) +def test_no_reference_escapes_the_installed_tree(): + """#839: nine files carried seventeen upward-relative links -- sixteen + into `docs/` and one into `evals/` -- every one a dead pointer on an + installed host, the same delivery class as #838's unreachable + `AGENTS.md`. Authorities are now cited by + repository path in prose; the rules those authorities own are stated + in-tree (the answer shape is SKILL.md's own projection, each reference + opens with its exemplar, card structure is enforced by the engine + renderer), so no reference needs to leave the tree. Scanned over every + text runtime surface, the same scope as the citation ban above -- the + card templates carry href/src attributes too. + """ + violations = escaping_reference_violations( + iter_text_runtime_sources(SKILL_TREE)) + assert not violations, ( + "references escape skills/fomo-kernel/ (dead on an installed " + "host):\n " + "\n ".join(violations) + ) + + +def test_escape_gate_mutations_are_caught(): + """Every extraction syntax and both resolution rules, each driven through + the same ``escaping_reference_violations`` the live test runs, on the + real tree's own sources with one thing injected -- plus two in-tree + negative arms proving the gate rejects escapes, not relative paths as + such. The adversarial probe set that shaped this list: titled links, + HTML href, reference-style definitions, site-root targets, and non-docs + escape targets all previously slipped the gate; in-tree `../evals/` was + previously a false red. + """ + real = list(iter_text_runtime_sources(SKILL_TREE)) + assert not escaping_reference_violations(real), ( + "fixture assumption broken: the real tree already escapes") + + nested = "references/agent-boundaries.md" + for required in (nested, "card-template.html"): + assert any(rel == required for rel, _ in real), ( + f"fixture assumption broken: the source walk did not reach " + f"{required}") + + def reddens(rel_target, extra, label): + mutated = [(rel, text + extra if rel == rel_target else text) + for rel, text in real] + violations = escaping_reference_violations(mutated) + assert any(v.startswith(f"{rel_target}:") for v in violations), ( + f"{label} injected into {rel_target} stayed green") + + def stays_green(rel_target, extra, label): + mutated = [(rel, text + extra if rel == rel_target else text) + for rel, text in real] + violations = escaping_reference_violations(mutated) + assert not violations, f"{label} was falsely flagged: {violations}" + + reddens(nested, "\nSee [the contract](../../docs/expression-contract.md).\n", + "a plain escaping link") + reddens(nested, "\nSee [it](../../README.md \"readme\").\n", + "a titled link to a non-docs out-of-tree target") + reddens(nested, "\n[floor]: ../../AGENTS.md\n", + "a reference-style link definition") + reddens("card-template.html", + "\nstructure\n", + "an HTML href escape in a card template") + reddens(nested, "\nSee `../../evals/episodes/README.md`.\n", + "an upward inline-code path into evals/") + reddens(nested, "\nSee [the root](/docs/output-contract.md).\n", + "a site-root target") + reddens("SKILL.md", "\nSee [the floor](../AGENTS.md).\n", + "a top-level file escaping with a single ../") + + stays_green(nested, + "\nSee [the schema](../schemas/trade-premise.schema.json).\n", + "an in-tree ../schemas/ link") + stays_green(nested, "\nThe bank is `../evals/evals.json`.\n", + "an in-tree ../evals/ code span") + + def test_boundary_and_citation_checks_are_mutation_proof(): """(a) the real tree is green; (b) removing any one boundary phrase reddens the checker for that exact (label, file, phrase); (c) injecting @@ -392,6 +537,8 @@ def main(): test_every_boundary_phrase_is_present_in_the_installed_tree, test_the_routed_boundary_file_is_reachable_from_skill_md, test_no_file_under_the_installed_tree_cites_agents_md, + test_no_reference_escapes_the_installed_tree, + test_escape_gate_mutations_are_caught, test_boundary_and_citation_checks_are_mutation_proof, test_every_test_in_this_module_is_registered, ]