Skip to content

ReachWeaver 0.3.0 release notes

Released on 2026-10-16.

The first release since 0.2.0: 149 commits, 13 new commands (82 in all, none removed), and the first live receipts. A minor version, not a patch, because it adds commands and changes two defaults (below). The section lists are the full record; the highlights:

  • First live receipts. One approved text post each was sent through ReachWeaver to Bluesky and Mastodon on 2026-10-11 and re-observed from the platforms’ public APIs on 2026-10-15 (docs/receipts/). Telegram and LinkedIn have none; every other adapter stays disabled until its own supervised receipt.
  • New commands. calendar-gaps, link-health, delivery-options, manual-export-batch, engagement-queue, seo-content-check, profile-audit and post-audit (profile CSV, cross-platform consistency), suggest, weekly-digest, peer-benchmark, disconnect and reconnect.
  • Safety, owner-reviewed. Account hold after an unresolved send, approval expiry (opt-in), account disconnect with a terminal-only reconnect, rights on attached images bound under approval/media-2, a documented API cost note in show and the dry run, branch protection and a hermetic test for the ignore rules.
  • Research and docs. 92 distribution channels with dated sources and a platform-matrix table, a regional API feasibility matrix, the Mastodon guide, owner-run pilot runbooks, and the browser-assist decision (not planned).

