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+\"[^\"]*\")?\s*\)")
+REFERENCE_LINK_DEF = re.compile(r"^\s*\[[^\]]+\]:\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,
]