Validator family for web content: outbound link hygiene now, page shape once the format is proven #1228

Closed
opened 2026-08-25 05:30:45 +00:00 by coilyco-ops · 1 comment
Owner

Specification from the Developer Advocate seat, handed to Developer Platform Engineer to build. Kai asked for this on 2026-08-25. Per boundary-build-foundational-software the build is platform's and the rules are the requesting seat's, so this issue says what it must do and does not say how.

Why the existing suite does not cover it

Checked against the code on 2026-08-25 rather than assumed.

dead-cross-links excludes external URLs on purpose. Its own docstring lists "External URLs (anything with a scheme, e.g. http://, mailto:)" under Out of scope, and is_external() returns early on them. It validates repo-relative targets only. So every outbound link in the estate is currently unchecked by anything.

documentation-layout owns size, in units built for doc trees. README at 160 lines and 12,500 chars, AGENTS.md at 320 and 25,000, a small band at 40 lines and 3,000 chars, a large band at 120 and 8,000. Those bands describe a repository's documentation, not a page a stranger reads once. A web page needs caps derived from attention, not from doc-tree shape.

Neither is wrong. Both were written for a different artifact.

Two defects found by hand on 2026-08-24, both of which an offline check would have caught instantly:

  • The GitHub profile README named ward-mcp and cli-guard, two repositories that had been renamed to mcp-beaver and umbra. A reader searching either name found nothing. Fixed in 9c259e5.
  • The same file linked three coilysiren.me/orgs/<org>/ pages that have never existed. Fixed in 4d526d0, found only because 25 links were checked one at a time by hand.

The estate has renamed repositories at least three times: ward-mcp to mcp-beaver, cli-guard to umbra, and agent-guard retired as a name for ward. Every rename leaves live links pointing at the old path across surfaces nobody re-reads.

Build the link half now. Wait on the shape half.

The link rules are independent of any content format and would pay for themselves against the two defects above. The shape rules describe a page format that currently has exactly one draft instance, in coilysiren/inbox#417. A validator is the ossification of a pattern, and ossifying a schema nobody has stress-tested across four pages is how a linter becomes something people add excludes for. Revisit once all four pages exist and the format has survived contact.

Pre-commit must stay fast and must not depend on the network, which the offline trufflehog run already establishes as the house expectation. So this hook does static analysis only, and network liveness is hook 3 below.

What it must catch:

  1. Retired names and paths. The highest-value check and the cheapest. A maintained data file lists retired repository names, display names, and paths, each with its replacement. Any link or prose mention of a retired name fails with the replacement named in the message. Seed it with ward-mcp, Ward MCP, cli-guard, and agent-guard. This is the check that would have caught the profile README.
  2. Canonical host per link class. Repository links resolve to one declared host rather than a mix. The estate already has this convention informally, since 4b76318 deliberately moved resume evidence links to GitHub and a later rename reintroduced a Forgejo link that sat inconsistent for weeks.
  3. Link text that disagrees with its target. Text naming one project pointing at another repository's path. A rename usually updates one and not the other.
  4. Placeholder and local URLs. example.com, localhost, TODO, empty targets, and bare #.

Rules and replacements are data, not code, so a rename updates a table rather than a validator.

Hook 2: web content shape, deferred

Specified now so the shape is on record, built after the format is proven.

  1. Required front matter, keys present and typed. The current draft carries project, claim, repo, canonical, release, license.
  2. claim matches the repository's canonical description, which coilysiren/inbox#397 establishes as the only solid thing about these repositories. Needs the description available offline, so it reads a synced data file rather than making a network call.
  3. Required sections, present and in order. The order is the buyer-framing order and it is load-bearing rather than stylistic.
  4. Per-section caps derived from reading, not from doc bands. The hero section is the one that most needs a cap, because a hero that runs long stops being a hero. The artifact section's code block needs a small cap too, since its whole claim is that the reviewable surface is a few lines.

Caps follow the existing convention. Constants in the validator, docs/catalog-caps-reference.md regenerated by just gen-caps-reference, and check-caps-reference-drift guarding staleness. Do not introduce a second place where a number lives.

Actually fetching every outbound URL is slow, flaky, and network-dependent, so it must not be a commit hook. It belongs as a scheduled Forgejo Actions job on the same shape as agentic-os-kai's .forgejo/workflows/sync-repo-digests.yml: runs daily, alerts on failure, no-ops cleanly without its token.

