AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻译准备中
Companion plugin for the Signal & Noise theme — SEO, login hardening, first-party edge analytics, Cloudflare purge, music/discography sync, and native wp-admin tooling built on the WP 7.0 Abilities API + AI Client.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Companion plugin to the Signal & Noise theme for juanlentino.com. It holds the operational tooling that doesn't belong in a presentation theme — SEO, security, analytics, admin surfaces, and AI-assisted editorial helpers — so the theme stays focused on design and the plugin owns behaviour.查看完整 README ↗The plugin that runs juanlentino.com, one person's WordPress site, built in the open. It isn't a product and isn't on WordPress.org; the code is public so the approach can be read and borrowed. The look lives in the companion Signal & Noise theme; this plugin owns everything the site does.
Three things make it unusual:
The rest of this file is the detail: what it does, then in depth. What every model does here, and what agents read, is in AI.md.
/music page readstests/mcp-capabilities.php pins both doors' tool lists), and a remote endpoint for an assistant on a phone; the site as something agents read, with the rights terms they read it under[sn_colophon] page and the Maturity hub with its system pages, painted as spec sheetsEach of these is expanded under In depth.
meta descriptions, canonical handling, Open Graph cards, sitemaps + IndexNow, a redirect manager with a 404 capture log, and cache excludes (replaces a third-party SEO plugin)
WordPress hardening (Permissions-Policy, REST user-enumeration lock, XML-RPC off), a custom login slug, and a read-only login-defense panel over the edge login-guard Worker's decisions; the five headers Cloudflare emits at the edge (CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) are drift-probed by a health check rather than duplicated in PHP
first-party, cookieless edge analytics: a Cloudflare Worker collects pageviews into Cloudflare Analytics Engine, server-side SQL rollups aggregate them into durable tables, and a server-rendered dashboard plus two home-dashboard widgets read them, with AI insights and narration derived from the same rollups (the third-party Plausible dependency this replaced is fully retired; only a few labels, such as the Insights description, still name it)
Live (23.x): what is happening right now, from the same cookieless data and never per person. A realtime refresh (at most every 30 s, and only while someone is watching) reads human readers in the last 5 minutes, views since midnight in the site's time, the last hour in 5-minute slots, the pages being read, and where readers arrived from, into one cache (inc/analytics-realtime.php, inc/analytics-live-*.php). /stats opens with a Live strip: Reading now with the last hour as bars, Views today, and Being read now, which lists only published, unprotected, indexable notes and pages. The admin Right now panel (classic and native) adds Arrived from, which is never public (it rides live/admin and the local-door live-now ability only), and a live-surge signal: the last completed slot against the same time of day on at least four earlier days, by median and MAD, said in words and raised as a neutral Attention row when it is a surge (inc/analytics-live-surge.php). The surge is an analytics signal, deliberately not an ML pipeline, because the ML family keeps reader data out. Agents read it all through signal-noise/live-now and sn-status{live}.
On the OpenStation desktop the analytics widgets are a pair: Traffic (views over 14 days with the trend, then countries, devices, sources, Hacker News, search, feed subscribers and top pages) and Reading (scroll depth, time per view, visits, custom events, Core Web Vitals). Traffic also carries a live Reading now line, the last hour as bars and the page most read right now.
Alerts run hourly and say only what changed: a traffic spike on a note or the site, a real page answering server errors to visitors (three in one UTC day, read from the daily edge 5xx rollup; the mail breaks the day down by status, who answered and who asked, with Worker and other errors as context that does not count; see docs/ops/edge-5xx-break-alert.md), a purge that never landed, and a new Hacker News story linking to the site or one reaching the front page (inc/hn-mentions.php, the public Algolia and Firebase APIs, no key). They arrive by email and as an in-app notification in the OpenStation shell; there is no push service.
The Posts view is not built on pageviews. One row per published note over the dense signals the site already syncs: Search Console impressions, clicks and position (the sync's own window, labelled once), URL Inspection coverage with the coverage state verbatim, the last crawl marked when it predates the last edit, inbound internal links from the live link graph, the anchored provenance version and block, the ML kernel's related-note count, and lifetime views as a raw number with no verdict attached. Three flags, each a true binary derived in one place: Not indexed, Stale crawl, Orphaned. The queue lists only flagged notes, most urgent first, with the fix each flag implies; the full table sits behind a toggle and sorts on every column. A missing signal renders as its reason (not shown, not inspected, never crawled, unsigned), never as a zero. Machine reads appear once, site-wide, because the crawler sensor keeps no document paths. The sustained / cooling / spike shape survives only where it feeds the insights forecast, and only above a floor of 15 lifetime views. Stale crawl compares the crawl to the last body change (the newest signed commit; post_modified only for unsigned posts), so a tag edit or a bulk save is not a reason to ask Google back. The same rows ride the read door as signal-noise/posts-signals and the posts_signals section of sn-status, so an agent reads the three flags rather than re-deriving them.
The queue closes the loop. A tenth Attention reader, Search, reads the same rows and emits a row when a flag has stood past a horizon: a note Discovered, not indexed after seven days, Crawled, not indexed on sight (a verdict, not a wait), a stale crawl fourteen days after the body change. Each row's door is Search Console's URL Inspection page for that exact URL, because Google exposes no API to request indexing; the row states, the door points, you press, and Acknowledge holds until the state moves. New notes get two single inspections of their own, at day 3 and day 10 after publish, written into the same coverage map the weekly run writes, so "Discovered" surfaces in days rather than at the next Monday.
a 34-check scan (among them: missing alt text, orphaned media, broken internal + rotted external links, stale posts, time-phrase and color drift, unlinked mentions, link opportunities, edge security-header drift, edge-Worker reachability, analytics integrity, the provenance integrity sweep, the rights-signals drift probe, the public ledger's own CI, ML cousins, publishing cadence, the rights-signal anchoring gap, search titles, Zenodo DOIs, Jev's reading of every note's search title and description, and Jev's reading of every note against its tags), run from Measurement → Health or the run-health-scan ability (inc/health-check-*.php, 28 modules; a check is only live once it carries all four of its registrations). The contrast report leads with what the live pages show: inc/health-contrast-rendered.php reads the theme's latest contrast.yml run from the public GitHub API (cached six hours) and says ok, the failure count linked to the run, or unknown, never a pass it cannot read. The placement-dependent pairings and token-pair counts below it are possibilities, not failures
cryptographic provenance for Notes: every publish/edit is Ed25519-signed, content-hashed, and Bitcoin-anchored via OpenTimestamps, then mirrored to a public git ledger. Readers get a byline verification chip + expandable record panel (with clickable Bitcoin-block links) and can independently verify any Note on the public /verify page — including a word-level diff between any two signed versions — no trust in the site required. Backed by a dedicated sn-provenance Cloudflare Worker; the plugin owns the canonical form, chain, and admin panel. Separately, a note's first publish asks the Internet Archive (Save Page Now) for a capture, so a dated outside copy exists from day one; notes that predate the keys are pushed by a one-time, owner-started run on Integrity › Provenance, one every five minutes (archive-push-existing, read back with archive-status). An hourly pass asks the Archive for each request's outcome and records captured, failed or unconfirmed on the note. The author also holds a key of their own and countersigns ledger records with it in batches, so a compromised site cannot forge the author's attestation: the sn_prov_author_key option (22.5.0) publishes that key in /.well-known/provenance-keys.json with role: "author" and in did.json as a verification method that never signs a note, and the ledger accepts it only while this entry and the _provenance-author DNS record match its key history. The Archive credentials are two wp-config.php constants, SN_ARCHIVE_ACCESS_KEY and SN_ARCHIVE_SECRET_KEY.
a W3C Webmention receiver at signal-noise/v1/webmention, advertised both ways the spec allows (a <link rel="webmention"> and a Link: header on every publicly viewable singular page). A webmention is treated as an unverified claim, never a fact: the public endpoint can only ever create an unverified row, and adjudication happens later on cron, which re-fetches each claim and sorts it by what can actually be checked. Claims whose link has gone, or that could not be reached, are kept but shown to nobody. The receiver is rate limited: 10 claims per IP per hour (every request counts, refusals included) and 20 per source host per day, each refused with a named 429 (sn_cit_rate_limited_ip, sn_cit_rate_limited_host) and a retry_after. The inbox holds at most 2,000 unverified claims at once; past that a new claim gets sn_cit_inbox_full, also a 429, until cron works the queue down.
ONE Cloudflare credential set under Connections › Cloudflare (API token, zone ID, account ID; inc/cloudflare-credentials.php), resolved by every Cloudflare call the plugin makes: the purge, the monitor, the Analytics Engine reads and the Edge view. The leaf lists the grants the one token needs, each marked documented, measured or candidate. A separately saved analytics token keeps working as an override until dropped; an empty central token takes it once on upgrade. Saving a note sends one purge by cache tag (the theme tags every cached response sn-render) plus the core sitemap URLs; every purge call waits for Cloudflare's answer, retries at 1, 5 and 15 minutes, and a purge that never lands is held as a failure that raises a cache alert. An update purges once, and never flushes Redis. There is no cache status readout and no routine purge button: purge-all-caches stays as the emergency ability, page caches only unless asked for more. Beside it, a daily Cloudflare monitor (inc/cloudflare-monitor.php, Connections › Cloudflare › Monitor, sn-status{cloudflare}): the API token's status and expiry, the zone's last seven days (requests, cached share, bytes, threats, 4xx/5xx) and the firewall's last 24 hours (events by action, top rules). A reading the token cannot make names the permission to add (Zone › Analytics › Read) and is never counted as zero. It exists because Cloudflare publishes no rate-limit headers, so the API-limits row could never fill; that row now reads the monitor.
a daily sync mirrors Muso.AI verified producer credits + Spotify album media into a cached store, exposed to the theme's /music page (role-filtered discography grid + featured player) via filters
eight intent-coherent tabs (Dashboard, Site, Content, Connections, Measurement, AI, Security, Integrity) plus the analytics dashboard, command palette, cron dashboard, audit log, and deploy/health views (native wp-admin styling)
The plugin is a first-class citizen of the WordPress/openstation shell, and still works on the pre-rename "Desktop Mode" family — which one is live is detected at runtime, never declared.
Three native App Framework windows:
| Window | What it paints | How |
|---|---|---|
S&N Dashboard (apps/sn-dashboard) | the classic admin page, all ~40 leaves | a faithful port: the same render callables paint the same HTML, every form saves through the same handler table — a host layer of four seams (capture, rewrite, assets, four write pipelines) |
S&N Analytics (apps/sn-analytics) | 13 report views — overview, visits, content, posts, engagement, campaigns, search, geography, technology, events, quality, edge, login defense | server views on the shell's <os-*> kit |
Signal & Noise (apps/signal-noise) | Notes, Pages, Discography, Citations, Schedules, Attention | native-only client view |
A per-user preference picks native or classic-iframe windows for the two that have a classic twin. It lives as a Signal & Noise tab in OpenStation Preferences — registered server-side with openstation_register_settings_tab() and painted client-side through wp.os.registerSettingsTab() — and the two classic admin URLs are remapped to their native windows with registerNativeUrlRemap(), so a deep link into the old screen opens the new one when the preference says so.
MIO, the shell's companion (OpenStation ≥ 1.1.9), inside the three windows. Three per-user switches on the same Signal & Noise tab: tips, plain-text callouts that say which state an item is in (an integrity verdict names the sweep it came from; a date-only watch says a clock rang and nothing was measured; an empty Commits table points at Trust checks), never a model call; help, five Markdown documents (apps/signal-noise/help/) and two read-only tools (list_items, read_item) registered for Ask MIO, which stays behind the shell's AI switch and connector gate, so the plugin makes no call of its own; look, the site's palette on the mascot through openstation_mio_config, opt-in, the user's own saved look still winning. No write-effect tool exists: every write here is a button.
Columns in the shell's own native Posts window (OpenStation ≥ 1.1.8, openstation.postsWindow.columns, assets/os-posts.js): Provenance, each Note's anchor-status badge, from the sn_provenance REST field, and Evergreen, from meta._sn_evergreen. Both ride the list request the window already makes, with no extra request per row. An unsigned Note paints no Provenance: absence is a gap, never a pass. (The Edge column and its sn_edge field were removed on 2026-10-06: no per-post probe has run since #1850.) The first Signal & Noise views to move onto the shell's surface instead of ours.
An Attention pill in the same window's toolbar (openstation.postsWindow.toolbarTrailing): "Attention · N", where N is what the app last composed — the pill reads the app's 60-second cache through GET /openstation/attention and never runs the ten readers itself; with no cache it paints without a number. A click opens the Signal & Noise app on its Attention section (wp.os.openWindow with a section param, honoured by the app's mount/reopen). The section stays ours; the pill only points at it.
An Attention row can be solved where it is: an edge row offers Purge edge, an anchors row Retry anchor — the note dossier's own dispatches, with the post's type resolved from the section that lists it, never from the client — and every row offers Acknowledge, which hides it at its current stamp and brings it back the moment the fact moves (a new probe, sweep or commit is a new stamp), so an acknowledgement can never bury a recurring fault. The pill counts what is left.
Around the windows: 6 desktop widgets (traffic, reading, queue, systems, deploy status, provenance; twelve folded to six in 2026-10) · 22 SN: commands in the ⌘K palette · a dock entry with an update-count badge and two desktop icons · an attention badge carrying the plugin's real queues · an S&N Analytics card on Station Home (structured data, no plugin markup) · drop-to-draft on the shell's OS-file-drop pipeline · a repaired PWA manifest icon set · fixes to the shell's own Plugins window · a nav-id migration so dock placement survived the move from menus to apps.
The AI Copilot gets its tool schemas repaired at the boundary, a prune list that keeps the tool budget paid, a system-prompt appendix teaching it the analytics vocabulary, and generation-budget shaping for ceiling-bounded reasoning (upstream #517).
Every seam is pinned against a named upstream tag by tests/openstation-compat.php; docs/openstation-compat.md is the audit trail.
What the integration hit on its way in went upstream, as merged pull requests and the issues that preceded them. None was sought out: every one is a seam this plugin crossed first, reported so the next integration doesn't have to.
Three kinds of model run in the ecosystem, and each has exactly one job: a text model (Claude, through WordPress's AI Client) suggests, an embedding model (Workers AI) relates, and Jev (TypeSafe's System One, through Tribar Connector for TypeSafe Jev) judges. A human clicks before anything a model said reaches a published note, and the site is itself a thing models read, under stated rights terms. The whole map, with every reading, its rubric, its cost and where it is pinned, is AI.md. The Anthropic and Google model pickers fill themselves: a daily job asks each connected provider what it serves, and prices come daily from LiteLLM's public price list, guarded (a change of more than 4x is held, not applied). The default model is pinned in code and never advances by itself; ai-models-status says what was last read.
More than a hundred plugin-registered Abilities, plus their remote twins (alongside the theme's; tabled in docs/ai-abilities-catalog.md, and live as the sn://abilities-catalog resource, which is where the count is true) reachable via wp ability run and the Abilities REST route, plus a native MCP JSON-RPC server with two curated doors: a read-only door at signal-noise/v1/mcp and a read-write door at signal-noise/v1/mcp-rw (their slug lists are pinned in tests/mcp-capabilities.php) gated by a kill switch, a bound application password, a per-minute rate limit, and its own audit log. The write door is deliberately small: sn-apply is one tool covering every mutation, behind four gates (fingerprint, validation, capability, idempotency) with dry_run defaulting to true. Both door sizes are pinned by tests/mcp-capabilities.php — that suite, not this paragraph, is where the number is true.
Both doors are dual-era: the legacy initialize handshake (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05) and the modern per-request-metadata revision (2026-07-28) on the same endpoint, selected by how the request opens — modern _meta or a modern MCP-Protocol-Version header goes to inc/mcp/mcp-modern.php; an initialize goes to the legacy router in inc/mcp/mcp-server.php, byte-for-byte as before. The modern layer implements server/discover, validates the mirrored Mcp-Method / Mcp-Name headers against the body (-32020), answers an unknown version with -32022 and the supported list, pairs -32601 with HTTP 404, and decorates every result with resultType and serverInfo, list and read results with ttlMs / cacheScope: "private". It is the same layer, check for check, as src/modern.mjs in the remote Worker; tests/mcp-modern.php mirrors the Worker's suite. A GET or DELETE on either door answers 405.
The write door, by name: sn-apply, ai-link-apply, ai-pair-suggest, describe-tags, apply-tag-description, prune-unused-tags, unschedule-cron-event, purge-all-caches, the seven Jev passes (jev-pass-now, jev-collision-check, jev-lane-map, jev-fit-now, jev-tells-check, jev-tells-pass, jev-tags-now), rights-evidence-now, forms-spam-apply and archive-push-existing. describe-tags is returns-only and sits here because it bills an AI call; the Jev passes are idempotent and sit here because each one spends a request per note.
The remote door is a third, separate surface: the sn-remote-mcp Worker (a private repository) behind Cloudflare Access, reaching the origin only through the bearer-checked signal-noise/v1/bridge route and only for the signal-noise/remote-* twins (inc/abilities-remote-*.php; tests/mcp-remote-guard.php pins the list). Each twin shares its admin ability's output schema byte for byte (two named strips, deploy runtime and the crosstab reduced to agent_networks, run a wrapper), and the door is off until the wp-admin toggle is on. The payload contract is versioned (SN_REMOTE_CONTRACT_VERSION; the current number and its hash are pinned in tests/remote-contract-shapes.php) with one distinct hash per version over the twins' output schemas; the Worker declares the same number and the deploy probe reports contract_match, observed and never refused.
The Abilities REST route is a write surface too. Core registers POST /wp-json/wp-abilities/v1/abilities/<slug>/run for every show_in_rest ability — the write-door slugs and the pre-consolidation apply abilities included. Since v13.110.0 an application-password request there meets the same four controls as the write door (sn_mcp_rw_guard_run_route, inc/mcp/mcp-rw-guard.php); cookie-authenticated wp-admin buttons use the same route and pass untouched. A Cloudflare WAF rule also refuses Authorization-bearing requests to /wp-abilities/ at the edge, and the Cloudflare security headers health check probes for it.
Before composing block markup, read sn-site-facts{editorial_conventions}. It returns the theme's convention registry: every house form (patterns, block styles, className conventions on core blocks, dynamic blocks, markup idioms) with an exemplar to paste, when to use it, placement, whether its text is anchor-reachable, and whether the corpus has ever chosen it. Hand-rolled markup that ignores a convention is still valid markup and passes every other gate, which is how a correction notice once shipped as a plain <em> paragraph; sn-validate's editorial_convention check now warns (never blocks) on markup that matches a convention's shape but not its form, naming the id, and sn-scan{editorial_conventions} finds the same drift across the corpus with block_path and, for a pure class fix, an sn-apply block_replace hint whose dry run reads ledger_impact: coalesces. The theme owns the data; without it the check says so and the scan refuses, neither reports a clean corpus.
Everything reachable without a credential, so nothing is public by accident. The plugin's REST population and its public routes are pinned by tests/rest-routes.php; a new __return_true has to be argued onto that list.
| Surface | Who serves it | Why it is public |
|---|---|---|
POST /wp-json/signal-noise/v1/webmention | plugin | W3C receiver; can only ever create an unverified row. 10 claims per IP per hour, 20 per source host per day, 2,000 unverified at most; each limit answers its own named 429 |
GET /wp-json/signal-noise/v1/live | plugin | the Live strip on /stats: aggregate human counts only (readers in the last 5 minutes, views today, the last hour in 5-minute slots) and the top five published, indexable pages being read now, read from a transient; edge-cacheable for 30 s. Sources, the surge verdict and per-class counts never appear here: they ride live/admin, which needs view_stats (or manage_options) and is never cached |
GET /wp-json/sn-prov/v1/credential/{uid} | plugin | a verifiable credential exists to be verified by anyone |
POST /wp-json/signal-noise/v1/bridge | plugin | bearer-checked in its handler; not registered unless armed; hidden from the index |
GET /wp-json/ route index, /wp/v2/posts (metadata only) | plugin-hardened core | discovery stays; anonymous callers get no rendered content, no users, no comments, no /batch/v1 |
/_sn/version, /_sn/status, /_sn/verify | sn-provenance Worker | build identity, anchoring status, the public verifier's API |
/_sn/rights-signals/{version,taxonomy,crawler-list-status} | sn-rights-signals Worker | build identity and the machine-reader taxonomy (see docs/MACHINE-READERS.md); machine-readers on the same prefix is bearer-gated |
/_sn/login-guard/status, /_sn/remote-mcp/status | their Workers | presence booleans and contract version — never secrets |
/_sn/px | sn-analytics Worker | the beacon; token-gated, but the token ships in every page, so it is a bot filter, not authentication — rate-limited per IP |
The [sn_colophon] page and the Maturity hub plus its seven system pages render as spec sheets: one band per group, the heading on the left and the items two across, stacking at 900px. Each takes the 1320px page track. The sheets load in the head only on pages that carry their shortcodes (inc/colophon-front.php, inc/maturity-layout.php), with a render-time fallback for a shortcode the head check could not see. Links in the colophon's inverted band are underlined at rest. The theme's live contrast check (contrast.yml) measures every page in its sample, these included, against AA.
GitHub-poll updater wired into WordPress's native update system
The plugin coordinates with the theme through WordPress hooks rather than shared code:
| Hook | Direction | Purpose |
|---|---|---|
sn_purge_all_caches_result | Plugin → Theme | Trigger the theme's cache purge, return a count |
sn_clear_template_overrides_result | Plugin → Theme | Clear the theme's template overrides, return a count |
sn_gh_latest_theme_tag_result / sn_gh_latest_theme_tag_error_result | Plugin → Theme | Read the theme's latest GitHub tag (and why a lookup failed) for the update panel |
sn_discography_entries | Plugin → Theme | Supply the synced Muso.AI + Spotify discography; theme renders the [sn_discography] grid |
sn_music_featured | Plugin → Theme | Supply the featured Spotify embed config for the /music hero ([sn_music_featured]) |
sn_websub_hub | Plugin ↔ Theme | Shared hub value — the theme advertises it in feeds, the plugin pings it on publish |
identity.availability (setting) | Plugin → Theme | Availability string the theme surfaces via [sn_availability] on /contact + /services |
sn_prov_render_chip / sn_prov_render_panel | Plugin → Theme | The plugin renders the byline chip and the record panel; the theme places them in the byline and closing parts ([sn_prov_chip], [sn_prov_panel]). sn_note_provenance is the plugin-side view-model filter behind both |
Tested up to; juanlentino.com runs 7.1 in production) · PHP 8.3+ (the plugin header's Requires PHP; production runs 8.4, CI pins 8.3)Distributed via GitHub tags. Install/update through wp-admin → Dashboard → Updates → Update plugin, powered by the plugin's self-updater (inc/wp-update-integration.php).
CHANGELOG.md carries ## [Unreleased] and the current release
only; everything older is in docs/changelog/. A pull request
does not bump Version and does not tag — it closes an issue and adds a bullet
under Unreleased. Cutting a release is a separate, deliberate act:
tools/cut-release.sh release "headline" (add --dry-run to see what it
would touch). Numbers are by date, YYYY.M.N with N the cut's place in its
month, since 2026-10-11: one site installs this plugin, so the number only has
to go up and say how old an install is. See docs/VERSIONING.md.
After the cut merges, gh workflow run release.yml tags the commit that set the
version; the self-updater reads tags, so an update appears in wp-admin only once
that tag exists.
Built for juanlentino.com.
/.well-known/tdmrep.json, /license.xml, /robots.txt, /tdm-policy, /ns/tdm, /webmcp/bridge.js | sn-rights-signals Worker | the rights surface itself |