Upgrade notes (behaviour you may notice).

  1. Account hold is on by default. While any post of an account is needs_review, or sending for over 15 minutes, no other post is claimed for that account (account_unresolved_send_hold) until you check the native account and run reconcile or cancel on that post. There is no bypass flag.
  2. Images that carry a rights record are approved under approval/media-2. Images without one, and every existing approval, keep approval/media-1 unchanged. Approval expiry (approval-mode --max-age-hours) is off until you set it.
  • Rights on attached images: license, attribution, provenance and alt translations (issue #48, owner-reviewed and merged 2026-10-15, PR #73). An image attached with media-attach can now carry an optional rights record (--rights-confirmed --license ID --created-by owner|tool|third_party, --attribution, --tool, --rights-source-url, repeatable --alt-translation LANG=TEXT), validated with the same rules as the media-check manifest (SPDX or owner/licensed, third-party media needs a real license and attribution, CC-BY* needs attribution, tool-made media names the tool and prints a disclosure note, translations validated like alt text). It is stored in media_json, shown by show and the confirmation display, and bound by the approval hash under the new tag approval/media-2: changing the license, attribution, provenance or a translation after approval invalidates it. An image without a rights record keeps approval/media-1 byte for byte, so every existing approval still verifies. Rights text is never published and nothing is fetched. Tests: tests/test_media_rights.py (18) and the tag pins in tests/test_binding_tags.py. Changes what the approval binds for new images; owner-reviewed as a security change and merged 2026-10-15 (PR #73).
  • disconnect and reconnect (issue #38, owner-reviewed and merged 2026-10-15, PR #72). disconnect --account ID is a tighten-only local action: it stores one account_disconnected:<id> settings row (any stored value counts) and an audit event; the send claim then refuses the account (account_disconnected, post stays approved, nothing written or budgeted), publish --live skips it before any credential check or request, the dry run reports it, and connect-check shows local_state. It prints the credential variable names the account reads (never values) and generic revocation steps, and revokes nothing itself. reconnect --account ID is human-only: it needs an interactive terminal on stdin and stdout and a fresh 32-byte challenge typed on /dev/tty (the ritual that turns off human confirmation); a failed ritual changes nothing. Worker logs gain the skip reason account_disconnected. Tests: tests/test_account_disconnect.py (16; seven fail without the enforcement). Touches the send claim; owner-reviewed as a security change and merged 2026-10-15 (PR #72).
  • Account hold after an unresolved send (issue #41, owner-reviewed and merged 2026-10-15, PR #71). While any post of an account is needs_review, or sending for over 15 minutes, no other post is claimed for that account: publish --live prints SKIPPED ...: account_unresolved_send_hold, before the identity preflight and again inside the claim lock; the post stays approved/scheduled, no budget is used, nothing is written, and the dry run reports it. The hold is derived from the posts table (no new table or setting), applies per account, and is lifted only by reconcile on the post that causes it; there is no bypass flag. A live sending post newer than the reconcile threshold does not hold. Worker logs gain the skip reason unresolved_send_hold. Tests: tests/test_account_hold.py (12; eight fail without the hold); existing fixtures that seeded several unresolved sends on one account now use the test-only tests/hold_support.py. Touches the send claim; owner-reviewed as a security change and merged 2026-10-15 (PR #71).
  • Tag releases split into a read-only verify job and an owner-only publish job (private #50, #51). .github/workflows/release.yml now defaults to contents: read; verify (checkout with fetch-depth: 0 and persist-credentials: false) runs the new scripts/release_ref_guard.py, the secret scan, stdlib lint, compileall, runtime-dependency guard, Ruff and mypy with the same pins and flags as ci.yml, sync_skill.py --check, the tag/version check and release_gate.py --tag with tests; publish needs verify, runs only when the pushing and triggering actors are the repository owner, is the only job with contents: write, tests skill.zip and its checksum, re-runs the guard and calls gh release create --verify-tag. The stdlib guard reads GITHUB_* variables and local git rev-parse only (no token, no network) and fails closed unless the run is a tag push of exactly vMAJOR.MINOR.PATCH by the owner (login and account id) and the checkout, tag, GITHUB_SHA and origin/main are one commit. Tests: tests/test_release_ref_guard.py (16, against real temporary local git repositories) and tests/test_release_workflow.py (16, was 7). Docs: “Maintainer release checks” in docs/RELEASE-PLAYBOOK.md, docs/SECURITY-CHECKLIST.md. Branch protection is a GitHub setting this cannot turn on, and a green run is not legal clearance or a social-platform receipt. No tag or release was made.
  • Opt-in approval expiry (issue #39, owner-reviewed and merged 2026-10-15, PR #70). approval-mode --max-age-hours N sets a time-to-live on approvals, off by default. When set, a post approved more than N hours ago is approval_expired_reapproval_required in the send claim, schedule, calendar-plan --apply, health and show (which prints approval_expires_at): it stays approved or scheduled, nothing is sent, no budget is used, and one audit event is written; re-approving starts a new window. The check runs last so tampering keeps its own reason code; schedule refuses a due time after the approval would lapse; the setting is tighten-only (only a lower value is accepted by a command) and an unreadable stored value fails closed. Tests: tests/test_approval_expiry.py (13). Touches the approval check; owner-reviewed as a security change and merged 2026-10-15 (PR #70). No default behaviour changes.
  • link-health no longer prints query values of stored links (private #15, #26). A post body or source_url link such as https://example.org/welcome?token=...&email=... was echoed verbatim in the destination, the utm_issue message and, online, the final URL and redirect chain. Every printed URL now goes through attribution.report_landing_page (origin, path and utm_* only, then privacy.redact); such a link is marked "redacted": true. Grouping and the --online fetch still use the full destination. Found by the extended leak sweep; pinned by tests/test_link_health.py::ReportSafetyTests (3 tests).
  • Send-state bookkeeping atomicity (owner-approved 2026-10-11 as a security-reviewed change). finish_send/uncertain_send return a flag and write a state_conflict event (remote id / error code) when the post is no longer sending; publish prints REVIEW and logs state_conflict instead of PUBLISHED. reconcile is atomic and status-guarded, refuses a sending post claimed under 15 minutes ago unless the new --force is given (an existing test now passes force=True for a freshly claimed post). transition is atomic and its updates are status-guarded. publish retries a failed bookkeeping write briefly, then prints the remote id to stderr and counts the post as needing review without aborting the run or resending. set_paused and its event commit together. Tests: tests/test_send_state_atomicity.py.
  • The onboarding audit prints a manual-export command the CLI accepts for Facebook Pages. The distribution row facebook_pages produced --platform facebook_pages, which manual-export rejects; it now prints --platform facebook. Pinned by tests/test_onboarding.py::ManualExportCommandIds.
  • profile-audit applies X’s 160-character bio limit again, now sourced (2026-10-16). The official X Help page says “Bio (maximum 160 characters)”. It answers curl with a bot challenge, so it was read in a real browser and recorded with the URL, date and quote in profile_audit_registry.json. A 161-character X bio now gets a length_review note. LinkedIn, which states no number, is still left unknown.
  • README channel count and one release-guard case (2026-10-16, salvaged from superseded branches). README said the planner catalogs 90 channels; the registry has 92 since LINE Official Account and Kakao Talk Channel were added. tests/test_readme_channel_count.py now ties the README number to the registry. tests/test_release_ref_guard.py::test_a_resolver_returning_a_non_sha_fails_closed, ported from the draft release-controls tests, covers the guard path where a ref resolves to something other than a commit SHA.
  • The channel registry’s live status no longer denies the receipts. reachweaver_live_status in reachweaver/distribution_registry.json still said no live publishing was verified as of 2026-10-08. It now says one approved text post each was sent through ReachWeaver to Bluesky and Mastodon on 2026-10-11 (first-party receipts in docs/receipts/), that the Telegram/LinkedIn-member text adapters are mock-tested only with no live send, and that no other platform has a live receipt. No capability or delivery field changed. tests/test_live_receipts.py::LiveReceiptTests::test_the_distribution_registry_live_status_does_not_deny_the_receipts keeps it in line with the scorecard.
  • The skill’s publishing reference names the right license (private #30). It told agents not to copy AGPL internals into “MIT code”; ReachWeaver is Apache-2.0, so it now says Apache-2.0 in both bundled skill copies (synced), and the matching eval prompt says “Apache-2.0 repository”. A test in tests/test_postiz.py pins the wording.
  • Review hardening (offline, no safety-control change). Campaign and policy validators no longer crash with TypeError on list/dict JSON values for objective, evidence kind/verification/source_visibility, variant account_id or platform; they report normal validation errors, and main() maps any remaining TypeError/KeyError/AttributeError to ERROR: invalid input structure (exit 2, no input echoed). campaign-preview renders (missing) for evidence without a URL. campaign-export validates before creating --out, creates it with mode 0700 and removes it if the export fails. growth.classify_result returns inconclusive when the pooled lift sign disagrees with the per-pair direction; experiment-evaluate now uses the same minimum-pairs / sign-test gate (no directional_positive for tiny samples; new directional_negative verdict; new readout_label, readout_explanations and windowed_snapshots_skipped keys, all existing keys unchanged). Snapshots with window_start/window_end are windowed counts and are excluded from matched-age comparisons in experiment-evaluate, baseline-eval and timing-analysis, with a warning/count. Engagement matching collapses all whitespace so newline-split injection directives are flagged suspicious. Content-Length/analytics counts accept ASCII digits only ("²" is a validation error, not a crash). Postiz _call converts http.client.HTTPException, InvalidURL and ValueError into fixed PublishError codes without echoing headers. LinkedIn drafts whose escaped commentary exceeds 3,000 characters are refused by validate_post (a heavy use of * or _ could previously pass the raw 2,800 cap and be rejected by LinkedIn after the send claim). Tests: tests/test_review_hardening.py.
  • Operational checks run for real, not only offline (2026-10-16). The example Prometheus alert rules in docs/WORKER-OPERATIONS.md passed promtool check rules and loaded into a running Prometheus 3.15.0 against real ops-metrics output from a synthetic database. All 9 rules were healthy, and the pause alert went pending. tests/test_alert_rules_doc.py now fails if a rule names a metric that ops-metrics does not emit. The LoadCredential= credential path ran once under a real systemd 255 user unit: the credentials directory was used and the environment variable was ignored. Docs: WORKER-OPERATIONS, ARCHITECTURE, ACCOUNT-ONBOARDING.

  • Salvaged from draft PR #65 (regional routing), without its browser-handoff parts (offline, no safety-control change). Kept and adapted to current main: (1) delivery-options [--platform ID] [--json] (reachweaver/delivery_options.py), an offline route per distribution-registry channel built on onboarding.blocker_audit, with an official_api_adapter route for the four mock-tested text adapters and the exact manual-export command for every manual_only channel; the PR’s “browser-assisted manual review” route and per-platform browser start URLs were dropped. (2) manual-export-batch --file BATCH.json --out NEW_DIR [--json] (reachweaver/manual_batch.py, example examples/manual-batch.example.json, bundled as a skill asset): 1 to 25 items preflighted as a whole through the existing manual-export builders, nothing written on any blocking error, human-only destinations refused, an advisory at time that is never scheduled, and a batch-manifest.json with the SHA-256 of every exact-text file, re-checked against the written bytes. (3) The PR’s Trojan Source text check now runs in every manual package: bidi override/embedding/isolate characters, invisible format characters and DEL block manual-export (single and batch), as policy.validate_post already does for live posts. (4) One new registry entry, pixnet (PIXNET, Taiwan blog; manual_only, scheduling unverified, checked 2026-10-14 in the PR), appended to the Taiwan adoption ranking; the registry now has 93 channels. Not ported: browser-handoff, browser-batch, BROWSER-TASK.md and docs/BROWSER-HANDOFF.md (they instructed an agent to fill a composer in the owner’s browser, which the 2026-10-16 decision rules out), the PR’s registry and market rewrite and docs/REGIONAL-PLATFORMS-2026-10-14.md (superseded by main’s regional catalog, feasibility matrix and market profiles), its duplicate kakao_channel, line_official and zalo_oa ids, and its checked_on validator change (already on main). Docs: docs/MANUAL-EXPORT.md#batch-of-manual-packages, README, architecture, troubleshooting table, regional catalog and the skill’s publishing reference. Tests: tests/test_delivery_options.py, tests/test_manual_batch.py, tests/test_distribution.py.

  • Browser-assist is declined and the decision is recorded (public issue #1, docs only). The owner decided on 2026-10-16 not to build it: docs/BROWSER-ASSIST-DECISION.md records the official terms read on 2026-10-15 (LinkedIn and TikTok prohibit automated post creation; Meta, X and Product Hunt could not be read from the primary source; DEV is silent), why a human pressing Post does not cure that, the compliant routes (official API with a human login, manual-export), and what evidence would reopen the question. tests/test_browser_assist_guard.py stays as the guard. No code change.

  • Threads acceptance evidence for issue #49 (offline, mocked; adapter still disabled). New tests in tests/test_threads.py close the gaps against the issue’s criteria on a temporary flipped registry copy: test_contract_rejected_permission_blocks_before_claim_and_keeps_post_approved (a 403 on /me or the quota read is BLOCKED before the claim, the post stays approved, no cooldown), test_contract_expired_token_blocks_before_claim_and_keeps_post_approved (a past authorization_expires_at is refused before the token is read or any request; a 401 from Meta blocks before the claim), test_approved_preview_matches_the_receipt_and_is_published_exactly_once (the dry-run preview shows the approved text, exactly that text is sent, the receipt is stored with text_match=true, and later publish --live runs make no request) and test_reconciled_receipt_cannot_be_applied_twice; the HTTP error tables now also cover 403 on /me, 400 on the quota read and 403 after the container write. Each new test fails when its behaviour is removed. docs/THREADS.md gains a criterion-to-test table and a section on media, alt_text (1,000 characters), public media URLs, carousels and audience options the adapter never sends, quoted from the official publishing reference and posts pages fetched on 2026-10-15; docs/MANUAL-EXPORT.md no longer lists the #19 mock contract tests and reconciliation as open. No adapter, registry entry, policy.SUPPORTED_LIVE, approval, dry-run, pause, credential or reconciliation rule changed; no Meta app, tester, token or live post exists.

  • Instagram issue #50 acceptance evidence (public #50, roadmap #20; tests and docs only, adapter still registry-disabled). Six new offline behaviour tests in tests/test_instagram.py close the gaps against the acceptance criteria: a post is sent only through its own account’s token and ig_user_id when two Instagram accounts are configured; a wrong or consumer (PERSONAL) account is blocked before the claim at the CLI and the post stays approved; a pre-claim 429 records the provider cooldown and keeps the post approved; a published post is never resent by a targeted or queue-wide publish --live; the identity 403 and quota 400 missing-permission hints are pinned; an emergency pause between a FINISHED container and media_publish stops before publishing. The existing never-ready test now also asserts exactly four one-minute sleeps. Three IssueFiftyEvidenceTests pin the setup guide’s prerequisite, scope, Login-comparison and eligibility sections, tie the Instagram row of docs/PLATFORM-MATRIX.md to the registry’s rate_limits.accessed date (2026-10-15), and fail if a test cited in the new criterion-to-evidence table in docs/INSTAGRAM.md disappears. docs/INSTAGRAM-SETUP.md gains a section on personal or unsupported accounts (the manual-export fallback, no login automation); the matrix row now lists the adapter’s verified image scope and replaces “Meta app review” with the Standard versus Advanced Access rule (official pages re-read with curl on 2026-10-15). No change to approval, dry-run, emergency stop, credential isolation, reconciliation, the registries or policy.SUPPORTED_LIVE.

  • Facebook Page adapter: doctor names the eligible Page and owner-declared permissions; personal-timeline and group targets pinned by tests (public issue #51, roadmap #21; offline, adapter still disabled). For a Facebook account with valid page_id and page_name, doctor now prints facebook_eligible_page (the one configured Page, by name and id, “from config and not checked offline”). A new optional account field, declared_permissions (1 to 16 permission names), records what the owner says was granted: doctor reports it as facebook_declared_permissions (owner-declared, not verified) against the three permissions the Page feed reference lists, as WARNING when one is missing. It is never sent, never allows or blocks a send, is not part of the approval binding, and connect-check still reports write_permission: unverified. Group permissions (publish_to_groups, groups_access_member_info) are refused in it. New tests: ConfigAndCredentialTests.test_declared_permissions_are_owner_declared_names_only, DoctorPageAndScopeReportTests (2), FlippedLibraryTests.test_only_write_is_post_to_the_configured_page_feed, test_page_id_cannot_address_a_profile_or_group_endpoint and test_declared_permissions_never_shape_a_request_or_count_as_verified, FacebookDocsTests (3) and an extended CLI doctor check, all in tests/test_facebook.py (mocked HTTP only). Docs: docs/FACEBOOK.md, docs/ACCOUNT-ONBOARDING.md, docs/PLATFORM-MATRIX.md (Facebook row and official references) and docs/ARCHITECTURE.md now cite the official Page feed and User feed references and the v19.0 Groups removal, re-checked with curl on 2026-10-15. docs/FACEBOOK.md also documents a residual: a User token with page_id set to that person’s own id passes the two identity reads, and the write then fails at Meta and goes to needs_review (no documented permission-free Page-vs-person read was found). No registry, SUPPORTED_LIVE, approval, dry-run, emergency-stop, credential-isolation or reconciliation change; no Page connected, no live request.

  • Postiz backend: issue #56 acceptance gaps closed offline (roadmap #30; no live call, no safety-control change). show POST_ID for a Postiz-backed account now prints the backend’s own offline preview as platform_preview (path: postiz, the POST /posts URL, type: now, shortLink: false, the configured integration id, network: none, plus the receipt and manual-fallback rules). Before this change it showed the native Bluesky record key and service, which such an account never sends. Native previews are unchanged. New mocked tests (tests/test_postiz.py::PostizIssue56Tests) cover: a pause during the read-only preflight, refused at the claim with no POST /posts; backend QUEUE/ERROR/unknown/missing job states, reported read-only and never reconciled; a lookup refused for a post without a postiz: job id; reconcile storing the observed native id and permalink; each account sending only its own key, and never borrowing another account’s; and a Postiz outage leaving the post approved for a manual fallback (show, post natively, cancel). docs/POSTIZ.md gains a manual-fallback section, a ready owner smoke-test procedure marked NOT RUN (pinned by PostizIssue56DocTests until a postiz* receipt exists), a licensing and credential review dated 2026-10-15 with sources (postiz-app still AGPL-3.0, latest release v2.25.1; a clause-by-clause review of the Postiz Cloud terms is still open), and an acceptance-evidence table. README, the platform matrix, the architecture doc and the skill publishing reference (synced bundle and plugin copies) are updated. Approval, dry-run, emergency stop, credential isolation and reconciliation semantics are unchanged, and the backend stays opt-in per account.

  • Official regional API feasibility matrix (public issue #68, first rule only; docs and tests, offline). docs/REGIONAL-API-FEASIBILITY-2026-10-15.md records, from the providers’ own developer pages read on 2026-10-15/16, the jurisdiction, account type, posting surface, scopes, docs, owner eligibility, rate/cost, content types, scheduling, data retention and confirmation action for Douyin, LINE Official Account, WeChat Official Accounts, WeChat Channels, Bilibili, Weibo, Xiaohongshu and Kuaishou. Verdicts: WeChat Official Accounts CONDITIONAL (owner-gated; the publish APIs are limited to verified accounts), LINE NO-GO as a feed publisher (it is messaging to opted-in friends), all others NO-GO with the native human handoff. Douyin’s create_video scope is video.create.bind in a test-stage Capability Lab, and its publishing norms bar marketing content; Bilibili and Kuaishou developer pages are JavaScript-only and stay not verified; nothing about Toutiao is inferred from Douyin. A separate section keeps platform-native scheduling apart from API-delivered and locally queued ReachWeaver posts, with no reach guarantee. The issue’s later rules (first authorized provider with mocked contract tests, legal/IP review, supervised receipt) are not done and need an owner-authorized developer account. No adapter, registry delivery or scheduling value, live-send set or account changed; Hacker News stays human-only and Reddit moderator-only. Linked from docs/REGIONAL-CHANNELS-2026-10-14.md and docs/PLATFORM-MATRIX.md. Tests: tests/test_regional_api_feasibility.py (16).

  • show prints a LinkedIn platform_preview (private #14). Like the Bluesky and Mastodon previews it is offline and reads account config only, never the token: account_id, handle (if set), member_urn from person_id (null when the account config does not load), the visibility the payload sends (new linkedin.VISIBILITY, the same PUBLIC value as before), counted_length against the 2,800 code-point text_limit and escaped_commentary_length against the 3,000-character escaped_commentary_limit (both the existing policy constants), within_limits, and a dry-run note. No send, approval or limit change. Tests: three in tests/test_linkedin.py::CliLiveTests (test_show_prints_an_offline_linkedin_preview_without_reading_the_token, test_preview_flags_escaped_commentary_over_the_limit, test_show_preview_without_account_config_has_no_member_urn). Docs: docs/LINKEDIN.md section 4 and the skill’s references/publishing.md.

  • engagement-fetch --quotes: public Bluesky quote posts as an intake source (private #17). With --quotes (and --online), each published Bluesky post whose thread fetch confirmed it is yours is also read with app.bsky.feed.getQuotes on the public AppView: unauthenticated GET through the same redirect-free helper, limit min(100, --max-quotes), cursor pagination capped at 5 pages and --max-quotes (1..500, default 100). Quotes become kind: "quote_post" items (in_reply_to null, quoted_url your post), go through the same redaction and untrusted-text triage as replies, and are kept only when the record embeds your post; deleted, blocked, detached and foreign quotes are skipped with a note. Fetched replies now carry kind: "reply". A getQuotes error fails that post’s quotes only; a 429 stops the run. Off by default; no send path, reply adapter or registry changed. Sources (lexicon, AppView handler, rate-limits guide, developer guidelines) checked 2026-10-15 and listed in docs/ENGAGEMENT.md; no live request was made. Tests: 9 new in tests/test_engagement_fetch.py (mock HTTP).

  • Generic manual package for every other manual_only destination (private #44, item C2; offline only). manual-export --platform now also accepts any other manual_only id in distribution_registry.json (DEV, X, Product Hunt, Reddit, Hacker News and the rest; facebook_pages maps to facebook). New reachweaver/manual_package.py builds a package with the approved text (post.txt, optional title.txt), the media manifest validated by media.validate_media_manifest (alt text, captions, rights record), the registry label, the registry’s official page only when it has one, and a checklist that ends with “keep the native post URL” (no command records a hand-made post). Limits come only from the registries and media.py tables; the text limit always reads limit unknown — check the platform. human_only destinations get no agent-written text: a variant text or title blocks the package, and it writes a NOTES.md form (at most 10 short talking_points) that says “Write this yourself.” Status stays manual_export_not_published; no delivery mode, browser automation, network access or registry change. tests/test_manual_package.py covers every generic destination, human-only refusal, unknown limits, media with alt text and rights, and golden files (tests/fixtures/manual_export_golden/) that keep the five dedicated packages byte-identical. See docs/MANUAL-EXPORT.md.

  • Sourced profile field limits in profile-audit (private #45 W3). The profile-audit registry gains profile_field_limits: 16 limits on 9 of 25 networks (Instagram bio; Facebook Page about (Graph API); X display name and handle (API data dictionary); YouTube channel description (Data API) and handle; Bluesky display name and description (lexicon, graphemes); Mastodon display name, bio and handle (server defaults); DEV display name, bio and handle (Forem source); Pinterest bio; GitHub personal bio), each with official URL, checked date 2026-10-15 and a verbatim quote confirmed with curl | grep. The loader refuses an entry without a https:// URL, ISO date or a quote containing the number, and keeps profile_bio_limit in step. Length alerts now cover display name, headline, about and handle, name the field, length, limit, unit and source, and appear in the text summary; each report carries field_limits with "unknown" for every unsourced field. X’s 160-character bio is no longer applied: help.x.com returns a bot challenge to curl, so it could not be re-verified. Tests: tests/test_profile_audit.py (5 new or changed). Docs: “Sourced profile field limits” in docs/PROFILE-AND-POST-AUDITS.md.

  • Publisher safety tests: Mastodon auth failures and cross-server handles, LinkedIn dry run, Meta reconcile (private #13, #14, #19; tests and docs only). Four synthetic Mastodon fixtures written from docs.joinmastodon.org (checked 2026-10-15): verify_credentials 401/403 and status-create 401/403 (the 403 on the status create is an assumption: that page documents only 401 and 422). tests/test_mastodon_auth_fixtures.py (6 tests) shows a 401/403 at the identity preflight blocks the post before the claim with one request, no retry, no cooldown and no provider text in the output, and on the status create gives needs_review (ambiguous), never published, with no resend from a new process; and that a handle on another server is refused by connect-check --online and the live preflight before any request. docs/MASTODON.md is corrected: the account file still loads with such a handle; the refusal happens at the identity check. tests/test_linkedin.py::CliLiveTests::test_dry_run_sends_nothing_and_leaves_the_post_unpublished: publish without --live sends no request, claims nothing and leaves the post approved even with the live gate open. tests/reconcile_support.py drives a Threads, Instagram or Facebook Page post (flipped test registry only) from needs_review through reconcile --published and --not-published, after which neither publish --live --post-id nor a sweep makes a request. No production code, registry or safety-control change; all three Meta adapters stay disabled.

  • YouTube duplicate avoidance and the Studio fallback are pinned (private #22, #23; tests and docs only). tests/test_youtube.py::DuplicateAvoidanceTests shows, against the temporary flipped registry with a mocked provider, that a re-run after a successful upload sends nothing, that a run started mid-upload sends nothing, and that two runs racing for one approved post upload it once (the loser’s claim_sending returns false after its read-only channel check). StudioFallbackDocTests keeps the “Manual fallback: upload in YouTube Studio” section of docs/YOUTUBE.md. The acceptance table in docs/MANUAL-EXPORT.md now names the tests that meet the #22 fixtures and #23 mock-test items, and a test checks every cited test exists; Google/TikTok audits and live receipts stay open. YouTube stays disabled in the registry.

  • Publishing-backend contract and Postiz fixtures (private #30; no send, approval or reconciliation change). publishers.PublishingBackend (a typing.Protocol) declares preview, create, receipt lookup and the reconciliation hand-off; publishers.NATIVE_PATH and postiz.POSTIZ_BACKEND delegate to the existing functions, publishers.backend_for names the path an account selects, and dispatch is unchanged. tests/test_postiz_contract.py runs the same conformance checks on both paths. Six synthetic fixtures in tests/fixtures/providers/postiz/ back PostizFixtureTests: a create returning two jobs (needs_review, one POST /posts), two racing runs (one claim, one create), a scope-shaped 403 on create (needs_review, body and key never shown, no retry) and a disabled integration (BLOCKED before the claim). No Postiz instance has been connected.

  • Browser-assist guard tests (private #44; tests only). tests/test_browser_assist_guard.py pins today’s state until the owner decides #44: no browser_assist mode in either registry or runtime module, both registry validators reject one, the skill and docs/FIRST-LIVE-PILOT.md keep their no-browser/cookie-automation sentences, and Reddit, Hacker News, Discord, WhatsApp, Facebook Groups and Indie Hackers (and every other human_only entry) stay manual_only.

  • Analytics, leak-sweep, inbox and brief coverage is pinned end to end (private #9, #15, #16, #17, #26; tests and docs only). tests/test_analytics_two_providers.py (4 tests) fetches Bluesky and Mastodon counters through mocked HTTP, adds a mockstats post and renders report as JSON, Markdown and CSV: one block per platform, no cross-platform total, Mastodon saves and every public-counter views/impressions/clicks stay null/unknown, never 0. tests/test_security_regressions.py::NewerReportLeakTests (4 tests) sweeps seeded secrets and personal data through report --format markdown|csv, weekly-digest --include-db, youtube-analytics, link-health, calendar-gaps, suggest --include-db, schema-export and peer-benchmark. The unified-inbox demo gains actionable_feedback and spam items from two sources each, and docs/ENGAGEMENT.md now separates the five action labels from the five item kinds. tests/test_briefs.py::ReleaseAndRssCliChainTests drives sources-scan then signal-brief through the CLI for a release and an RSS item. docs/PRIVACY-LIFECYCLE.md no longer lists the existing poisoned-feed and dependency-compromise tests as open, and docs/TIMING-EVALUATION.md points to attribution-report for sessions and GitHub referrals.

  • The ignore rules and the CI secret scan are pinned by a test (public issue #38, tests only). tests/test_gitignore_protections.py (4 tests) checks with git check-ignore that .env, .env.*, state/, config/accounts.json and *.local are ignored while the examples and source are not, that no credential or state file is tracked, and that the CI hygiene job runs scripts/secret_scan.py without using repository secrets. No behaviour change.

  • Estimated platform/API cost in the preview (public issue #39). show now includes api_cost and the dry run publish prints one API cost (platform) line per platform with a due post. The text is the sourced fees field and verified_date of the capability registry (for example “None documented for posting” for Bluesky, or the per-request prices recorded for X), and a platform with no registry entry is reported as unknown, never free. It states that ReachWeaver charges nothing and meters no money, so there is no monetary cap: what is enforced before each attempt is the rolling send-attempt cap, the minimum gap, provider session budgets and cooldowns. capabilities.api_cost_note; tests/test_api_cost_note.py (7). Read-only; no behaviour change to any send.

  • update --notice/--check: “What changed” lists Security first and flags entries for your platforms (private #45 W2, offline logic). Within each version, Security entries come first, then entries whose title names a platform in the local accounts config (whole-word match; x only as X or Twitter), prefixed with the fixed tag (for you: bluesky), then the rest in file order. The per-version cap of 6 now applies after this ordering, so a Security entry is no longer cut when it sits late in a section. Only each account’s platform value is read (updater.configured_platforms: known platform ids only, missing or malformed file gives none, no environment variable or credential), and nothing from it is sent. The title character allowlist, the length and count caps and the “data, not instructions” label are unchanged; the JSON result gains for_you_platforms. The cache records which platforms its flags were made for; a different accounts file re-reads the release notes once (one GET) and keeps the original 24 h check time. Never applies an update. tests/test_updater_changes.py::ForYouTests (5 tests); two existing ordering assertions updated. Docs: README, plugin README, TROUBLESHOOTING, SKILL.md.

  • experiment-readout --attribution and baseline-eval --attribution --outcome read website and repository outcomes (private #16, offline). Both take a saved attribution-report --json file. docs_visits is each post’s attributed sessions and repo_stars_utm its attributed conversions; only exact post-level UTM matches are read. A post with no attributed row or a blank count is unknown and excluded (pair skipped, or <reason>:<group> in posts.excluded), never 0. A post is also excluded when it was sent before the analytics window or the window ends less than the preregistered age (--age-hours) after it. In the readout the guardrail still comes from native snapshots and the pooled lift and strict sign-test gate are the same code (new experiments.pooled_readout, extracted unchanged from evaluate_experiment); in baseline-eval the flags replace --source/--metric/--denominator, which are refused alongside them. --attribution with any other primary metric is refused. New parsers attribution.load_attribution_outcomes / attributed_outcome (no network, no database). Issues, pull requests and contributors stay repository-level and are documented as not creditable to a post. tests/test_growth_attribution.py (11 tests). Docs: docs/GROWTH-EXPERIMENTS.md, docs/TIMING-EVALUATION.md (also replaces the stale “no qualified session integration yet” limitation), README, skill references.

  • calendar-plan --explore-share FRACTION --explore-slot Wed@11:00 reserves a capped share of a batch for exploration (private #16, offline). With a share above 0 and at most 0.5 (calendar.EXPLORE_SHARE_CAP), exactly floor(share x posts) posts, evenly interleaved by post order, go to the operator’s explore slots; the rest stay on the usual exploit slots (generic prior, --slot or --audience-activity). Every entry gets role; explore entries are evidence: operator_explore_hypothesis, confidence: unverified; an exploration block gives counts, both slot lists, randomized: false and the matching baseline-eval --slot values. No explore time is invented, an explore slot equal to an exploit slot is refused, and spacing, blackouts, DST checks and --apply gating are unchanged. With the default 0 the output is byte-for-byte unchanged (golden test). tests/test_calendar.py::ExplorationShareTests (6 tests) and a CLI test. Docs: docs/CALENDAR-PLANNER.md section 2c, docs/TIMING-EVALUATION.md, README, skill.

  • timing-evaluate --stratify format|campaign and a cold-start pointer (private #16, offline). --stratify format (text, image or video, from whether the post row has a stored image or video; nothing is decoded) or --stratify campaign adds a strata list with the same matched-age daypart readout per stratum. A stratum with fewer than two slots at --min-posts-per-slot is insufficient_data with candidate: null and a cold_start block that points to calendar-plan’s generic, low-confidence baseline slots (none_available for platforms without one) instead of naming a winner; the pooled readout carries the same block when it has too few comparable slots. Without the flag the pooled fields are unchanged (only the additive cold_start key). Read-only: no network, no queue change. tests/test_timing_analysis.py::StratifiedTimingTests (7 tests) and the CLI test. Docs: docs/TIMING-EVALUATION.md, README, skill references.

  • peer-benchmark: compare your public counters with peers’ public counters you copied by hand (public issue #11, offline). The owner records, in a local JSON file (examples/peer-benchmark.example.json), the counters shown on each account’s public page (followers, following, posts, likes, reposts, replies, views), the page URL and the date seen, and confirms public_data_only. The command reports, per platform and never pooled across platforms, the owner’s value against the peers’ minimum, median and maximum and how many peers it is at or above, only with at least three peers; fewer gives insufficient_peers; an absent counter is unknown, not zero; warnings flag observations more than 30 days apart or over 90 days old. It has no fetch path (the module imports no HTTP code; a test checks the import list), reads no login or private analytics, stores nothing, refuses credential-like fields and values, query-string or credentialed URLs and unknown keys without echoing the file, and excludes individual people (kind: person) unless --include-individuals is passed. docs/PEER-BENCHMARK.md states the limits (counters differ between platforms, can be edited or deleted, a position among a few chosen peers is not a ranking; not legal advice). tests/test_peer_benchmark.py (11 tests). Fetching other accounts’ pages is deliberately not implemented: that is a separate platform-terms and privacy decision.

  • profile-audit reads a profile CSV and compares profiles across platforms (public issue #3, offline). The issue asks for a JSON or CSV owner snapshot and a name/handle/bio consistency check across platforms; only JSON and per-profile advisories existed. --file now accepts a long-format .csv (platform,account_id,handle,profile_kind,keywords,field,value, optional constant brand_name and preferred_handle; see examples/profile-snapshot.example.csv): blank means unknown, never false; later rows may repeat but never contradict a profile’s handle, kind or keywords; errors name a row number and never echo it; the result goes through the same validation as JSON (size limit, credential-like names and values refused). Every result also carries an advisory cross_platform_consistency block (and a text summary): per profile kind, whether display names, handle stems and website hosts agree and whether any two bios, headlines or about texts share a significant word; unknown fields are listed under not_compared, and nothing changes a score. tests/test_profile_snapshot_csv.py (11 tests); docs/PROFILE-AND-POST-AUDITS.md and the skill reference updated. Provider-API/OAuth intake and real-user experiments mentioned in earlier comments are separate, permission-gated work and are not part of this offline command. No network, no behaviour change for JSON input.

  • The Bluesky lifecycle is pinned end to end through the CLI (public issue #42, offline). tests/test_bluesky_lifecycle.py drives one post through draft, show (offline preview: facets, grapheme limit, handle), an unapproved live attempt (no request), approve, the default dry run (no request, still approved), a mocked live send (identity session, publish session, createRecord: published with the stable record key, the at:// URI and a bsky.app link), a second live attempt (no request) and report (one published post; every metric nobody imported stays unknown, not zero; no credential in the output). No behaviour change.

  • The first two live receipts are recorded: one Bluesky and one Mastodon text post (owner request, 2026-10-15). On 2026-10-11 the owner approved and published one text post each through ReachWeaver; the working install’s event log shows approve, send_claimed and published for both, with the remote ids stored. On 2026-10-15 both posts were re-read from the platforms’ own public APIs without credentials (Bluesky app.bsky.feed.getPosts: the record text is byte-equal to the approved content; Mastodon /api/v1/statuses/<id>: the text begins with it; both show zero engagement). docs/receipts/bluesky-2026-10-11.json and docs/receipts/mastodon-2026-10-11.json hold the first-party receipts; docs/release-readiness.json moves Bluesky and Mastodon to live_receipt_verified (2 live receipts, other 22 areas unchanged); the README, platform matrix (Owner live receipt column), docs/PLATFORMS.md, the pilot, onboarding and roadmap statuses and the Mastodon guide now say so, and Telegram and LinkedIn still have none. The receipts prove two posts exist; they do not prove reach or growth, and do not enable any disabled adapter (Mastodon images and replies, Threads, Instagram, Facebook, YouTube, TikTok stay registry-disabled). tests/test_live_receipts.py (4 tests) pins the receipts, the scorecard and the docs; test_platform_matrix_docs.py now requires the matrix cell to agree with the scorecard receipt; test_onboarding.py expects exactly two receipts. No change to approval, dry-run, pause, credential isolation or reconciliation.

  • India’s app-blocking notices are checked against the India entries (public issue #64, docs and tests only). docs/REGIONAL-CHANNELS-2026-10-14.md gains “India: blocking orders checked”: the two Ministry of Electronics and IT notices that name blocked apps (29 June 2020, 59 apps; 2 September 2020, 118 apps; section 69A of the IT Act) were read on 2026-10-15; TikTok is named in both, and none of the eleven India registry entries (ShareChat, Moj, Josh, Chingari, Hipi, Public App, Kutumb, Arattai Channels, LocalCircles, Pratilipi, Rooter) appears by name. The section lists what was not reviewed (later orders, app-store availability today, state and sectoral rules) and says it is not legal advice. tests/test_distribution.py::IndiaBlockingReviewTests pins the section, that every India entry stays manual_only and unverified, and that no India plan lists TikTok. No behaviour change.

  • LINE Official Account and Kakao Talk Channel are registered (public issues #25 and #26; registry, docs and tests only). Those issues ask about official-account and channel posting, but only the OpenChat group products had entries. line_official_account and kakao_talk_channel (both manual_only, unverified scheduling, human_only no because they reach only people who added the owner’s own account) record what the official pages say, read on 2026-10-15: the LINE Messaging API sends reply, push, multicast and broadcast messages from an Official Account with a plan-dependent free allowance, and the Kakao Talk Channel API needs a Biz app connected to a reviewed Business Channel. docs/COMMUNITY-AND-CREATOR-PLATFORMS-2026-10-15.md lists the short quotes and what stays unverified (scheduling, limits, pricing, promotion rules; Kakao Story was not researched). The platform matrix has two more rows (28 in all); tests/test_distribution.py counts 92 surfaces. No adapter, no behaviour change.

  • Eight thinly sourced registry entries are re-read from the platforms’ own pages (public issues #13, #14, #23 to #26, #31; docs, registry text and tests only). Medium, Substack, VK, Naver Blog, Naver Cafe, LINE OpenChat, KakaoTalk Open Chat and Snapchat Spotlight were registered with only a landing page as source. Read directly on 2026-10-15: the Medium API repository (archived 2023-03-02, “no longer supported”), the Substack Terms of Use (no crawling or scraping, manual or automated), the VK Terms of Service (6.3.6 mass mailing, 6.3.9 automated scripts, 5.13.4.1 outside advertising in Communities), the Naver Open API list (a login-method Cafe API posts to a cafe board; no blog-writing API), the LINE Messaging API overview (Official Accounts; OpenChat not mentioned) and the Kakao Developers menu (no Open Chat API). Each entry’s scope, research_source and checked_on now say so, and a new section of docs/COMMUNITY-AND-CREATOR-PLATFORMS-2026-10-15.md lists the quotes (25 words or fewer) and, separately, everything that stayed unverified (403 or JavaScript-only pages). Every entry stays manual_only with unverified scheduling; no behaviour change. tests/test_platform_research_batch.py::RereadEntryTests (4 tests) pins entries and doc together; test_distribution.py allows the new check date for these eight.

  • The platform matrix lists every researched surface (public issues #13 to #37, docs and tests only). docs/PLATFORM-MATRIX.md gains a “Research-only registry surfaces” table with one row for each of the 26 surfaces of those issues (Medium, Substack, Lobsters, Lemmy, GitHub Discussions, Hugging Face, Quora, Stack Exchange, Tumblr, Xing, VK, Naver Blog and Cafe, KakaoTalk Open Chat, LINE OpenChat, Beehiiv, Ghost, Vimeo, Twitch, Snapchat, Flipboard, ResearchGate, Behance, Dribbble, Viber, Slack communities): category, manual_only delivery, human-only flag, the platform’s own scheduling mode, check date, source link and what was not verified. The stale “36 planning surfaces” sentence now carries the real registry count. tests/test_distribution.py::PlatformMatrixResearchTableTests (3 tests, rows derived by tests/matrix_rows.py) requires each row to equal its registry entry, every entry to stay manual_only, a surface with no readable source to stay unverified and human-only, and the count to match. No registry or behaviour change.

  • Community, creator and publishing platforms researched and registered (public issues #15-#22, #27-#30, #32-#37, offline). Eighteen new manual_only entries in reachweaver/distribution_registry.json, each dated 2026-10-15 with a research_source and, only where an official page documents it, a scheduling claim with its source: Lobsters, Lemmy, Stack Overflow / Stack Exchange, GitHub Discussions, Hugging Face, Quora, Tumblr, Xing, Beehiiv, Ghost, Flipboard, Vimeo, Twitch, Behance, Dribbble, ResearchGate, Viber and Slack communities. human_only is set where the surface’s own rules make tool-drafted promotion inappropriate; Stack Exchange is sourced from its Help Center promotion page and API v2.3 documentation, and Xing from its developer site (plugins only, no posting API listed); Xing’s member terms are JavaScript-only and stay unverified; both are unverified for scheduling and human_only. docs/COMMUNITY-AND-CREATOR-PLATFORMS-2026-10-15.md records, per platform, posting methods, API, scheduling, limits, automation and self-promotion rules with short quotes and links, an AI-content rule where one exists, and what is not verified. The key quotes and numbers were spot-checked against the live pages by direct HTTP, which removed one wrong agent-reported claim and one figure. tests/test_platform_research_batch.py (12 tests) pins the entries and the doc. No adapter, no behaviour change; Hacker News and Reddit rules untouched. Stacked on the regional-channels work: it needs that change’s checked_on rule.

  • Mastodon guide, pinned to the code (public issue #43, docs and tests only). New docs/MASTODON.md: account fields and their limits, the two-request server capability probe (connect-check --online: token identity, then the public /api/v2/instance limits), supported formats, what the approver sees in show, how each failure ends (needs_review classes, the 429 cooldown, the one-hour idempotency window) and local testing steps. It says where the account options (visibility, language, spoiler_text) come from at send time and tells the approver to re-run show after any account-file change. tests/test_mastodon_docs.py (7 tests) checks every number, command, error code, cited test and fixture against the source, that the links resolve, that the documented HTTP classes match classify_send_failure, and that the doc’s statement of where the account options come from matches the code (so the doc and the code cannot drift apart silently). No behaviour change.

  • A project has one sensitivity in the sources config (public issue #40). validate_config now refuses two sources of the same project that declare different sensitivity values: a project is identified by the optional project label (any kind, case-insensitive), the GitHub repo, or the repository behind a markdown changelog’s raw URL; the error names both entries and nothing is scanned. project is validated for every kind, not only changelog. The example and fixture configs stay valid. tests/test_sources_project_sensitivity.py (14 tests; six fail if the check is removed) also pins that scan uncertainties reach the brief’s campaign and sidecar and that an edited scan file cannot smuggle text in as an uncertainty. Docs: docs/SOURCES.md states the rule and that only pull requests are filtered as trivial. No change to approval, dry-run, pause, credential isolation or reconciliation.

  • The pilot report carries its metric definitions, and the release workflow runs the full release gate (public issue #55). pilot-report now includes a metric_definitions block in every report (refused comparisons too): unit, source of truth and definition for each of the nine measures as in docs/FIELD-PILOT-PROTOCOL.md section 4, the numerator and denominator of each rate, and what a blank cell means; the text report points to it. tests/test_pilot_definitions.py (13 tests) checks the definitions cover exactly the logged measures and match the protocol table, that sample size and coverage are reported, and that no key or text claims a lift, significance or guarantee. .github/workflows/release.yml now runs python scripts/release_gate.py --tag "$GITHUB_REF_NAME" before the bundle is built or published, in place of the bare test step: the gate refuses a release with a missing or undated changelog section, broken docs links, a stale skill bundle, a blocked platform without an alternative, an invalid readiness scorecard or a failing test suite, and runs the tests itself (--skip-tests can never exit 0 and is not used). Checked locally with release_gate.py --tag v0.2.0 (exit 0, 2093 tests) and, before the bundle sync, exit 1 on a stale bundle. tests/test_release_workflow.py (7 tests) pins the wiring: semver tag trigger only, gate before build and publish, no --skip-tests, no secrets, platform names or live opt-ins, write permission for the release only. No behaviour change to publishing.

  • Backup, recovery and incident procedures are complete and pinned (public issue #54, docs and tests only). docs/PRIVACY-LIFECYCLE.md: the backup now includes approval-ledger.jsonl next to the database, taken and restored together (the checklist already required it; the backup steps did not say so), and Recovery names the two fail-closed outcomes (approval_ledger_unavailable, and approval_replay_reapproval_required for a database older than its ledger) and warns never to create an empty ledger without checking the account for duplicates. A new “Incident response (operator procedure)” gives roles (the operator, or a named incident owner and deputy) and a timed order of steps: pause, revoke, preserve evidence, find what went out, reconcile, assess exposure with qualified advice (no compliance claim), recover and resume, record. tests/test_ops_docs.py (11 tests) requires every command the procedure names to be a real subcommand, every quoted error code to exist in the source, the ledger to be part of the backup, the steps to stay in order with pause first and resume last, and the relative links to resolve. No behaviour change.

  • Analytics linkage and the weekly-report decisions are pinned to their data (public issue #45, offline). tests/test_analytics_linkage.py (6 tests) asserts each exported AnalyticsSnapshot carries the account, platform, remote post id and source of its storage rows, points at the same PublishJob and PublishReceipt as its post, exports the window in UTC (an unknown window is left out), leaves out a non-slug campaign instead of rewriting it, never exports a snapshot whose post has no remote id, and exports an unimported metric as null, never 0. tests/test_weekly_report_prose.py (7 tests) requires every figure in the hand-written decisions of docs/WEEKLY-REPORT-EXAMPLE.md to come from the generated block or the fixtures and the derived ones to equal what report and attribution-report compute. No behaviour change. Docs: docs/SCHEMAS.md, docs/WEEKLY-REPORT-EXAMPLE.md.

  • Reply proposals carry per-platform constraints, pinned (public issue #47, offline). tests/test_engagement_constraints.py (6 tests) asserts each draft records its own platform’s conservative text limit (Bluesky 300, Mastodon 450, X 260, LinkedIn 2800) and that the limits really differ, carries the sanitized excerpt and its evidence (matched rules and docs link) without quoting the comment, says how to post it on that platform and that nothing is sent, that human-authored-only platforms get no draft, and that a platform without a comment send path degrades to manual-review instructions. docs/ENGAGEMENT.md now states that the five inbox categories are the five item kinds and how actions relate to them. No behaviour change.

  • Growth recommendations are proven to stay under the approval and budget policy (public issue #46, offline). tests/test_growth_policy.py (8 tests): an AST guard shows the advice modules (growth, growth_readout, baseline_eval, timing_analysis, experiments, audience) import no publisher, adapter or network module, import only read helpers from storage, and name no claim/approve/schedule/send function; next_hypotheses and experiment-next carry requires_human_approval and open no database; and two experiment arms driven through the CLI behind a network refusal stay drafts until approved, are a dry run by default, are refused without the live opt-in, are held to one send per account in a sweep by the minimum gap, stop on pause, and are blocked when edited after approval. No behaviour change. Docs: docs/GROWTH-EXPERIMENTS.md.

  • TikTok setup, audit and scope/rate-limit tests (public issue #53, offline). docs/TIKTOK.md gains “Setup, review and audit”: the official steps (register the app, add the Content Posting API product, get video.publish approved, the creator authorizes the scope, the audit that lifts the private-only restriction), who does each, and an explicit no-bypass statement, with the official page and the date it was read; what is not stated (audit duration and criteria, caps for unaudited clients) is listed as not verified. tests/test_tiktok_epic.py (new, 15 tests including three that pin the setup section) pins existing behaviour: a 429 at user_info or creator_info retries a bounded number of times, records a cooldown and keeps the post approved with nothing uploaded; a 429 at init after the claim is needs_review, certain-not-sent, with a cooldown and no resend; 401 at creator_info or init names the missing video.publish or upload scope; an API scope_not_authorized body blocks without an upload; the exact privacy level is shown by video-attach and show for each choice, a Direct Post has no default level, and a bound level cannot change without an unapprove. No behaviour change.

  • YouTube publish output states the visibility (public issue #52, offline). After PUBLISHED <id>: <watch url>, publish prints a NOTE line from the approved privacy: for private and unlisted it says the upload succeeded but the video is NOT public and how to change that in YouTube Studio; for public it says so as approved. The line changes no state, cannot fail the send (a malformed stored record prints no note) and uses the new youtube.visibility_note. tests/test_youtube_visibility.py also feeds a real adapter receipt through youtube-analytics and checks the receipt fields agree and every metric stays unknown, never zero. Docs: docs/YOUTUBE.md.

  • One source to per-platform variants and a media cut list is documented and pinned (public issue #8, offline). The release playbook gains step 2b: the signal-brief scaffold gives one placeholder variant per approved account and media-storyboard --campaign turns the same campaign file into a timed scene list, SRT skeleton, image specs and manifest. tests/test_repurposing_chain.py runs the chain end to end on the offline fixtures behind a network guard and asserts that no copy or media is generated, that the scaffold stays blocked until a human writes it, and that existing targets are never overwritten. No behaviour change.

  • Engagement queue uses owner-supplied reach and sentiment (public issue #5, offline). The export contract gains two optional fields, reach (integer) and sentiment (negative, neutral, positive); an absent value is unknown, never zero or neutral, and neither is inferred from the text. engagement-queue orders overdue first, then label priority, then within the same label negative sentiment, higher reach (absent sorts last) and age. They never change a label, a safety route (harassment, suspicious, opt-out) or whether a draft exists; triage items and queue entries carry the values and the text view shows them. Tests: tests/test_engagement_reach_sentiment.py; docs: docs/ENGAGEMENT.md.

  • seo-content-check checks name spelling and, opt-in, link previews (public issue #4). --name NAME (repeatable) or a top-level names list in the input file flags other spellings of your project name (Reachweaver, Reach Weaver, REACHWEAVER; URLs, hashtags and handles ignored) as name_variant warnings. --online [--allow-origin O] also checks each item’s link once per distinct page through the guarded landing.fetch_landing (robots.txt first, inspected redirects, at most 25 pages, no image requests): preview_http_error is an error, missing og:title/og:description/og:image and redirects are warnings. The default run is unchanged and still offline; the report’s policy.network_access now says which. Tests: tests/test_seo_content_names_preview.py; docs: docs/SEO-INTEGRATION.md.

  • Reply drafts are claims-checked (public issue #5, offline). engagement-triage runs every reply draft, built-in or from an owner template, through the lexical claims check against an optional evidence list in the docs map (campaign-evidence shape, at most 50 entries, https url). Each draft gains claims_check (clean, review or error, with findings) and the report a claims_check summary; render_markdown shows the status. Unsupported figures and release language are errors, quotes, superlatives and feature claims need review; the built-in templates state no facts and are always clean. Annotation only: nothing is blocked, edited or sent, and every draft stays draft_requires_approval. Tests: tests/test_engagement_claims.py; docs: docs/ENGAGEMENT.md.

  • Release notes in the update notice (public issue #7). When a newer release exists, update --notice and update --check also read CHANGELOG.md at that release tag (read-only HTTPS GET, raw.githubusercontent.com, covered by the existing GitHub-only host guard) and print a “What changed” list for the versions after the installed one up to the latest. The first notice line is unchanged and the list is omitted when the changelog cannot be read. The titles are untrusted text that reaches an agent, so they use a narrow character allowlist (no markup, links or backticks), at most 5 versions, 6 items per version and 100 characters per title, are labelled as data, and a cached list is re-validated on read. update --check --json gains changes. update --apply is still human-confirmed and nothing else changed. Tests: tests/test_updater_changes.py.

  • calendar-gaps (public issue #9, offline, read-only). Lists empty and overloaded slots per account: each candidate slot (from --slot, --audience-activity or the generic baseline) is filled, empty or past against the locally scheduled posts; days over --max-per-day and posts closer than --min-spacing-hours are flagged; optional --timing-source/--timing-metric/--timing-denominator annotate empty slots with the account’s own timing-evaluate result (association only). Runs on a mode=ro connection (a missing database is an error and is not created), no network, no write, and it cannot approve, schedule or publish. Posts in a platform’s native scheduler are invisible to it. Tests: tests/test_calendar_gaps.py; docs: docs/CALENDAR-PLANNER.md.

  • link-health (public issue #10, offline by default, read-only). Reports broken, redirected, robots-blocked or preview-less links in the posts stored locally: links from content and source_url of approved, scheduled and published posts (--status, --account, --campaign) are grouped by UTM-stripped landing page. Offline it checks https, credentials and UTM tags; with --online it reads robots.txt first and sends one GET per distinct page (at most --max-links, default 25, max 50) through landing.fetch_landing (inspected redirects, https and same-domain rule unless --allow-origin, size caps, no image requests) and flags missing og:title/og:description/og:image. Only broken and unusable links are errors (exit 1). Runs on a mode=ro connection (a missing database is an error and is not created), writes nothing, and cannot approve, schedule or publish; page text is untrusted data. Tests: tests/test_link_health.py; docs: docs/SEO-INTEGRATION.md.

  • media-check knows the GitHub social preview and the DEV cover. New github and devto image targets with sourced rows: GitHub PNG/JPEG/GIF, under 1 MB, at least 640 × 320 (new min_height rule), 1280 × 640 recommended; DEV only the recommended 1000 × 420 (file type and size are not stated, so they stay unknown). Rows now carry their own verification date (older rows keep 2026-10-08) and the report adds constraints_verified_dates; constraints_verified_date is unchanged. Offline, no network, no upload. Tests: tests/test_media_github_devto.py; docs: docs/MEDIA-ASSETS.md.

  • weekly-digest (private issue #45 W7, offline). Writes the ranked suggest actions, and with --include-db the local report totals, to one new owner-only (0600) Markdown file. Same input flags and loading code as suggest. An existing file or symlink at --out is refused; no network, no database write, no send, e-mail or schedule path, and post text is not copied in. Tests: tests/test_digest.py; docs: docs/NEXT-ACTIONS.md.

  • seo-content-check (private issue #45 W4, offline). Discoverability checks on owner-supplied post texts: conservative length, Instagram’s documented caption rules, missing alt text, repeated or heavy hashtags, UTM link rules, absent topic phrases and wording repeated across platforms. Every finding is labelled official, repo_policy or hypothesis; none claims a ranking effect. Read-only: no network, no database, no publishing. Exit 2 on error findings. Tests: tests/test_seo_content.py; docs: docs/SEO-INTEGRATION.md.

  • Engagement aging/SLA queue (engagement-queue, private issue #45 W5, offline). Re-orders the existing triage into the items that still need a human, with age buckets and owner-adjustable overdue targets (--sla LABEL=HOURS); closed/no-reply items are counted, not queued. Read-only: no network, no database, no send path. Tests: tests/test_engagement_queue.py; docs: docs/ENGAGEMENT.md.

  • Instagram pilot runbook and setup preflight (private issue #43, offline only). docs/INSTAGRAM-PILOT.md (one-post supervised pilot; the live steps are marked blocked until the reviewed enablement). doctor now prints a SETUP: note naming any of handle, ig_user_id or authorization_expires_at missing from an Instagram account (config field names only, no credential read, no network). docs/INSTAGRAM.md records the 2026-10-12 re-check of the unverified Meta items. No registry, SUPPORTED_LIVE or send-gate change.

  • Skill install first-use fixes. scripts/sync_skill.py now also bundles config/accounts.example.json and examples/example-post.txt into the skill’s assets/, and gains --check (lists out-of-date bundle files, exits 1, writes nothing). A missing accounts file in a skill-only install now names the bundled assets/accounts.example.json. SKILL.md defines the RW command prefix and a short First run (RW doctor, copy the example accounts, never share secrets in chat) and fixes a duplicate (b) label in the output contract; README and QUICKSTART use python3, make doctor the first skill command and map examples//config/ to the skill’s assets/. Tests in tests/test_skill_contract.py. No approval, dry-run, pause, credential or reconciliation behaviour changed.

  • Claude Code plugin and marketplace. .claude-plugin/marketplace.json (plugin source is the lean ./plugin folder) and plugin/ (.claude-plugin/plugin.json, .claude-plugin/icon.png, hooks/hooks.json with a SessionStart hook that only runs update --notice, a README with a data-handling disclosure, LICENSE, and skills/reachweaver, a generated byte-identical copy of the skill bundle kept in sync by scripts/sync_skill.py). The plugin is a subfolder so an install copies only the manifest, hook, README and skill bundle, not the tests and docs. Install with /plugin marketplace add MSKazemi/reachweaver then /plugin install reachweaver@reachweaver. updater.install_kind() reports plugin; for a plugin the notice names the /plugin update steps and update --apply refuses to touch the plugin cache. Passes claude plugin validate . (one advisory about the root CLAUDE.md). Tests in tests/test_updater.py pin manifest versions to __version__. Not yet exercised through a real /plugin install from GitHub.

  • Skill install and update (scripts/install_skill.py, reachweaver version, reachweaver update). Stdlib installer for ~/.claude/skills or --project, from the latest GitHub Release (SHA-256 verified) or --from-checkout. update --check, a cached once-a-day --notice that SKILL.md runs at session start, and --apply --yes (needs the user’s agreement; atomic swap, previous copy kept outside the skills directory). New reachweaver/updater.py (github.com HTTPS only, no redirects off GitHub, zip-slip and symlink refusal, strictly-newer check) and .github/workflows/release.yml (on a vX.Y.Z tag: version and bundle-sync check, tests, skill.zip + skill.zip.sha256). tests/test_updater.py: 13 offline tests. No approval, dry-run, pause, credential or reconciliation code changed; no release has been tagged.

  • Offline mock media-upload adapter (issue #18 slice). New reachweaver/media_upload.py: MockMediaUploadAdapter(platform).preflight() validates a media manifest against one platform’s sourced constraints and returns stable error codes (FILE_TOO_LARGE, ALT_TEXT_MISSING, CAPTIONS_MISSING, …) with an actionable fix per finding. Simulation only: no network, credential, database write or upload; accepted is not consent to publish. Provenance/license are not persisted (deferred protected item). Tests in tests/test_media_upload.py; documented in docs/MEDIA-ASSETS.md.

  • YouTube analytics-sampling report (issue #22 follow-up; offline only). New read-only youtube-analytics [--account ID] [--post-id ID] and reachweaver/youtube_analytics.py. It reads stored metric snapshots of published YouTube videos and labels every metric exact, sampled (platform estimate), suppressed, delayed, not_available or unknown, next to the video receipt fields (video id, watch URL, channel, privacy, upload status from the published audit event; null when absent). Unknown is never zero, estimates are never summed with exact counts, and snapshots without an observation window are counted apart. Opened with connect_readonly: no network, no credential, no write, no migration. tests/test_youtube_analytics.py: 9 offline tests, socket-guarded. No approval, dry-run, emergency-stop, credential, reconciliation, identity, send-gate or ledger behaviour changed. Nothing was fetched from or exercised against YouTube.

  • reachweaver ops-summary (issue #26 slice). One read-only, offline, redacted report that composes health (adapter capability, budget, cooldowns, pause, dead letters, credential variable names) with ops-metrics totals (claims, publishes, failures, stale sending). queue-health and ops-metrics each covered part of this and health lacked the metrics, so the combined view is new. Options: --json, --since, --stale-minutes, --optout, --now. Exit 1 only when the overall level is blocked. It opens the database with connect_readonly (never created or migrated) and its output is an allow-list (id-shaped account ids, known platforms, fixed codes, counts); reason detail text is dropped. New reachweaver/ops_summary.py; no existing module’s behaviour changed. Docs: WORKER-OPERATIONS.md. Tests: tests/test_ops_summary.py.

  • Postiz scope-detection design note (issue #30, docs and tests only). New docs/POSTIZ-SCOPE-DETECTION.md and a section in docs/POSTIZ.md: what a Postiz API key proves (key accepted, integration listed, provider and handle match, not disabled) and what it cannot (platform write scope, token validity, Page role, delivery), every claim tagged repo, documented or unverified, plus an unchecked live-verification checklist. tests/test_postiz_scope.py: 7 offline tests pinning current behaviour (including that a non-boolean disabled reads as enabled, flagged for the owner). No source change; no approval, dry-run, emergency-stop, credential, reconciliation, identity, send-gate or ledger behaviour touched. Nothing was exercised against Postiz.

  • Approval nonce and replay ledger (wave 9 item 1; owner-approved 2026-10-10 as a security-reviewed change, recommended option). Every approve draws a fresh 128-bit posts.approval_nonce (new nullable column, additive migration) bound into the approval hash under tag approval/2 (added only when present, so nonce-less hashes are byte-identical). New reachweaver/ledger.py: claim_sending appends one consumed line for (post_id, approval_nonce) to approval-ledger.jsonl (mode 0600, O_NOFOLLOW, O_APPEND, flock, fsync, SHA-256 hash chain) next to the database, under BEGIN IMMEDIATE, before the row flips to sending. A restored older approval row, or a published row set back to approved, is refused with approval_replay_reapproval_required (one approval_replay_blocked event, no claim) before the identity preflight and any request; a missing, unreadable, wrongly-permissioned, symlinked or chain-broken ledger refuses with approval_ledger_unavailable. A human unapprove then approve draws a new nonce and works. Not grandfathered: approvals made before the upgrade have no nonce and are refused with approval_nonce_missing_reapproval_required until re-approved once (upgrade note). health reports approval_ledger_broken, replayed rows and rows needing a nonce as attention. Residual risk (THREAT-MODEL T7): the ledger is editable by the same Unix user, and withdrawal is not recorded. Four test fixtures were updated, no assertion loosened (see the proposal’s section 1.9). tests/test_approval_ledger.py: 23 offline tests, socket-guarded. The human-approval, dry-run, emergency-stop, credential-isolation, reconciliation, identity and send-gate rules are unchanged; this only adds refusals. Nothing was exercised against a live account.

  • Verified-identity binding at approve time (wave 9 item 2; owner-approved 2026-10-10 as a security-reviewed change). New opt-in approve POST_ID --verify-identity --online: one read-only check_connection request (the connect-check call class; a Bluesky run reserves one createSession like connect-check), then a SHA-256 fingerprint of the account id, platform and identity fields of the verified account entry is stored in the new nullable column posts.approved_identity and bound into the approval hash under tag approval/id-1 (keys added only when set, so every existing and every unbound approval hash is byte-identical). publish --live recomputes the fingerprint from the current accounts file and refuses a difference with approval_identity_mismatch_reapproval_required before the identity preflight and any request; claim_sending re-checks under its lock (new identity_fingerprint argument), and a sender that passes none (the Postiz and TikTok runners) fails closed for a bound row. New approval-mode --require-identity latches identity-bound approval (tighten-only, no command removes it): plain approve is refused with approval_identity_required, and approvals without a stored identity are refused by publish --live, schedule, health and the dry run with approval_identity_missing_reapproval_required. Existing approvals are not touched by the upgrade; the upgrade path is unapprove, show, approve --verify-identity --online (WORKER-OPERATIONS.md). The failed-check case approves nothing. No token, remote response or handle is stored or printed; the error codes are fixed strings. It binds which account was verified, not who approved (THREAT-MODEL.md T3, T8). New reachweaver/identity.py and tests/test_identity_binding.py (25 offline tests, socket-guarded, mocked identity check); publishers.py is untouched. Approval, dry-run, emergency-stop, credential-isolation and reconciliation rules are unchanged; this only adds refusals. Nothing was exercised against a live account.

  • Send gate inside publishers.publish() (wave 9 item 3, owner-approved 2026-10-10, security-reviewed change). New reachweaver/sendgate.py. storage.claim_sending registers each committed claim in a process-local registry; publishers.publish() and publish_video() now refuse, before any capability, credential or network step and with a fixed send_* code (send_ticket_required, send_ticket_mismatch, send_ticket_consumed, send_ticket_expired, send_caps_required, send_live_gate_closed), any call that was not preceded by a claim of exactly that post, account, content and media, made with caps in the CLI’s range (1..10 per 24 h, gap at least 15 minutes), unused, under 15 minutes old, with REACHWEAVER_ALLOW_LIVE=1 and REACHWEAVER_ENV=prod set. Claims already enforce the emergency pause, approval hash/MAC, provider cooldown and caps, so direct callers meet the CLI’s conditions. connections.check_connection records what it returns, and publish()/publish_video() ignore a hand-built VerifiedConnection and re-run the identity check. Behaviour changes: a direct publish() / publish_video() call without a claim now raises ValidationError (previously it sent); the CLI and Postiz paths are unchanged. No escape hatch exists; 14 existing suites that call publish() as a library now import it from the test-only tests/gate_support.py, which records the same registry entry a claim does. New tests/test_send_gate.py (18 tests). In-process only: it stops accidental and naive bypass, not a same-process attacker (THREAT-MODEL T8). Mock-tested; no live account or post is involved.

  • Provider 429/quota fixtures and fault-injection tests for every send adapter (issue #11 follow-up, tests and docs only). New synthetic 429 fixtures for Mastodon (X-RateLimit-Reset), Telegram (parameters.retry_after), LinkedIn (daily throttle, no header) and Postiz (Retry-After) beside the Bluesky ones, and tests/test_fault_injection.py (5 offline tests). The fixtures are replayed through publish --live and pin the cooldown source and C1/C2/C3 send class from ratelimit.py, needs_review, and that a new process, a --post-id run and a full sweep send nothing. A stateful fake provider proves that after a timeout following a successful remote create, exactly one create request is made per adapter (needs_review, class ambiguous, no cooldown). A process death inside the create call (KeyboardInterrupt) leaves the post in sending, not needs_review: it is never eligible again and queue_health reports it as stale_sending for a human to reconcile. No runtime code changed; the fixtures are hand-written, not recordings, and are not evidence of a connected account.

  • Mastodon media rate-limit and processing fixtures, and an EXIF GPS warning (issue #13 follow-up, D7; offline only). Five synthetic fixtures in tests/fixtures/providers/mastodon/ (202 unprocessed upload, 206 partial poll, 200 ready poll, 429 with and without Retry-After) replayed by new tests in tests/test_mastodon_media.py: the poll stays bounded, a never-ready upload ends in needs_review with no status request, and a 429 records a cooldown and is never resent. media-attach --file now prints a WARNING: when the image carries an EXIF GPS block (media.exif_has_gps, stdlib header parse for JPEG, PNG eXIf and WebP EXIF). The warning is advisory: it does not block, and it changes neither the stored record, the approval hash nor the uploaded bytes. No approval, dry-run, emergency-stop, credential or reconciliation logic changed.

  • Unified-inbox offline demo and more moderation/opt-out tests (issue #17 slice). Export items accept an optional kind (reply, mention, quote_post, comment, direct_message; invalid values refused, missing is unspecified). The triage report gains an inbox summary (per category, per platform, actions per category) and the Markdown sheet a category table. New fixtures tests/fixtures/engagement/unified-inbox.json (five mocked sources, five categories) and unified-inbox-optout.json, and 19 offline tests in tests/test_engagement_inbox.py (opt-out phrases in every category, case/prefix/domain/platform-scoped list matching, no issue draft for opted-out authors, hostile text never drafted, HN/DEV human-only, secret-like fields still refused). kind never changes a label, draft or safety decision; no send path, approval, dry-run, emergency-stop, credential or reconciliation code changed.

  • Instagram and Facebook Page provider-failure fixtures (issues #20 and #21, tests and docs only). 13 synthetic fixtures in tests/fixtures/providers/meta/ and 10 offline tests in tests/test_meta_provider_fixtures.py for an unsupported consumer account, missing permissions, app-review-required, bad media at container creation, asynchronous container processing (IN_PROGRESS, ERROR) and a Page mismatch. They pin that failures before the first write are certain-not-sent, that failures after it are ambiguous and never retried, and that no remote error text or token reaches an error string. Both adapters stay registry-disabled (reachweaver_adapter: none, requires-review); no adapter, registry, approval, dry-run, pause, credential or reconciliation code changed. The body shapes are illustrative, not recorded, and prove nothing about live Meta behaviour.

  • YouTube and TikTok chunk-upload, creator-option and unaudited-private fixtures, plus a YouTube Studio manual fallback (issues #22 and #23 slice, tests and docs only). Six synthetic fixtures in tests/fixtures/providers/youtube/ and twelve in tests/fixtures/providers/tiktok/ (hand-written, not recordings) and 9 new offline tests: ChunkUploadFixtureTests (YouTube 403 forbidden/404 on a middle chunk, 308 without Range, final-chunk 500 and 308, and a private receipt for a public request from an unverified project), ChunkFixtureTests (TikTok 500/416 on chunk 2, 200 on chunk 1, 206 on the final chunk) and CreatorOptionFixtureTests (invalid privacy_level_options and max_video_post_duration_sec, and an unaudited SELF_ONLY-only creator). They pin that every failure after the first byte is needs_review/ambiguous, is never retried, resumed or polled, never leaks the token or session URI, and that an invalid creator response is refused before the claim. New docs/YOUTUBE.md section “Manual fallback: upload in YouTube Studio” and fixture notes in YOUTUBE.md and TIKTOK.md. No adapter, approval, dry-run, pause, token-refresh, credential or reconciliation code changed; no live result is claimed.

  • Threads app setup and Meta review checklist; LinkedIn Page versus Postiz note (issues #19 and #30, documentation only). docs/THREADS.md gains an owner checklist for the Meta app, Threads Tester route, token exchange and App Review, with a verification date (last verified 2026-10-09; not re-fetched 2026-10-10) and explicit [unverified] markers for review turnaround, business verification and account-type eligibility. docs/POSTIZ.md documents that a Postiz linkedin-page integration is accepted although the native adapter refuses organization Pages, and records refusing it as a pending owner decision (it would change tests/test_postiz.py). No code, test, approval, dry-run, emergency-stop, credential or reconciliation change; no live result is claimed.

  • Security review proposal, wave 9 (docs only, nothing approved). New docs/SECURITY-REVIEW-PROPOSAL-WAVE9.md for owner review of four protected items, each with threat, minimal option, rejected options, required tests, risk and a yes/no owner question: approval replay (append-only ledger plus nonce, with migration), approve-time verified-identity binding, a send-ticket gate inside publishers.publish(), and #18 media rights/provenance persistence with its effect on the media hash binding. No code, schema, test or registry change; the approval, dry-run, emergency-stop, credential-isolation and ambiguous-send rules are untouched.

  • Bluesky one-post pilot runbook and CODEOWNERS. New docs/BLUESKY-PILOT.md: a copy-pasteable supervised pilot for a throwaway Bluesky account, with every step tagged SAFE-OFFLINE or OWNER-ONLY, commands checked against reachweaver/cli.py (draft output and POST_ID, unapprove, cancel, pause/resume, reconcile, connect-check, approval-mode), the exit codes (0 sent, 2 blocked, 3 needs review), emergency stop, teardown and receipt recording. Linked from the README, the quickstart and the first-live-pilot guide. New .github/CODEOWNERS names @MSKazemi for the two registries and accounts.py, storage.py, publishers.py and caps.py; THREAT-MODEL.md T6 notes it. Documentation only: no code, approval, dry-run, pause, credential-isolation or reconciliation change, and no live result is claimed.

  • Bluesky provider-error fixtures and tests (issue #12, tests and docs only). Nine synthetic fixtures in tests/fixtures/providers/bluesky/ (createSession 401 ExpiredToken and AuthenticationRequired, createRecord 401 InvalidToken, 429 with and without Retry-After, wrong-account and deactivated-account sessions, createRecord 400 blob-too-large and invalid-embed) and 14 offline tests (BlueskyProviderFixtureTests). They pin that errors are sanitized, that a 401 or 429 on createSession is certain-not-sent while one on createRecord is ambiguous, that a wrong-handle session stops before any write, and that no embed is sent and Bluesky image/video publishing stays refused. No adapter, approval, dry-run, emergency-stop, credential or reconciliation code changed. The fixtures are hand-written, not recorded, and are not evidence of a connected account.

  • Offline end-to-end test and benchmark-harness skeleton (issue #27). New tests/test_e2e_offline.py runs the shipped example files through draft, show, check, approve, dry-run, live-gate refusal and a mock send with every socket refused, and asserts that unapproved or edited posts are never sent. New scripts/pilot_benchmark.py runs that workflow for N synthetic posts against a mocked Bluesky layer and reports local CPU seconds per step, labelled synthetic and not_pilot_evidence. Documented in docs/FIELD-PILOT-PROTOCOL.md section 8a. No pilot has been run and no pilot result is claimed.

  • Revocation and sunset triage in the ops runbook, plus a credential-cause metric (issue #26 slice). docs/WORKER-OPERATIONS.md keeps its revoked-permission and API-version-sunset runbook and adds a per-provider reference table (token lifetimes, pinned or retired API versions, where to look) and an alert rule. reachweaver ops-metrics gains the gauge reachweaver_needs_review_credential_posts{account_id,platform,cause}, counting needs_review posts whose stored error code is HTTP 401/403 (permission) or 426 (version_sunset). It reads local SQLite state only, contacts no provider and reads no credential. docs/PRIVACY-LIFECYCLE.md links the runbook from the disconnect section. Money-based spend caps are not part of this change. Tests: tests/test_ops_metrics.py.

  • LinkedIn missing-scope message tests and API version lifecycle doc alignment (issue #14 follow-up). tests/test_linkedin_scope.py (MissingScopeMessageTests) pins the operator wording for a missing w_member_social (introspection refusal, userinfo 403, post-claim 403, unverified send) and that no scope list or secret is echoed; tests/test_linkedin.py pins the 0/9/10/11/12-month boundaries. docs/LINKEDIN.md now gives future/malformed/too-old versions their own row (linkedin_version_invalid, previously mislabelled linkedin_version_unsupported) and cites the registry constraint. No code or registry change.

  • Source hostile-input fixtures and the exact --sources caveat (issue #9 slice). New fixtures feed-malformed-items.xml, feed-injection.xml and changelog-injection.md plus HostileFixtureTests in tests/test_sources.py pin that malformed items are skipped or flagged, instruction-like text stays inert and confidence-lowering, and unparseable feeds fail only their own source. tests/test_private_sources_integration.py now pins the private-link caveat: without --sources nothing is detected, a config with no private repository refuses nothing, and a missing or malformed --sources file fails closed. No runtime behaviour changed; docs/SOURCES.md states the caveat precisely.

  • Third mocked metrics provider with privacy thresholds and a follower-polling design (issue #15 slice, offline only). New pure metrics_fetch.normalize_mockstats maps an invented provider whose definitions differ from Bluesky/Mastodon (plays_3s to views, link_taps to clicks, bookmarks to saves; unique_viewers and reactions_all deliberately unmapped). Counts below the privacy threshold are stored as suppressed and the small number is dropped; withheld, not-collected and pending markers become suppressed, not_available, delayed; malformed values stay unknown, never 0. Fixtures in tests/fixtures/providers/mockstats/threshold_cases.json. report --format markdown adds a one-line suppressed legend only when a suppressed value exists. New docs/FOLLOWER-POLLING-DESIGN.md (design only, no code, no network, no CLI command).

  • Human-presence confirmation and keyed approval binding (issue #7, D6 and D7; owner-approved 2026-10-09 with every recommended decision). New reachweaver approval-mode [--confirmation tty_hash_prefix|off] [--require-key]. D6 (opt-in, off by default): with tty_hash_prefix set, approve and publish --live (native and Postiz paths) refuse unless stdin and stdout are terminals. The owner must then type the first 8 hex characters of the shown payload sha256 on /dev/tty (never stdin), compared in constant time, with 3 attempts. The live check runs before any account load, then per post before the cooldown, preflight and claim. A failure stops the run with nothing claimed, and turning the check off needs the same ritual. D7: with REACHWEAVER_APPROVAL_SECRET (read via accounts.credential, environment or LoadCredential=, at least 32 characters), each approval also stores an HMAC-SHA256 tag over the post id and payload hash in the new nullable posts.approved_payload_mac. Claim, pre-claim gate, schedule, calendar-plan --apply and health verify it. A forged, copied or unkeyed row is refused with approval_mac_mismatch_reapproval_required or approval_mac_missing_reapproval_required and stays approved until re-approved (D2/D5). Keyed mode is required whenever the verifier holds the key, a tighten-only DB latch is set, or the row carries a tag; without a usable key, approval_key_unavailable refuses every grant and claim. Unkeyed approvals keep working until the key is first provided. Dry run, show and ops-metrics read no credential. Both are friction against an agent, not proof of a human (a pseudo-terminal passes D6; anything that reads the key can sign). Replay of an earlier valid row is not prevented. Proposal and owner decisions: docs/SECURITY-REVIEW-PROPOSAL-7-d6-d7.md. Tests: tests/test_approval_confirmation_keyed.py (42 offline tests; /dev/tty mocked, plus one real local pseudo-terminal). Owner decision 7: .claude/settings.json also denies Claude Code approval-mode ... --confirmation off, any Bash command naming the approval secret and reading a credentials file of that name (harness rule, not a ReachWeaver control). tests/test_cross_feature_gates.py pins the combined gate order: a failed D6 confirmation stops before the #11 Bluesky createSession reservation and the #14 LinkedIn introspection request, and claim_sending enforces the D7 tag and a #11 shared cooldown scope together.

  • Shared Postiz cooldowns and a Bluesky createSession budget (issue #11 follow-up; owner-approved 2026-10-09 with every recommended decision). A new optional accounts.json field, postiz_workspace (a lowercase slug, Postiz-backed accounts only), groups accounts whose keys belong to one Postiz organization. A 429 on one of them then pauses all of them in the dry run, in the pre-claim gate and inside claim_sending. Postiz counts create-post requests per organization: the throttle key is org.id + '_posts' in postiz-app commit 4c5d42a, read 2026-10-09. No per-instance scope is added. A new cooldown_scope_members table, created idempotently on open, records the declared membership at each live run, so a dry run shows shared cooldowns without loading accounts. Bluesky createSession calls (2 per live publish, 1 per connect-check --online) are reserved in a local counter before any request. ReachWeaver refuses with bluesky_create_session_budget_exhausted (worker reason session_budget) at half of the documented 30 per 5 minutes and 300 per day per account (Rate Limits, registry facts). Counter events are kept 48 h by privacy-purge. health shows the shared cooldown and the session usage. Both changes only add waiting. Mock-tested only. See docs/SECURITY-REVIEW-PROPOSAL-11-shared-cooldown.md.

  • Opt-in LinkedIn write-scope check before sending (issue #14; owner-approved 2026-10-09 with every recommended decision). A LinkedIn account with "scope_check": "introspect" is checked with LinkedIn’s read-only token introspection (POST https://www.linkedin.com/oauth/v2/introspectToken, form-encoded client_id, client_secret, token; token introspection, accessed 2026-10-09) inside the identity preflight, before the claim. The send is refused unless the token is active, a 3L member token, issued to the configured app, carries w_member_social, and has an expires_at more than 1 hour away. HTTP errors fail closed and are reported as an identity preflight failure, so the post stays approved and nothing is sent. connect-check then reports write_permission: verified. A missing or more-than-one-day-off authorization_expires_at prints a note with LinkedIn’s expiry; the config file is never edited. Introspection is bounded to 1 request per account per run (3 including retries), and its result is kept in memory only for 15 minutes, because LinkedIn documents no limit for the endpoint (rate limiting, accessed 2026-10-09). publish_member_post re-checks that result offline before the write. New credential names {prefix}_CLIENT_ID and {prefix}_CLIENT_SECRET are added to accounts.CREDENTIAL_SUFFIXES, so the env_prefix overlap rule always covers them. They are required (and listed by doctor, credentials-audit and missing_credentials) only after the opt-in, _CLIENT_SECRET values are redacted by privacy.redact, and the token, secret, scope list and raw response are never printed, logged or stored. Without the opt-in, behaviour is unchanged, and doctor prints INFO: linkedin_write_scope_unverified to explain the residual risk. Mock-tested only (tests/test_linkedin_scope.py); no LinkedIn app or token was used. Decision record: docs/SECURITY-REVIEW-PROPOSAL-14-linkedin-scope.md.

  • Mastodon image attachments (issue #13; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so live image sending stays refused). New media-attach POST_ID --file IMG (--alt TEXT | --alt-file F) [--focus x,y] [--optout F] and media-detach POST_ID change a Mastodon draft’s attachments offline. New reachweaver/attachments.py accepts PNG, JPEG and WebP detected from the file header (not the extension; GIF and video are refused), at most 4 images of at most 16,000,000 bytes, with required alt text of at most 1500 code points (placeholders, control, bidi and invisible characters refused) and an optional focal point in -1.0..1.0. Account fields max_media_attachments, image_size_limit and media_description_limit can only lower these limits. Attachments live in a new nullable posts.media_json column that storage.migrate() adds idempotently. Approval binding (#7): a post with attachments binds the image payload (reachweaver.storage.approval/media-1), which adds each attachment’s SHA-256 of the file bytes, MIME type, alt text and focus, in order. Text-only posts keep the text payload (reachweaver.storage.approval/1), so every existing approval hash stays valid. approve refuses a file that no longer matches its recorded hash; a file changed after approval blocks the send before any claim (approval_media_changed_reapproval_required, post stays approved), and the adapter re-reads each file once and uploads only bytes that still hash to the approved value. Opt-out checks now also cover alt text. Send flow (publishers.publish_mastodon, mock-tested only): POST /api/v2/media with a stdlib multipart body per image, at most 8 polls of GET /api/v1/media/:id while it answers 206 (47 s total, the emergency pause checked before each wait), then POST /api/v1/statuses with media_ids[] and the existing Idempotency-Key; the receipt’s media ids must match. The write marker is set before the first upload, so any later failure goes to needs_review and is never resent. Gate: the capability registry entry may list an optional live_media_formats; the bundled registry lists none, so require_capability("mastodon", "image", "publish") refuses and the path is unreachable until the owner changes the registry after review. Media posts are also refused through the Postiz backend. show lists attachments and whether each file still matches; the dry run lists them and the registry refusal. Sources were read on 2026-10-09 (docs/PLATFORMS.md). Proposal and owner decisions: docs/SECURITY-REVIEW-PROPOSAL-13-mastodon-media.md. Tests: tests/test_mastodon_media.py. data-purge now also clears media_json when it minimizes or redacts post text.

  • Instagram single-image adapter (issue #20; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so unreachable). New reachweaver/instagram.py implements the official Instagram API with Instagram Login container flow on graph.instagram.com/v25.0: POST /{ig-user-id}/media (image_url, caption, alt_text, JSON with Authorization: Bearer), status_code checks once a minute (at most 5; the emergency pause is re-read before each wait and before publishing), POST /{ig-user-id}/media_publish, then a receipt from GET /{media-id} (username and media_type must match; the permalink is kept only in the observed instagram.com/p/... shape). Before the claim it checks GET /me (user_id and username must equal ig_user_id and handle; account_type must be Business or Media_Creator) and the read-only content_publishing_limit quota (documented as both 100 and 50 posts per 24 hours, so the live value decides). Instagram fetches images from a public URL and ReachWeaver hosts nothing, so new reachweaver/remote_media.py and the commands media-attach POST_ID --url HTTPS_URL (--alt T | --alt-file F) --online and media-detach POST_ID attach one owner-hosted JPEG to an Instagram draft: the URL must be HTTPS on a public host (all DNS answers global), port 443, with no credentials, query string or fragment; one read-only GET (no redirects, no credentials, at most 8,000,000 bytes) checks the JPEG header and the 4:5 to 1.91:1 aspect ratio and records the SHA-256. Approval binds it through the shared image payload (reachweaver.storage.approval/media-1: sha256, mime, alt, focus, url per attachment; text-only posts keep the approval/1 hash byte for byte), stored in the posts.media_json column shared with #13 as one attachment model: each record carries source: "local" (Mastodon file, with path) or source: "url" (Instagram, with url), and media-attach takes --file for Mastodon or --url ... --online for Instagram. A live send downloads the URL again before the claim (changed bytes: BLOCKED approval_media_changed_reapproval_required, post stays approved) and again immediately before the container POST (changed bytes: refused before the write marker). Opt-out checks cover alt text, backends refuse media posts, validate_post adds the official caption limits (tighten-only), the token is read only from {env_prefix}_ACCESS_TOKEN (new CREDENTIAL_SUFFIXES["instagram"]), and doctor notes the draft status and token expiry. The capability registry gains an unused live_media adapter value and optional live_media_formats; the Instagram entry and policy.SUPPORTED_LIVE are unchanged, so publish --live, connect-check --online and publish() refuse Instagram before any credential read. tests/test_instagram.py proves that and runs the happy path, image swap, 400/401/429/500, wrong or personal account, quota, pause and post-write needs_review cases against a temporary flipped registry copy. All HTTP and DNS is mocked; no Meta app, account or live post exists. Meta fetches the image after ReachWeaver’s last check, so the bytes Meta receives can still differ (residual risk in the proposal). See docs/INSTAGRAM.md and docs/SECURITY-REVIEW-PROPOSAL-20-instagram.md (sources read 2026-10-09).

  • TikTok Content Posting API adapter (issue #23; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so unreachable). The new reachweaver/tiktok.py implements creator-inbox draft upload as the first mode: POST /v2/post/publish/inbox/video/init/ (FILE_UPLOAD), chunked PUT with Content-Range (206, then 201) and POST /v2/post/publish/status/fetch/ polling. Because the creator finishes and posts the video in the TikTok app, a delivered upload moves to needs_review with reason tiktok_creator_inbox_delivered_not_public and is never recorded as published, so reports and metrics cannot count it. Direct Post (/v2/post/publish/video/init/) sits behind a second registry switch. It needs an explicit, bound privacy level that must appear in the creator’s live creator_info privacy_level_options, so it never defaults to PUBLIC; interactions are off by default. A post becomes published only when TikTok returns a public post id. Identity is checked before the claim: GET /v2/user/info/ open_id and display_name must match the config. mark_write_started() runs after init (which creates no content) and before the first chunk, so every later failure goes to needs_review and is never retried. The new offline commands video-attach/video-detach bind one local MP4 to a TikTok draft (posts.video_json, migrated with ADD COLUMN). Approval then binds the record, including the file’s SHA-256, under reachweaver.storage.approval/video-1; text-only posts keep the /1 hash byte for byte. At integration (2026-10-09) the record became the shared video record of reachweaver/video.py (kind: reachweaver_video, core platform, sha256, bytes, mime and the parsed headers, plus a bound platform_options object, for TikTok {mode, privacy_level, commercial, disable_comment, disable_duet, disable_stitch, is_aigc}); the hash covers the whole record except its local path, a post carries images or one video (never both), the record’s platform must equal the post’s, and a backend or a text adapter refuses a post with a video. The TikTok runner also enforces the #7 D6 confirmation, keyed binding and #11 scopes like the other live paths. The file is re-hashed by show, check, approve and the dry run, and before the claim, and only the matching in-memory bytes are uploaded (256 MiB cap). The video is validated by media.probe_bytes against TikTok’s documented limits and fails closed on any unknown value. The token is read only from {env_prefix}_TIKTOK_ACCESS_TOKEN via accounts.credential, never sent to the upload host and never shown in an error. The registry entry was re-verified, with sourced rate-limit facts; reachweaver_video_inbox and reachweaver_video_direct_post stay off and policy.SUPPORTED_LIVE_VIDEO is empty. tests/test_tiktok.py proves the adapter is unreachable, and runs the mocked contract against a temporary flipped registry copy. All HTTP is mocked; no TikTok app, account or upload exists. See docs/TIKTOK.md and docs/SECURITY-REVIEW-PROPOSAL-23-tiktok.md (sources read 2026-10-09).

  • YouTube video / Shorts upload adapter (issue #22; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so unreachable). New reachweaver/youtube.py implements the official resumable videos.insert flow: a session POST to https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status (X-Upload-Content-Length, X-Upload-Content-Type), the Location session URI (kept on the documented endpoint, never printed, logged or stored), 8 MiB PUT chunks with Content-Range, a 308 Resume Incomplete Range check per chunk, and a 201 receipt whose channel, privacy, made-for-kids and upload status must match. Before the claim, channels.list?part=id&mine=true must return exactly the configured channel_id. New offline youtube-attach / youtube-detach commands bind one local MP4 (validated by media.py; symlinks and QuickTime refused) to a YouTube draft. Approval payload reachweaver.storage.approval/video-1 (the draft’s approval/2, renamed at integration; nullable posts.video_json added by migrate(), shared with #23 as one video record, reachweaver/video.py, with YouTube platform_options {title, tags, category_id, privacy_status, self_declared_made_for_kids}; everything except the local path is bound) binds the file SHA-256, size, MIME, title, tags, category, privacyStatus (default private; the API default is public) and a required human selfDeclaredMadeForKids answer; the description is the post text. Posts without a video keep the approval/1 payload byte for byte. The file is re-hashed at approve, before the claim (changed: BLOCKED, post stays approved) and in the adapter; while it streams, the digest is compared before the final chunk, so a changed file never completes an upload. mark_write_started() sits after the session POST and before the first byte. Every later failure (5xx, network drop, partial Range, pause between chunks, receipt mismatch, including a public request returned private by an unverified project) is _uncertain, goes to needs_review and is never resumed or resent. Opt-out checks now also cover the title and tags. policy.validate_post adds the official 5000-byte, no-</> description rule (tighten-only). The registry gains a reachweaver_video_upload switch (the draft’s live_video adapter value, folded at integration into the shared video switches with #23’s; a live upload still needs video: api_direct and sourced rate-limit facts), which must agree with the new, empty policy.SUPPORTED_LIVE_VIDEO; the bundled YouTube entry only gains that switch set to off, so publish --live, connect-check --online and publish_video() refuse it before any credential read. The token is read only from {env_prefix}_ACCESS_TOKEN (no refresh flow); YouTube accounts without channel_id stay manual-export metadata and expect no credential. tests/test_youtube.py proves the gates and runs the mocked flow (happy path, 308, 503, network drop, quotaExceeded 403, 429 cooldown, wrong channel, file changes, pause) against a temporary flipped registry. Official facts re-read 2026-10-09: videos.insert has its own quota bucket (default 100 calls/day, 1 unit each, since 2026-06-01); the unverified-project private lock remains contradicted between the revision history and the videos.insert page, so it is an enable blocker. No Google project, channel or upload exists. See docs/YOUTUBE.md and docs/SECURITY-REVIEW-PROPOSAL-22-youtube.md.

  • Human-approved engagement replies, gated off (issue #17; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt). A reply is an ordinary post row with a new nullable posts.reply_target column, which storage.migrate() adds. It goes through the same approve (payload hash), publish --live, claim and receipt or needs_review lifecycle as any post; there is no second pipeline.

    • Drafting. draft --account ID --text|--file … --reply-to ITEM [--online] [--override-flagged LABEL]. Without --online it only plans; with it, the new reachweaver/replies.py verifies the parent through unauthenticated, redirect-free GETs: Bluesky getPostThread with parentHeight=20, Mastodon statuses/:id and /context. Refused: parents whose thread root is not a post ReachWeaver published for the account (no cold replies), the owner’s own posts, top-level posts, Bluesky threadgates and root-CID mismatches, and non-public Mastodon parents. Also refused, never overridable: opt-out-listed parent authors and “don’t contact me” requests. Parents that re-triage as suspicious, harassment, spam or moderation-labelled need an exact human --override-flagged label, which is audited as reply_override_flagged.
    • Binding and checks. The approval hash covers the stored target byte for byte: Bluesky root and parent URI and CID, or Mastodon server, in_reply_to_id and visibility. A changed target therefore needs re-approval. Ordinary post hashes are unchanged. approve, check and publish re-check the opt-out list, the account match and the visibility.
    • Sending. Bluesky sends record.reply (root and parent strongRefs, per the app.bsky.feed.post lexicon). Mastodon sends in_reply_to_id with the stricter of the parent’s and the account’s visibility, never wider. A receipt with a mismatched in_reply_to_id goes to needs_review. Replies share the per-account rolling budget.
    • Gate. The registry gains an optional reachweaver_reply_adapter field. capabilities.require_reply_capability refuses every reply claim while it is none, and the bundled registry ships none for both platforms.
    • Counting and docs. report counts replies under a separate replies key, ops-metrics adds reachweaver_reply_posts, and data-purge minimizes targets. At integration (2026-10-09) a reply was made text-only: attaching images or a video to a reply row, approving one, or sending one is refused (reply_media_unsupported); the #7 D6 confirmation, the D7 keyed tag (which covers the target through the payload hash), the #11 cooldown scopes and the Bluesky createSession budget apply to replies exactly as to posts. Mock-tested only, with no live receipt. Proposal: docs/SECURITY-REVIEW-PROPOSAL-17-reply-send.md; guide: docs/ENGAGEMENT.md. Tests: tests/test_reply_send.py.
  • Opt-in private GitHub repository sources (issue #9; owner-approved 2026-10-09 with every recommended decision). A private repository is read only when the source sets "visibility": "private" AND "sensitivity": "private" AND the owner passes sources-scan --allow-private. Without all three, it is skipped before any read, or refused after the metadata request (repository_not_confirmed_public), and nothing from it is emitted or stored. A private source also needs REACHWEAVER_GITHUB_TOKEN, and the API must confirm the repository is private. Every signal from it carries visibility: "private", private: true and publishable: false. Text output withholds its summary, diagnostics drop its item URLs, and --mark-seen stores its ID only. signal-brief marks its evidence source_visibility: "private", publishable: false and scaffolds no cta_url. validate_campaign (campaign-check, -preview, -stage) refuses that evidence until a human sets "publishable": true on each record. A link into a private source repository is never public: campaign links, campaign-preview --utm and the new utm-link --sources CONFIG refuse it, and staging never stores it as source_url. data-export and campaign-export redact private evidence excerpts and URLs as [REDACTED:private_source]. A new token_scope report flags classic-token X-OAuth-Scopes that can write (for example repo). For fine-grained tokens the scope is reported as unknown, never as read-only. The new module is reachweaver/private_sources.py. Docs: docs/SOURCES.md and docs/SECURITY-REVIEW-PROPOSAL-9-private-sources.md. Tests: tests/test_private_sources.py, fully mocked. Approval, dry-run, emergency stop, credential isolation and reconciliation are unchanged, and no write path was added. At integration (2026-10-09) the private-link refusal was extended to the surfaces added by #13, #17, #20 and #22: with --sources CONFIG, draft (reply drafts too), media-attach (alt text, hosted image URL), youtube-attach (title, tags), check and approve (everything the post publishes, plus its source URL) refuse a link into a visibility: private repository (private_source_link); tests/test_private_sources_integration.py.

  • Threads text adapter (issue #19; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so unreachable). New reachweaver/threads.py implements the official two-step flow: POST /{threads-user-id}/threads (media_type=TEXT, text), the documented 30-second wait plus container-status polls (once a minute, at most 5), POST /{threads-user-id}/threads_publish, then a permalink receipt from GET /{media-id} that must name the configured profile. Before the claim it checks GET /me (id and username must equal threads_user_id and handle) and the read-only threads_publishing_limit quota (250 API-published posts per 24-hour moving period). mark_write_started() precedes the container POST, so every later failure is _uncertain, goes to needs_review and is never retried. The token is read only from {env_prefix}_ACCESS_TOKEN via accounts.credential, and that suffix now feeds the env_prefix overlap rule and credentials-audit. validate_post adds the official Threads 500-character (emoji as UTF-8 bytes) and 5-link limits (tighten-only). doctor notes the draft status and token expiry. The registry entry and policy.SUPPORTED_LIVE are unchanged, so publish --live, connect-check --online and publish() still refuse Threads before any credential read. tests/test_threads.py proves that, and runs the happy path and the 400/401/429/500, wrong-account, quota and post-write needs_review cases against a temporary flipped registry copy. All HTTP is mocked; no Meta app, account or live post exists. See docs/THREADS.md and docs/SECURITY-REVIEW-PROPOSAL-19-threads.md (sources read 2026-10-09). At integration (2026-10-09), per owner decisions D19-2 and D19-4: the emergency pause is re-read before and after every container wait and right before threads_publish (a pause stops it with threads_container_paused_not_public_uncertain, needs_review, never resent), and the new caps.BUILTIN_PROVIDER_CAPS holds Threads to 2 sends per 24 h with or without a caps file. The #7 D6 confirmation, the D7 keyed tag and the #11 cooldowns gate it like the other native adapters (tests/test_meta_text_integration.py).

  • Facebook Page text/link adapter (issue #21; owner-approved 2026-10-09 with every recommended decision; disabled until a live receipt, so unreachable). New reachweaver/facebook.py publishes through POST /{page-id}/feed with message. When the approved text contains an https:// URL, the first one is also sent as link, so every byte sent is still covered by the #7 approval hash. The Graph API version is pinned at v25.0, documented as available until 2028-07-29; doctor warns 90 days ahead and preflight refuses after that date. Before the claim, GET /me must return the configured page_id (a User token or another Page’s token fails), and GET /{page-id}?fields=id,name must match page_id and page_name. mark_write_started() immediately precedes the single feed POST, so every later failure is _uncertain, goes to needs_review and is never retried. The receipt is the returned {page-id}_{post-id}, and permalink_url is stored only for HTTPS www.facebook.com. The token is read only from {env_prefix}_PAGE_ACCESS_TOKEN via accounts.credential and sent only as Authorization: Bearer, never in a URL or a body. That suffix now feeds the env_prefix overlap rule and credentials-audit. Errors are mapped by HTTP status only, because Meta documents throttling codes 4/17/32/613/80001 without statuses. The registry entry and policy.SUPPORTED_LIVE are unchanged, so publish --live, connect-check --online and publish() still refuse Facebook before any credential read. tests/test_facebook.py shows that, and runs the mocked contract against a temporary flipped registry copy: happy path, User token or wrong Page, 400/401/403/429/500, timeout, unexpected ids and needs_review. No Meta app, Page connection or live post exists. See docs/FACEBOOK.md and docs/SECURITY-REVIEW-PROPOSAL-21-facebook-page.md (sources read 2026-10-09). At integration (2026-10-09), per owner decision D21-5, caps.BUILTIN_PROVIDER_CAPS holds Facebook to 2 sends per 24 h with at least 60 minutes between them, with or without a caps file; the #7 D6 confirmation, the D7 keyed tag and the #11 cooldowns gate it like the other native adapters (tests/test_meta_text_integration.py).

  • Structured worker logs (issue #26 slice). publish --log-json [--log-file PATH] emits one JSON line per worker decision, in dry and live runs, through the until-now unused privacy.structured_log (new reachweaver/worker_log.py). Fixed events: run_start, gate_refused (live_gate, emergency_pause), post_skipped (fixed reason codes such as not_due, paused, optout_blocked, rebind_required, cooldown, budget, account_missing), post_claimed, post_published (account id, platform, remote id, remote URL without query string), post_needs_review (send class, sanitized error code) and run_end (counts, exit code). Each line has a sortable run_id, a seq and a UTC ISO ts. No post text, handles, credential names or values, provider error text or URL queries are logged, and every value passes privacy.redact. The log is observational only: an unusable destination (missing or read-only directory, symlink, FIFO, group- or world-accessible file) refuses the run with exit 2 before any account load or claim, and later write failures are counted, never raised. --log-file appends to an owner-only 0600 file. tests/test_structured_logs.py shows that database rows, mocked provider requests, stdout and exit codes are identical with and without --log-json in a dry run and in a mocked live run. A WorkerLogLeakTests case was added to tests/test_security_regressions.py. Format table and journald/jq examples: docs/WORKER-OPERATIONS.md.

  • Owner-exported audience activity for calendar-plan (issue #16 slice). calendar-plan --audience-activity FILE [--audience-top-hours 5] [--audience-max-age-days 90] reads an hour-of-week audience-activity CSV that the owner fills in from their own native analytics dashboard. The planner then takes its candidate slots from the account’s top activity hours instead of the generic prior. The CSV (account_id,timezone,weekday,hour,activity,source,as_of, optional consent,notes) is ReachWeaver’s own interchange format; no real platform export has been verified against it, and examples/audience-activity.example.csv is synthetic. The new reachweaver/audience.py rejects duplicate (account, weekday, hour) rows, mixed timezone/source/as_of per account, negative or non-finite values, invalid IANA zones, future dates, oversized files (1 MiB, 10,000 rows) and text cells starting with a formula character (OWASP CSV Injection, accessed 2026-10-09). Missing and unknown hours stay missing and are never zero-filled. Rows are normalised to a UTC hour of week using the as_of week’s offsets: a DST gap hour is nonexistent and not ranked, and a fold uses its first occurrence. Every proposed entry carries provenance (first_party_audience_activity, source, as_of, rank, stale, verified_by_reachweaver: false), and a warning appears when as_of is older than the threshold. Without the flag, output is byte-identical (golden test). The planner stays advisory; approval, --apply and publishing gates are unchanged. See docs/CALENDAR-PLANNER.md. Tests: tests/test_audience.py.

  • Release-readiness scorecard (issue #27 slice). docs/release-readiness.json records every platform in the capability registry plus Postiz, Hacker News, sources, content, scheduler, analytics, engagement, media, security and privacy/operations, each with a status (not_started, offline_mock_tested, manual_export_only, live_receipt_verified, blocked), its issue, evidence paths and test classes, and owner, live or policy blockers. python3 scripts/release_gate.py --scorecard prints it as a table and --scorecard --json as JSON. The release gate’s new readiness check fails on an unknown status, a missing evidence path or test name, a capability-registry platform without a row, or a status the registries contradict, and accepts live_receipt_verified only with a checked-in first-party receipt file. No area claims a live receipt, and a test enforces that.

  • Sourced provider rate limits (issue #11). The capability registry now records the official rate-limit numbers for Bluesky and Telegram, which were unknown, and the re-verified Mastodon and LinkedIn numbers. Each was read from the official page on 2026-10-09. rate_limits gains optional accessed (ISO date), facts (one exact limit per window_seconds in a unit for a scope) and not_published (limits the official page does not state). Bluesky: 5,000 write points per hour and 35,000 per day per account, createSession 30 per 5 minutes and 300 per day (Rate Limits). Telegram: about 1 message per second per chat, 20 per minute per group, about 30 per second for bulk notifications (Bots FAQ); no channel-specific limit is published, and that gap is recorded. capabilities.validate_registry requires sourced facts for every live_text adapter and an HTTPS source and access date for every fact. A new test checks that the loosest budget the CLI allows stays inside every published per-account, per-member and per-chat limit. Data, docs and validation only: cooldown, backoff and budget behaviour are unchanged. See docs/PLATFORMS.md.

  • Read-only operational metrics and example alerting (issue #26 slice). reachweaver ops-metrics [--format json|prometheus] [--since 24h|ISO8601] [--stale-minutes N] [--optout F] [--out FILE] [--now ISO8601] counts, from the local database only: send claims, publish receipts, failures by C1/C2/C3 send class, reconciliations, approval-binding failures, recorded provider cooldowns, posts per status, needs_review by class, the dead-letter total, stale sending rows, overdue and due-but-unsent posts with the oldest due age, posts needing re-approval, active cooldowns per account, opt-out blocks and the emergency pause. Labels are allow-listed (account id shape, known platform, status, fixed codes); post text, third-party handles, URLs, provider error text and credential values never appear. The Prometheus output follows the text exposition format with sorted, deterministic output, and --out replaces the file atomically (temp file and rename, mode 0644) for node_exporter’s textfile collector. reachweaver/ops_metrics.py uses new SELECT-only storage helpers (post_status_counts, queued_posts, event_activity, needs_review_details). docs/WORKER-OPERATIONS.md has an example systemd timer and example Prometheus alert rules; they are examples and have not been tested against a live Prometheus. Tests: tests/test_ops_metrics.py and OpsMetricsLeakTests in tests/test_security_regressions.py. Approval, dry-run, emergency stop, credentials, reconciliation, opt-out and cooldown behaviour are unchanged.

  • Read-only engagement-fetch of public replies (issue #17 slice). reachweaver/engagement_fetch.py reads the public replies under posts ReachWeaver recorded as published: Bluesky app.bsky.feed.getPostThread on the public AppView and Mastodon GET /api/v1/statuses/:id plus /context, both unauthenticated GETs with no redirects. It writes a NEW owner-only (0600) reachweaver.engagement-export.v1 file that engagement-triage reads unchanged. It is plan-only without --online, bounded in posts, depth and replies, deduplicated, and redacts secrets and personal data in reply text. A 429 stops the run with the retry hint reported read-only. It never replies and writes no database row. See docs/ENGAGEMENT.md.

  • Translated subtitle checks in media-check (issue #18 slice). A subtitles[] track whose locale differs from the asset’s is compared cue by cue with the asset’s source-locale track. A different cue count, a missing or extra cue, or a start or end more than subtitle_timing_tolerance_ms (default 500, 0-5000) from its source cue is an error; alignment: "independent" on a deliberately re-timed track downgrades those to warnings. A preserve_terms entry, number or URL in the source cue that the translated cue drops is a warning, like the existing alt-text rule. Numbers compare as digit runs, so 1,500, 1.500 and Persian digits match. The JSON report adds subtitle_translations per asset. See docs/MEDIA-ASSETS.md.

  • Trustworthy metric status, privacy thresholds and report formats (issue #15 slice). metrics and import-metrics accept a value status per metric: a count, empty/unknown, suppressed (withheld by the platform, for example below a privacy threshold), not_available (not exposed by the platform), delayed (not final yet) or estimated:N. A non-numeric value is never coerced to 0. Before this, import-metrics crashed on suppressed with invalid literal for int(). Statuses are stored in a new nullable metric_snapshots.metric_status JSON column, which storage.migrate() adds idempotently to older databases. Exact counts stay in the integer columns, so the timing, baseline and experiment readers see exact values only. report --format json|markdown|csv was added; the default stays the JSON envelope. CSV cells that start like a formula are neutralised (OWASP CSV Injection). New read helpers storage.list_posts, published_posts, snapshots_for, latest_snapshot and metric_states replace the raw SELECTs in report, baseline_eval, timing_analysis, experiments and attribution, with no change in their results. Canonical schema 1.2 adds the optional AnalyticsSnapshot.metric_status; 1.0 and 1.1 documents stay valid, and schema-export emits the field. See docs/ATTRIBUTION.md and docs/SCHEMAS.md.

  • Repetition checks against the local posting history (issue #10 slice). campaign-check and campaign-preview accept --history-db PATH (plus --history-days N, default 30, 1..365; --history-window-days W, default 7; --history-max-posts K, default 7). Each variant is compared with the same account’s local posts in the statuses draft, approved, scheduled, sending, published and needs_review (cancelled is ignored) using the existing claims.jaccard and the same 0.6 threshold. A match is a history_near_duplicate warning naming the post id, status and date, never the earlier text. history_frequency warns once per account that already has K posts in the rolling window; it is labelled a ReachWeaver brand-hygiene heuristic, not a platform rule. Posts staged from the same campaign_id are skipped and counted. The database must already exist and is opened read-only through the new storage.connect_readonly (mode=ro, query_only) and SELECT-only storage.posting_history; a missing path fails with exit 2 and is not created. The global --db stays inert for these commands, and without --history-db reports are unchanged. The shared 3-gram tokenizer now composes to NFC and drops invisible format characters before comparing, so combining accents and zero-width spaces no longer hide a duplicate (this also tightens the in-campaign near_duplicate check). Warnings only: staging, approval, scheduling and the database are untouched. See docs/CLAIM-GROUNDING.md. Tests: tests/test_history.py.

  • The release playbook and the skill’s evaluation reference no longer say no area is live_receipt_verified. docs/release-readiness.json marks Bluesky and Mastodon live_receipt_verified (one approved text post each, 2026-10-11, receipts in docs/receipts/); docs/RELEASE-PLAYBOOK.md and references/skill-evaluation.md (both skill copies) now say so, and tests/test_live_receipts.py checks the playbook sentence.
  • Platform matrix reconciled with the registries and code (issues #14, #30, #26). docs/PLATFORM-MATRIX.md now separates native platform scheduling, mock-tested ReachWeaver adapters and real owner account receipts (none exist). LinkedIn is shown as live_text (personal profile/member text, mock-tested, no live receipt); a new backend table lists the optional Postiz backend (owner opt-in, mock-tested, behind every ReachWeaver approval gate) and each platform row says whether Postiz accepts it. Bluesky and Mastodon analytics are metrics-fetch --online public counters only (views, impressions and clicks unknown), and the media column names media-check, media-storyboard and manual-export where the code covers each platform. tests/test_platform_matrix_docs.py parses the delimited tables and fails when an adapter, Postiz, native-scheduling, receipt, media-tool or metrics-fetch cell disagrees with platform_registry.json, distribution_registry.json, policy.SUPPORTED_LIVE, postiz.PROVIDERS, media.CONSTRAINTS, storyboard.py, manual_export.MANUAL_PLATFORMS or metrics_fetch.SOURCES.
  • platform_registry.json metrics_access for Bluesky and Mastodon described manual import only; it now names the read-only metrics-fetch --online public counters, with the getPosts lexicon and Mastodon Account entity added to sources.
  • ADR-0002 decision 8 records the dynamic-dependency guard in scripts/check_runtime_deps.py: [project] dynamic naming dependencies/optional-dependencies (or not being a list) and [tool.setuptools.dynamic] declaring either key now fail.
  • Skill references (SKILL.md, publishing.md, global-distribution.md, timing-intelligence.md), the growth playbook and the distribution governance guide no longer say only three text adapters exist; LinkedIn member text is the fourth.
  • Docs and Claude Code skill consistency (D1). Current-state statements in ARCHITECTURE, PRODUCT, IMPLEMENTATION-ROADMAP, FIRST-LIVE-PILOT, FIELD-PILOT-PROTOCOL, RELEASE-PLAYBOOK, UPSTREAM-RESEARCH and THREAT-MODEL now name all four mock-tested text adapters (Bluesky, Mastodon, Telegram, LinkedIn member) and the optional mock-tested Postiz backend, with no real live receipt; dated records (ADR-0001 context, THREAT-MODEL T10, PRODUCT v0.1 scope) keep their text and gain an “Update 2026-10-09” note. The skill now covers every CLI subcommand: references/security.md lists each credentials-audit finding code with the owner’s fix, the owner-only REACHWEAVER_ENV=prod live gate, systemd LoadCredential= and the init/privacy-status/data-export/data-purge lifecycle; references/measurement.md adds landing-check; references/publishing.md adds schema-export and cancel; references/skill-evaluation.md adds release_gate.py --scorecard; SKILL.md routes these requests and shows init, credentials-audit, engagement-fetch, ops-metrics, campaign-check --history-db and calendar-plan --audience-activity. tests/test_skill_contract.py now fails when a CLI subcommand is missing from SKILL.md and its references, when a reference is not linked from SKILL.md, or when the frontmatter breaks the Agent Skills field limits (name 64, description 1024 characters).
  • Wave-8 documentation sync (2026-10-09). Operator guides (QUICKSTART, TROUBLESHOOTING, ACCOUNT-ONBOARDING, FIRST-LIVE-PILOT) cover the new error codes, credential suffixes and account fields, provider revoke steps, the opt-in #7 D6 confirmation and D7 keyed binding, and the disabled adapters. The threat model covers D7, the open replay residual, the real live-gating sets (SUPPORTED_LIVE, SUPPORTED_LIVE_VIDEO, live_media_formats, reachweaver_reply_adapter), custom Bluesky PDS origins and the Facebook Page token, host and missing idempotency key. ROADMAP, IMPLEMENTATION-ROADMAP, the skill (SKILL.md, publishing.md, formats.md, global-distribution.md, campaign-contract.md) and YOUTUBE.md follow. README, ARCHITECTURE, PLATFORMS and PLATFORM-MATRIX now separate the four enabled live_text adapters from the mock-tested Threads, Facebook Page, Instagram, YouTube and TikTok adapters, Mastodon images and Bluesky/Mastodon replies, all owner-approved 2026-10-09 and disabled until a live receipt. They also name the real approval binding tags (approval/1, approval/media-1, approval/video-1, reply target), every credential suffix, the reachweaver_reply_adapter registry field and the shared Postiz workspace cooldown and Bluesky createSession budget, the built-in tighten-only Threads (2 per 24 h) and Facebook (2 per 24 h, 60-minute gap) caps, and the caveat that the #9 private-link check on draft, media-attach, youtube-attach, check and approve runs only with --sources CONFIG (also in TROUBLESHOOTING and the campaign-contract skill reference). Stale draft-awaiting-review and pending-integration labels are gone.
  • Meta setup docs re-checked, Facebook doctor names its permissions, registry descriptions refreshed (private #12, #14, #19, #20, #21, #43; no adapter enabled). doctor on a Facebook account now prints INFO: facebook_write_scope_unverified naming pages_manage_posts, pages_read_engagement and pages_show_list as unverified until checked live, and a SETUP: line when page_id or page_name is missing (facebook.setup_gaps; names only, no credential read, no request; tests/test_facebook.py::ConfigAndCredentialTests::test_doctor_names_permissions_and_setup_gaps_offline). docs/ACCOUNT-ONBOARDING.md gains the Facebook Page credential row (<P>_PAGE_ACCESS_TOKEN), the page_id/page_name field row and Facebook in the Meta revocation step; tests/test_account_onboarding_doc.py fails when any platform with a credential variable in accounts.CREDENTIAL_SUFFIXES has no row. Official Meta pages were re-fetched with curl on 2026-10-15 and every quote matched with grep: docs/THREADS.md (no business or creator requirement found; public versus private profile grants), docs/INSTAGRAM-SETUP.md (scopes, a dated Instagram Login versus Facebook Login comparison, Standard versus Advanced Access eligibility) and docs/INSTAGRAM.md (IG User Media and Instagram Login re-read), docs/FACEBOOK.md. reachweaver/platform_registry.json descriptive fields only: the top-level note names the two 2026-10-11 receipts instead of saying no account was verified; dated Threads, Instagram and Facebook rate limits, constraints and approval requirements; pages_show_list in the Facebook scopes; a LinkedIn text-length constraint citing the UGC Post API page (the Posts API pages state no maximum). formats, reachweaver_adapter, live_media_formats, reply/video switches, status, verified_date and policy.SUPPORTED_LIVE are unchanged; tests/test_live_receipts.py pins the note. Mirrored in docs/PLATFORMS.md.
  • A public YouTube upload that is still processing says so (private #22). When an upload approved as public returns uploadStatus=uploaded rather than processed, the NOTE line after PUBLISHED now adds that processing is not confirmed yet and how to check: YouTube Studio, or a later videos.list read with part=status (Video resource and videos.list docs, checked 2026-10-15). ReachWeaver makes no such read itself. Text only (youtube.visibility_note gains an optional upload_status): the receipt check, send, retry, pause and reconciliation are unchanged. Tests: three new in tests/test_youtube_visibility.py::PublishOutputTests. Docs: docs/YOUTUBE.md. YouTube stays disabled in the registry.
  • Sourced UI-overlay safe zones for media-storyboard (issue #18 slice). The built-in TikTok, Instagram Reels and YouTube Shorts zones were unverified. They are now sourced from first-party pages and files, accessed 2026-10-09, each with its URL, the numbers as stated, and the evidence. TikTok uses the In-Feed ads template (160/440/80 px plus a 120 px action column on 720x1280). Instagram uses Meta’s Reels ad specs (14%/35%/6%). YouTube Shorts uses the Google Ads vertical-video image (288/672/48/192 px on 1080x1920). All three are paid-ad specs (placement: "ads"), and none of the sources states an organic overlay, so every check warns that organic overlays may differ. Owner zones without a source and date stay unverified and now add a warning to every check that uses them. The TikTok zone and the combined safe area of the default targets are stricter than before; YouTube’s sourced left margin (48 px, 0.044445) is narrower than the old 0.06 convention, and its other three margins are wider.
  • report aggregates exact values only and no longer prints a fabricated 0 (issue #15). A metric with no exact value on any published post is now null in JSON (it was 0) and unknown in Markdown and CSV. Each platform gains a metrics block with value, exact/total coverage, counts by status (a published post with no snapshot counts as unknown) and a separately labelled estimated block that is never added to the value. import-metrics is now all-or-nothing: a bad cell names its CSV line and nothing from that file is imported. The SYNTHETIC weekly report example section A is now the report --format markdown output, and its fixture imports Bluesky and Mastodon views and impressions as not_available.
  • ops-metrics --out could overwrite the state database (issue #26, review F1). --out atomically replaced any path, so --out state/reachweaver.sqlite replaced the database with metrics text, set it to mode 0644 and printed database unchanged. ops_metrics.write_textfile now refuses, with exit 2 and nothing written, the database itself (same path, same resolved path or a hard link, via os.path.samefile), any symbolic link, a directory, and an existing file whose name does not end in .prom or .json; a new file may have any name. Tests: OutTargetTests in tests/test_ops_metrics.py.
  • Read-only commands no longer create or migrate the database (issues #26, #17, review F1). ops-metrics and engagement-fetch opened the database with storage.connect, which creates the file and its directory and runs ALTER TABLE migrations. Both now use storage.connect_readonly (SQLite mode=ro, query_only): a missing database is exit 2 instead of an empty one that reports zeros. On a database from an older version, ops-metrics names the missing columns (new storage.schema_gaps) and exits 2 without migrating, and engagement-fetch reads it unchanged (it needs only the original posts columns). metrics-fetch writes snapshots and still uses connect. Tests: ReadOnlyDatabaseTests in tests/test_ops_metrics.py and the read-only cases in tests/test_engagement_fetch.py.
  • ops-metrics --since 999999d crashed with an OverflowError traceback (issue #26, review F1). The relative amount is now bounded by the 3650-day limit before any date arithmetic, --now must lie between 1970 and 9998, and storage.parse_utc turns an out-of-range date such as 0001-01-01T00:00:00+01:00 into a ValidationError (exit 2) for every command that parses a date.
  • engagement-fetch cut reply text before redacting it (issue #17, review F1). An e-mail address, token or phone number crossing the 4000-character cap was seen only partially by the redaction patterns, so a fragment such as john.doe@examp or the first 4000 characters of a JWT reached the export. Redaction now runs on the first 8000 characters before the cap, a fragment split by either cut is dropped, and the result is redacted again.
  • One Bluesky reply with an empty handle made the whole engagement-fetch export fail (issue #17, review F1). write_export rejected the document (author_handle must be a non-empty string). An empty or missing handle now falls back to the author’s DID; a reply with neither is skipped and noted as reply_without_author_identity_skipped.
  • A credential with a control character could leak into ERROR: output (issue #5, review F1). A $CREDENTIALS_DIRECTORY file ending in \r\n kept its \r, http.client refused the header with ValueError("Invalid header value b'Bearer <token>\\r'"), and the CLI printed that message, token included. accounts.credential now strips exactly one trailing \n or \r\n from a file and refuses any value, from a file or the environment, that still contains a C0 control character or DEL (credential NAME contains control characters, name only). As defence in depth, publishers turns a ValueError, http.client.InvalidURL or unknown-scheme error raised while building or sending a request into the fixed PublishError("invalid_request") (and a later http.client.HTTPException into invalid_api_response_uncertain), with the original message dropped; the CLI’s top-level ERROR: line now passes through privacy.redact. Ambiguous-send classification is unchanged: a failure after mark_write_started is still C3 ambiguous and goes to needs_review. Tests: ControlCharacterCredentialTests, ControlCharacterCliTests, RequestBuildErrorTests in tests/test_hardening.py.
  • Telegram and LinkedIn member posts are mock-tested only: no live send has been recorded. Threads, Facebook Pages, Instagram, YouTube and TikTok adapters stay registry-disabled until the owner’s supervised receipts.
  • No field pilot has been run, so there are no growth results and none are claimed.
  • Browser-assist delivery is not planned (the official terms prohibit it on LinkedIn and TikTok; see docs/BROWSER-ASSIST-DECISION.md).