Requirements: report only non-2xx and non-3xx, tolerate rate limiting without failing the run, and report rather than edit. A link checker that opens pull requests against prose is worse than one that files a report.

Out of scope

  • Voice and house style. Already covered by the writing-voice-guide-linter and kai-voice-guide-linter skills. This family must not duplicate them.
  • Repo-relative links. dead-cross-links owns those and is working.
  • Rollout. Per the authoring-versus-rollout split, the validator and its .pre-commit-hooks.yaml entry are authored here and fanned across the fleet by an ansible role in infrastructure, never by this repository.

Done means

  • Hook 1 lands with its retired-name table seeded from the four known renames
  • Running it against coilysiren/coilysiren at 9c259e5^ reproduces both defects found by hand
  • Caps, if any land, are generated into docs/catalog-caps-reference.md and drift-guarded
  • docs/FEATURES.md gains an entry, since this is a new validator family rather than a bugfix
  • Hook 2 explicitly deferred with a note pointing at coilysiren/inbox#417
Specification from the Developer Advocate seat, handed to Developer Platform Engineer to build. Kai asked for this on 2026-08-25. Per `boundary-build-foundational-software` the build is platform's and the rules are the requesting seat's, so this issue says what it must do and does not say how. ## Why the existing suite does not cover it Checked against the code on 2026-08-25 rather than assumed. **`dead-cross-links` excludes external URLs on purpose.** Its own docstring lists "External URLs (anything with a scheme, e.g. `http://`, `mailto:`)" under Out of scope, and `is_external()` returns early on them. It validates repo-relative targets only. So every outbound link in the estate is currently unchecked by anything. **`documentation-layout` owns size, in units built for doc trees.** README at 160 lines and 12,500 chars, AGENTS.md at 320 and 25,000, a small band at 40 lines and 3,000 chars, a large band at 120 and 8,000. Those bands describe a repository's documentation, not a page a stranger reads once. A web page needs caps derived from attention, not from doc-tree shape. Neither is wrong. Both were written for a different artifact. ## Evidence that link rot is a live problem here, not a hypothetical Two defects found by hand on 2026-08-24, both of which an offline check would have caught instantly: * The GitHub profile README named **`ward-mcp`** and **`cli-guard`**, two repositories that had been renamed to `mcp-beaver` and `umbra`. A reader searching either name found nothing. Fixed in `9c259e5`. * The same file linked three `coilysiren.me/orgs/<org>/` pages that have never existed. Fixed in `4d526d0`, found only because 25 links were checked one at a time by hand. The estate has renamed repositories at least three times: `ward-mcp` to `mcp-beaver`, `cli-guard` to `umbra`, and `agent-guard` retired as a name for `ward`. Every rename leaves live links pointing at the old path across surfaces nobody re-reads. ## Recommended split, and a sequencing opinion **Build the link half now. Wait on the shape half.** The link rules are independent of any content format and would pay for themselves against the two defects above. The shape rules describe a page format that currently has exactly one draft instance, in `coilysiren/inbox#417`. A validator is the ossification of a pattern, and ossifying a schema nobody has stress-tested across four pages is how a linter becomes something people add excludes for. Revisit once all four pages exist and the format has survived contact. ## Hook 1: outbound link hygiene, offline Pre-commit must stay fast and must not depend on the network, which the offline trufflehog run already establishes as the house expectation. So this hook does static analysis only, and network liveness is hook 3 below. What it must catch: 1. **Retired names and paths.** The highest-value check and the cheapest. A maintained data file lists retired repository names, display names, and paths, each with its replacement. Any link or prose mention of a retired name fails with the replacement named in the message. Seed it with `ward-mcp`, `Ward MCP`, `cli-guard`, and `agent-guard`. This is the check that would have caught the profile README. 2. **Canonical host per link class.** Repository links resolve to one declared host rather than a mix. The estate already has this convention informally, since `4b76318` deliberately moved resume evidence links to GitHub and a later rename reintroduced a Forgejo link that sat inconsistent for weeks. 3. **Link text that disagrees with its target.** Text naming one project pointing at another repository's path. A rename usually updates one and not the other. 4. **Placeholder and local URLs.** `example.com`, `localhost`, `TODO`, empty targets, and bare `#`. Rules and replacements are data, not code, so a rename updates a table rather than a validator. ## Hook 2: web content shape, deferred Specified now so the shape is on record, built after the format is proven. 1. **Required front matter**, keys present and typed. The current draft carries `project`, `claim`, `repo`, `canonical`, `release`, `license`. 2. **`claim` matches the repository's canonical description**, which `coilysiren/inbox#397` establishes as the only solid thing about these repositories. Needs the description available offline, so it reads a synced data file rather than making a network call. 3. **Required sections, present and in order.** The order is the buyer-framing order and it is load-bearing rather than stylistic. 4. **Per-section caps derived from reading, not from doc bands.** The hero section is the one that most needs a cap, because a hero that runs long stops being a hero. The artifact section's code block needs a small cap too, since its whole claim is that the reviewable surface is a few lines. **Caps follow the existing convention.** Constants in the validator, `docs/catalog-caps-reference.md` regenerated by `just gen-caps-reference`, and `check-caps-reference-drift` guarding staleness. Do not introduce a second place where a number lives. ## Hook 3: link liveness, scheduled rather than pre-commit Actually fetching every outbound URL is slow, flaky, and network-dependent, so it must not be a commit hook. It belongs as a scheduled Forgejo Actions job on the same shape as `agentic-os-kai`'s `.forgejo/workflows/sync-repo-digests.yml`: runs daily, alerts on failure, no-ops cleanly without its token. Requirements: report only non-2xx and non-3xx, tolerate rate limiting without failing the run, and report rather than edit. A link checker that opens pull requests against prose is worse than one that files a report. ## Out of scope * **Voice and house style.** Already covered by the `writing-voice-guide-linter` and `kai-voice-guide-linter` skills. This family must not duplicate them. * **Repo-relative links.** `dead-cross-links` owns those and is working. * **Rollout.** Per the authoring-versus-rollout split, the validator and its `.pre-commit-hooks.yaml` entry are authored here and fanned across the fleet by an ansible role in `infrastructure`, never by this repository. ## Done means - [ ] Hook 1 lands with its retired-name table seeded from the four known renames - [ ] Running it against `coilysiren/coilysiren` at `9c259e5^` reproduces both defects found by hand - [ ] Caps, if any land, are generated into `docs/catalog-caps-reference.md` and drift-guarded - [ ] `docs/FEATURES.md` gains an entry, since this is a new validator family rather than a bugfix - [ ] Hook 2 explicitly deferred with a note pointing at `coilysiren/inbox#417`
Author
Owner

Hook 1 landed in #1229 as d261805a, with the deferral pointer following in #1230.

Against the done-conditions

  • Hook 1 lands with its retired-name table seeded from the four known renames - done. agentic_os/outbound_link_rules.json carries ward-mcp, Ward MCP, cli-guard and agent-guard, each with its replacement, plus the coilysiren.me/orgs/ path. Rules are data, so a rename edits the table.
  • Reproduces both defects found by hand - done, with one correction below.
  • Caps generated and drift-guarded - vacuous. Hook 1 introduces no numeric cap, so docs/catalog-caps-reference.md is unchanged and check-caps-reference-drift stays green.
  • docs/FEATURES.md gains an entry - done.
  • Hook 2 explicitly deferred with a note pointing at coilysiren/inbox#417 - done in #1230. #1229 deferred it and said why but did not point anywhere, which is a weaker artifact than you asked for.

One correction to the issue

The issue says running against 9c259e5^ reproduces both defects. The org-path half does. The retired-name half does not: 77a54fb had already cleaned the README, so at 9c259e5^ the only retired-name hits are in .agents/skills/coilysiren-portfolio/SKILL.md. Both classes reproduce together one commit earlier, at 9c259e5~2, where the README still carries Ward MCP, ward-mcp and cli-guard.

The finding you did not ask for

Run against coilysiren/coilysiren on today's main, the hook is not clean. .agents/skills/coilysiren-portfolio/SKILL.md lines 50 and 51 still name cli-guard and link https://github.com/coilyco-flight-deck/cli-guard, which is now umbra. The README fixes were real, but the same rename survived in the portfolio skill next to it, which is precisely the failure mode this issue describes: a rename updates one surface and not the other. Fixing that is the profile repo's call, not this one's.

The design decision worth disagreeing with

Use versus mention carries the whole exemption mechanism. Fenced and inline code are stripped before the name scan, so a doc narrating a rename backticks the retired name and a doc still using it does not.

I measured that against agentic-os, agentic-os-kai, coilysiren and infrastructure before committing to it. Every legitimate historical mention found was already backticked, including SSM parameter paths and IAM ARNs that keep a pre-rename spelling on purpose. Zero allowlist entries needed anywhere. The two unbackticked hits it does produce, in agentic-os-kai/docs/scout-displacement-targets.md and an infrastructure reference page, are both stale prose rather than false positives.

Canonical host went the other way. The issue reads the convention as estate-wide, but the estate runs a real split: the profile repo deliberately moved evidence links to GitHub, while AGENTS.md tells agents to keep outward-facing refs on the canonical Forgejo form. So canonical_repo_host is per-repo config and unset means no opinion, rather than a fleet-wide host I would have picked wrong.

What did not land

Hook 3's checker is authored and tested as check-link-liveness, deliberately not a commit hook. The scheduled job that runs it is not built, because which repo hosts it is a placement call rather than a build call, and the surfaces whose links rot are not this repo. Carried in #1231 with the three options and my read on them.

Rollout stays out per the authoring-vs-rollout split: no DEFAULT_HOOK_IDS entry, so audit-pre-commit-coverage will report consumers as missing it until an ansible role fans it out.

Hook 1 landed in #1229 as `d261805a`, with the deferral pointer following in #1230. ## Against the done-conditions - **Hook 1 lands with its retired-name table seeded from the four known renames** - done. `agentic_os/outbound_link_rules.json` carries `ward-mcp`, `Ward MCP`, `cli-guard` and `agent-guard`, each with its replacement, plus the `coilysiren.me/orgs/` path. Rules are data, so a rename edits the table. - **Reproduces both defects found by hand** - done, with one correction below. - **Caps generated and drift-guarded** - vacuous. Hook 1 introduces no numeric cap, so `docs/catalog-caps-reference.md` is unchanged and `check-caps-reference-drift` stays green. - **`docs/FEATURES.md` gains an entry** - done. - **Hook 2 explicitly deferred with a note pointing at coilysiren/inbox#417** - done in #1230. #1229 deferred it and said why but did not point anywhere, which is a weaker artifact than you asked for. ## One correction to the issue The issue says running against `9c259e5^` reproduces both defects. The org-path half does. The retired-name half does not: `77a54fb` had already cleaned the README, so at `9c259e5^` the only retired-name hits are in `.agents/skills/coilysiren-portfolio/SKILL.md`. Both classes reproduce together one commit earlier, at `9c259e5~2`, where the README still carries `Ward MCP`, `ward-mcp` and `cli-guard`. ## The finding you did not ask for Run against `coilysiren/coilysiren` on today's `main`, the hook is not clean. `.agents/skills/coilysiren-portfolio/SKILL.md` lines 50 and 51 still name `cli-guard` and link `https://github.com/coilyco-flight-deck/cli-guard`, which is now `umbra`. The README fixes were real, but the same rename survived in the portfolio skill next to it, which is precisely the failure mode this issue describes: a rename updates one surface and not the other. Fixing that is the profile repo's call, not this one's. ## The design decision worth disagreeing with Use versus mention carries the whole exemption mechanism. Fenced and inline code are stripped before the name scan, so a doc narrating a rename backticks the retired name and a doc still using it does not. I measured that against agentic-os, agentic-os-kai, coilysiren and infrastructure before committing to it. Every legitimate historical mention found was already backticked, including SSM parameter paths and IAM ARNs that keep a pre-rename spelling on purpose. Zero allowlist entries needed anywhere. The two unbackticked hits it does produce, in `agentic-os-kai/docs/scout-displacement-targets.md` and an `infrastructure` reference page, are both stale prose rather than false positives. Canonical host went the other way. The issue reads the convention as estate-wide, but the estate runs a real split: the profile repo deliberately moved evidence links to GitHub, while `AGENTS.md` tells agents to keep outward-facing refs on the canonical Forgejo form. So `canonical_repo_host` is per-repo config and unset means no opinion, rather than a fleet-wide host I would have picked wrong. ## What did not land Hook 3's checker is authored and tested as `check-link-liveness`, deliberately not a commit hook. The scheduled job that runs it is not built, because which repo hosts it is a placement call rather than a build call, and the surfaces whose links rot are not this repo. Carried in #1231 with the three options and my read on them. Rollout stays out per the authoring-vs-rollout split: no `DEFAULT_HOOK_IDS` entry, so `audit-pre-commit-coverage` will report consumers as missing it until an ansible role fans it out.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os#1228
No description provided.