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-auditandpost-audit(profile CSV, cross-platform consistency),suggest,weekly-digest,peer-benchmark,disconnectandreconnect. - 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 inshowand 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).
- Account hold is on by default. While any post of an account is
needs_review, orsendingfor over 15 minutes, no other post is claimed for that account (account_unresolved_send_hold) until you check the native account and runreconcileorcancelon that post. There is no bypass flag. - Images that carry a rights record are approved under
approval/media-2. Images without one, and every existing approval, keepapproval/media-1unchanged. Approval expiry (approval-mode --max-age-hours) is off until you set it.
Security
Section titled “Security”- 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-attachcan 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 themedia-checkmanifest (SPDX orowner/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 inmedia_json, shown byshowand the confirmation display, and bound by the approval hash under the new tagapproval/media-2: changing the license, attribution, provenance or a translation after approval invalidates it. An image without a rights record keepsapproval/media-1byte 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 intests/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). disconnectandreconnect(issue #38, owner-reviewed and merged 2026-10-15, PR #72).disconnect --account IDis a tighten-only local action: it stores oneaccount_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 --liveskips it before any credential check or request, the dry run reports it, andconnect-checkshowslocal_state. It prints the credential variable names the account reads (never values) and generic revocation steps, and revokes nothing itself.reconnect --account IDis 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 reasonaccount_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, orsendingfor over 15 minutes, no other post is claimed for that account:publish --liveprintsSKIPPED ...: 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 byreconcileon the post that causes it; there is no bypass flag. A livesendingpost newer than the reconcile threshold does not hold. Worker logs gain the skip reasonunresolved_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-onlytests/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
verifyjob and an owner-onlypublishjob (private #50, #51)..github/workflows/release.ymlnow defaults tocontents: read;verify(checkout withfetch-depth: 0andpersist-credentials: false) runs the newscripts/release_ref_guard.py, the secret scan, stdlib lint, compileall, runtime-dependency guard, Ruff and mypy with the same pins and flags asci.yml,sync_skill.py --check, the tag/version check andrelease_gate.py --tagwith tests;publishneedsverify, runs only when the pushing and triggering actors are the repository owner, is the only job withcontents: write, testsskill.zipand its checksum, re-runs the guard and callsgh release create --verify-tag. The stdlib guard readsGITHUB_*variables and localgit rev-parseonly (no token, no network) and fails closed unless the run is a tag push of exactlyvMAJOR.MINOR.PATCHby the owner (login and account id) and the checkout, tag,GITHUB_SHAandorigin/mainare one commit. Tests:tests/test_release_ref_guard.py(16, against real temporary local git repositories) andtests/test_release_workflow.py(16, was 7). Docs: “Maintainer release checks” indocs/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 Nsets a time-to-live on approvals, off by default. When set, a post approved more than N hours ago isapproval_expired_reapproval_requiredin the send claim,schedule,calendar-plan --apply,healthandshow(which printsapproval_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;schedulerefuses 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-healthno longer prints query values of stored links (private #15, #26). A post body orsource_urllink such ashttps://example.org/welcome?token=...&email=...was echoed verbatim in thedestination, theutm_issuemessage and, online, the final URL and redirect chain. Every printed URL now goes throughattribution.report_landing_page(origin, path andutm_*only, thenprivacy.redact); such a link is marked"redacted": true. Grouping and the--onlinefetch still use the full destination. Found by the extended leak sweep; pinned bytests/test_link_health.py::ReportSafetyTests(3 tests).- Send-state bookkeeping atomicity (owner-approved 2026-10-11 as a security-reviewed change).
finish_send/uncertain_sendreturn a flag and write astate_conflictevent (remote id / error code) when the post is no longersending;publishprintsREVIEWand logsstate_conflictinstead ofPUBLISHED.reconcileis atomic and status-guarded, refuses asendingpost claimed under 15 minutes ago unless the new--forceis given (an existing test now passesforce=Truefor a freshly claimed post).transitionis atomic and its updates are status-guarded.publishretries 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_pausedand its event commit together. Tests:tests/test_send_state_atomicity.py.
- The onboarding audit prints a
manual-exportcommand the CLI accepts for Facebook Pages. The distribution rowfacebook_pagesproduced--platform facebook_pages, whichmanual-exportrejects; it now prints--platform facebook. Pinned bytests/test_onboarding.py::ManualExportCommandIds. profile-auditapplies X’s 160-character bio limit again, now sourced (2026-10-16). The official X Help page says “Bio (maximum 160 characters)”. It answerscurlwith a bot challenge, so it was read in a real browser and recorded with the URL, date and quote inprofile_audit_registry.json. A 161-character X bio now gets alength_reviewnote. LinkedIn, which states no number, is still leftunknown.- 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.pynow 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_statusinreachweaver/distribution_registry.jsonstill 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 indocs/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_receiptskeeps 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.pypins the wording. - Review hardening (offline, no safety-control change). Campaign and policy validators no longer crash with
TypeErroron list/dict JSON values forobjective, evidencekind/verification/source_visibility, variantaccount_idorplatform; they report normal validation errors, andmain()maps any remainingTypeError/KeyError/AttributeErrortoERROR: invalid input structure(exit 2, no input echoed).campaign-previewrenders(missing)for evidence without a URL.campaign-exportvalidates before creating--out, creates it with mode 0700 and removes it if the export fails.growth.classify_resultreturnsinconclusivewhen the pooled lift sign disagrees with the per-pair direction;experiment-evaluatenow uses the same minimum-pairs / sign-test gate (nodirectional_positivefor tiny samples; newdirectional_negativeverdict; newreadout_label,readout_explanationsandwindowed_snapshots_skippedkeys, all existing keys unchanged). Snapshots withwindow_start/window_endare windowed counts and are excluded from matched-age comparisons inexperiment-evaluate,baseline-evalandtiming-analysis, with a warning/count. Engagement matching collapses all whitespace so newline-split injection directives are flaggedsuspicious.Content-Length/analytics counts accept ASCII digits only ("²"is a validation error, not a crash). Postiz_callconvertshttp.client.HTTPException,InvalidURLandValueErrorinto fixedPublishErrorcodes without echoing headers. LinkedIn drafts whose escaped commentary exceeds 3,000 characters are refused byvalidate_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.mdpassedpromtool check rulesand loaded into a running Prometheus 3.15.0 against realops-metricsoutput from a synthetic database. All 9 rules were healthy, and the pause alert went pending.tests/test_alert_rules_doc.pynow fails if a rule names a metric thatops-metricsdoes not emit. TheLoadCredential=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 ononboarding.blocker_audit, with anofficial_api_adapterroute for the four mock-tested text adapters and the exactmanual-exportcommand for everymanual_onlychannel; 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, exampleexamples/manual-batch.example.json, bundled as a skill asset): 1 to 25 items preflighted as a whole through the existingmanual-exportbuilders, nothing written on any blocking error, human-only destinations refused, an advisoryattime that is never scheduled, and abatch-manifest.jsonwith 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 blockmanual-export(single and batch), aspolicy.validate_postalready does for live posts. (4) One new registry entry,pixnet(PIXNET, Taiwan blog;manual_only, schedulingunverified, 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.mdanddocs/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 anddocs/REGIONAL-PLATFORMS-2026-10-14.md(superseded by main’s regional catalog, feasibility matrix and market profiles), its duplicatekakao_channel,line_officialandzalo_oaids, and itschecked_onvalidator 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.mdrecords 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.pystays as the guard. No code change. -
Threads acceptance evidence for issue #49 (offline, mocked; adapter still disabled). New tests in
tests/test_threads.pyclose 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/meor the quota read isBLOCKEDbefore the claim, the post stays approved, no cooldown),test_contract_expired_token_blocks_before_claim_and_keeps_post_approved(a pastauthorization_expires_atis 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 withtext_match=true, and laterpublish --liveruns make no request) andtest_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.mdgains 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.mdno 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.pyclose the gaps against the acceptance criteria: a post is sent only through its own account’s token andig_user_idwhen 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-claim429records the provider cooldown and keeps the post approved; a published post is never resent by a targeted or queue-widepublish --live; the identity403and quota400missing-permission hints are pinned; an emergency pause between aFINISHEDcontainer andmedia_publishstops before publishing. The existing never-ready test now also asserts exactly four one-minute sleeps. ThreeIssueFiftyEvidenceTestspin the setup guide’s prerequisite, scope, Login-comparison and eligibility sections, tie the Instagram row ofdocs/PLATFORM-MATRIX.mdto the registry’srate_limits.accesseddate (2026-10-15), and fail if a test cited in the new criterion-to-evidence table indocs/INSTAGRAM.mddisappears.docs/INSTAGRAM-SETUP.mdgains a section on personal or unsupported accounts (themanual-exportfallback, 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 withcurlon 2026-10-15). No change to approval, dry-run, emergency stop, credential isolation, reconciliation, the registries orpolicy.SUPPORTED_LIVE. -
Facebook Page adapter:
doctornames 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 validpage_idandpage_name,doctornow printsfacebook_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:doctorreports it asfacebook_declared_permissions (owner-declared, not verified)against the three permissions the Page feed reference lists, asWARNINGwhen one is missing. It is never sent, never allows or blocks a send, is not part of the approval binding, andconnect-checkstill reportswrite_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_endpointandtest_declared_permissions_never_shape_a_request_or_count_as_verified,FacebookDocsTests(3) and an extended CLI doctor check, all intests/test_facebook.py(mocked HTTP only). Docs:docs/FACEBOOK.md,docs/ACCOUNT-ONBOARDING.md,docs/PLATFORM-MATRIX.md(Facebook row and official references) anddocs/ARCHITECTURE.mdnow cite the official Page feed and User feed references and the v19.0 Groups removal, re-checked withcurlon 2026-10-15.docs/FACEBOOK.mdalso documents a residual: a User token withpage_idset to that person’s own id passes the two identity reads, and the write then fails at Meta and goes toneeds_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_IDfor a Postiz-backed account now prints the backend’s own offline preview asplatform_preview(path: postiz, thePOST /postsURL,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: apauseduring the read-only preflight, refused at the claim with noPOST /posts; backendQUEUE/ERROR/unknown/missing job states, reported read-only and never reconciled; a lookup refused for a post without apostiz:job id;reconcilestoring 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.mdgains a manual-fallback section, a ready owner smoke-test procedure marked NOT RUN (pinned byPostizIssue56DocTestsuntil apostiz*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.bindin 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 fromdocs/REGIONAL-CHANNELS-2026-10-14.mdanddocs/PLATFORM-MATRIX.md. Tests:tests/test_regional_api_feasibility.py(16). -
showprints a LinkedInplatform_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_urnfromperson_id(null when the account config does not load), thevisibilitythe payload sends (newlinkedin.VISIBILITY, the samePUBLICvalue as before),counted_lengthagainst the 2,800 code-pointtext_limitandescaped_commentary_lengthagainst the 3,000-characterescaped_commentary_limit(both the existingpolicyconstants),within_limits, and a dry-run note. No send, approval or limit change. Tests: three intests/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.mdsection 4 and the skill’sreferences/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 withapp.bsky.feed.getQuoteson the public AppView: unauthenticated GET through the same redirect-free helper,limitmin(100, --max-quotes),cursorpagination capped at 5 pages and--max-quotes(1..500, default 100). Quotes becomekind: "quote_post"items (in_reply_tonull,quoted_urlyour 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 carrykind: "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 indocs/ENGAGEMENT.md; no live request was made. Tests: 9 new intests/test_engagement_fetch.py(mock HTTP). -
Generic manual package for every other
manual_onlydestination (private #44, item C2; offline only).manual-export --platformnow also accepts any othermanual_onlyid indistribution_registry.json(DEV, X, Product Hunt, Reddit, Hacker News and the rest;facebook_pagesmaps tofacebook). Newreachweaver/manual_package.pybuilds a package with the approved text (post.txt, optionaltitle.txt), the media manifest validated bymedia.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 andmedia.pytables; the text limit always readslimit unknown — check the platform.human_onlydestinations get no agent-written text: a varianttextor title blocks the package, and it writes aNOTES.mdform (at most 10 shorttalking_points) that says “Write this yourself.” Status staysmanual_export_not_published; no delivery mode, browser automation, network access or registry change.tests/test_manual_package.pycovers 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 gainsprofile_field_limits: 16 limits on 9 of 25 networks (Instagram bio; Facebook Pageabout(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 withcurl | grep. The loader refuses an entry without ahttps://URL, ISO date or a quote containing the number, and keepsprofile_bio_limitin 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 carriesfield_limitswith"unknown"for every unsourced field. X’s 160-character bio is no longer applied: help.x.com returns a bot challenge tocurl, so it could not be re-verified. Tests:tests/test_profile_audit.py(5 new or changed). Docs: “Sourced profile field limits” indocs/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_credentials401/403and status-create401/403(the403on the status create is an assumption: that page documents only401and422).tests/test_mastodon_auth_fixtures.py(6 tests) shows a401/403at 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 givesneeds_review(ambiguous), neverpublished, with no resend from a new process; and that a handle on another server is refused byconnect-check --onlineand the live preflight before any request.docs/MASTODON.mdis 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:publishwithout--livesends no request, claims nothing and leaves the post approved even with the live gate open.tests/reconcile_support.pydrives a Threads, Instagram or Facebook Page post (flipped test registry only) fromneeds_reviewthroughreconcile --publishedand--not-published, after which neitherpublish --live --post-idnor 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::DuplicateAvoidanceTestsshows, 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’sclaim_sendingreturns false after its read-only channel check).StudioFallbackDocTestskeeps the “Manual fallback: upload in YouTube Studio” section ofdocs/YOUTUBE.md. The acceptance table indocs/MANUAL-EXPORT.mdnow 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(atyping.Protocol) declares preview, create, receipt lookup and the reconciliation hand-off;publishers.NATIVE_PATHandpostiz.POSTIZ_BACKENDdelegate to the existing functions,publishers.backend_fornames the path an account selects, and dispatch is unchanged.tests/test_postiz_contract.pyruns the same conformance checks on both paths. Six synthetic fixtures intests/fixtures/providers/postiz/backPostizFixtureTests: a create returning two jobs (needs_review, onePOST /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 (BLOCKEDbefore the claim). No Postiz instance has been connected. -
Browser-assist guard tests (private #44; tests only).
tests/test_browser_assist_guard.pypins today’s state until the owner decides #44: nobrowser_assistmode in either registry or runtime module, both registry validators reject one, the skill anddocs/FIRST-LIVE-PILOT.mdkeep their no-browser/cookie-automation sentences, and Reddit, Hacker News, Discord, WhatsApp, Facebook Groups and Indie Hackers (and every otherhuman_onlyentry) staymanual_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 amockstatspost and rendersreportas JSON, Markdown and CSV: one block per platform, no cross-platform total, Mastodonsavesand every public-counterviews/impressions/clicksstaynull/unknown, never 0.tests/test_security_regressions.py::NewerReportLeakTests(4 tests) sweeps seeded secrets and personal data throughreport --format markdown|csv,weekly-digest --include-db,youtube-analytics,link-health,calendar-gaps,suggest --include-db,schema-exportandpeer-benchmark. The unified-inbox demo gainsactionable_feedbackandspamitems from two sources each, anddocs/ENGAGEMENT.mdnow separates the five action labels from the five item kinds.tests/test_briefs.py::ReleaseAndRssCliChainTestsdrivessources-scanthensignal-briefthrough the CLI for a release and an RSS item.docs/PRIVACY-LIFECYCLE.mdno longer lists the existing poisoned-feed and dependency-compromise tests as open, anddocs/TIMING-EVALUATION.mdpoints toattribution-reportfor 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 withgit check-ignorethat.env,.env.*,state/,config/accounts.jsonand*.localare ignored while the examples and source are not, that no credential or state file is tracked, and that the CIhygienejob runsscripts/secret_scan.pywithout using repository secrets. No behaviour change. -
Estimated platform/API cost in the preview (public issue #39).
shownow includesapi_costand the dry runpublishprints oneAPI cost (platform)line per platform with a due post. The text is the sourcedfeesfield andverified_dateof 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;xonly asXorTwitter), 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’splatformvalue 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 gainsfor_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 --attributionandbaseline-eval --attribution --outcomeread website and repository outcomes (private #16, offline). Both take a savedattribution-report --jsonfile.docs_visitsis each post’s attributedsessionsandrepo_stars_utmits attributedconversions; 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>inposts.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 (newexperiments.pooled_readout, extracted unchanged fromevaluate_experiment); inbaseline-evalthe flags replace--source/--metric/--denominator, which are refused alongside them.--attributionwith any other primary metric is refused. New parsersattribution.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:00reserves 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,--slotor--audience-activity). Every entry getsrole; explore entries areevidence: operator_explore_hypothesis,confidence: unverified; anexplorationblock gives counts, both slot lists,randomized: falseand the matchingbaseline-eval --slotvalues. No explore time is invented, an explore slot equal to an exploit slot is refused, and spacing, blackouts, DST checks and--applygating 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.mdsection 2c,docs/TIMING-EVALUATION.md, README, skill. -
timing-evaluate --stratify format|campaignand 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 campaignadds astratalist with the same matched-age daypart readout per stratum. A stratum with fewer than two slots at--min-posts-per-slotisinsufficient_datawithcandidate: nulland acold_startblock that points tocalendar-plan’s generic, low-confidence baseline slots (none_availablefor 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 additivecold_startkey). 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 confirmspublic_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 givesinsufficient_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-individualsis passed.docs/PEER-BENCHMARK.mdstates 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-auditreads 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.--filenow accepts a long-format.csv(platform,account_id,handle,profile_kind,keywords,field,value, optional constantbrand_nameandpreferred_handle; seeexamples/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 advisorycross_platform_consistencyblock (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 undernot_compared, and nothing changes a score.tests/test_profile_snapshot_csv.py(11 tests);docs/PROFILE-AND-POST-AUDITS.mdand 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.pydrives one post throughdraft,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, theat://URI and absky.applink), a second live attempt (no request) andreport(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_claimedandpublishedfor both, with the remote ids stored. On 2026-10-15 both posts were re-read from the platforms’ own public APIs without credentials (Blueskyapp.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.jsonanddocs/receipts/mastodon-2026-10-11.jsonhold the first-party receipts;docs/release-readiness.jsonmoves Bluesky and Mastodon tolive_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.pynow requires the matrix cell to agree with the scorecard receipt;test_onboarding.pyexpects 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.mdgains “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::IndiaBlockingReviewTestspins the section, that every India entry staysmanual_onlyandunverified, 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_accountandkakao_talk_channel(bothmanual_only,unverifiedscheduling,human_onlyno 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.mdlists 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.pycounts 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_sourceandchecked_onnow say so, and a new section ofdocs/COMMUNITY-AND-CREATOR-PLATFORMS-2026-10-15.mdlists the quotes (25 words or fewer) and, separately, everything that stayed unverified (403 or JavaScript-only pages). Every entry staysmanual_onlywithunverifiedscheduling; no behaviour change.tests/test_platform_research_batch.py::RereadEntryTests(4 tests) pins entries and doc together;test_distribution.pyallows 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.mdgains 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_onlydelivery, 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 bytests/matrix_rows.py) requires each row to equal its registry entry, every entry to staymanual_only, a surface with no readable source to stayunverifiedand 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_onlyentries inreachweaver/distribution_registry.json, each dated 2026-10-15 with aresearch_sourceand, 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_onlyis 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 areunverifiedfor scheduling andhuman_only.docs/COMMUNITY-AND-CREATOR-PLATFORMS-2026-10-15.mdrecords, 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’schecked_onrule. -
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/instancelimits), supported formats, what the approver sees inshow, how each failure ends (needs_reviewclasses, 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-runshowafter 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 matchclassify_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_confignow refuses two sources of the same project that declare differentsensitivityvalues: a project is identified by the optionalprojectlabel (any kind, case-insensitive), the GitHubrepo, or the repository behind a markdown changelog’s raw URL; the error names both entries and nothing is scanned.projectis validated for every kind, not onlychangelog. 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.mdstates 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-reportnow includes ametric_definitionsblock in every report (refused comparisons too): unit, source of truth and definition for each of the nine measures as indocs/FIELD-PILOT-PROTOCOL.mdsection 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.ymlnow runspython 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-testscan never exit 0 and is not used). Checked locally withrelease_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 includesapproval-ledger.jsonlnext 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, andapproval_replay_reapproval_requiredfor 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 withpausefirst andresumelast, 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 exportedAnalyticsSnapshotcarries the account, platform, remote post id and source of its storage rows, points at the samePublishJobandPublishReceiptas 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 asnull, never 0.tests/test_weekly_report_prose.py(7 tests) requires every figure in the hand-written decisions ofdocs/WEEKLY-REPORT-EXAMPLE.mdto come from the generated block or the fixtures and the derived ones to equal whatreportandattribution-reportcompute. 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.mdnow 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 fromstorage, and name no claim/approve/schedule/send function;next_hypothesesandexperiment-nextcarryrequires_human_approvaland 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 onpause, 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.mdgains “Setup, review and audit”: the official steps (register the app, add the Content Posting API product, getvideo.publishapproved, 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 atuser_infoorcreator_inforetries a bounded number of times, records a cooldown and keeps the post approved with nothing uploaded; a 429 atinitafter the claim isneeds_review, certain-not-sent, with a cooldown and no resend; 401 atcreator_infoorinitnames the missingvideo.publishor upload scope; an APIscope_not_authorizedbody blocks without an upload; the exact privacy level is shown byvideo-attachandshowfor 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>,publishprints aNOTEline from the approved privacy: forprivateandunlistedit says the upload succeeded but the video is NOT public and how to change that in YouTube Studio; forpublicit says so as approved. The line changes no state, cannot fail the send (a malformed stored record prints no note) and uses the newyoutube.visibility_note.tests/test_youtube_visibility.pyalso feeds a real adapter receipt throughyoutube-analyticsand checks the receipt fields agree and every metric staysunknown, 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-briefscaffold gives one placeholder variant per approved account andmedia-storyboard --campaignturns the same campaign file into a timed scene list, SRT skeleton, image specs and manifest.tests/test_repurposing_chain.pyruns 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) andsentiment(negative,neutral,positive); an absent value is unknown, never zero or neutral, and neither is inferred from the text.engagement-queueorders 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-checkchecks name spelling and, opt-in, link previews (public issue #4).--name NAME(repeatable) or a top-levelnameslist in the input file flags other spellings of your project name (Reachweaver,Reach Weaver,REACHWEAVER; URLs, hashtags and handles ignored) asname_variantwarnings.--online [--allow-origin O]also checks each item’slinkonce per distinct page through the guardedlanding.fetch_landing(robots.txt first, inspected redirects, at most 25 pages, no image requests):preview_http_erroris an error, missingog:title/og:description/og:imageand redirects are warnings. The default run is unchanged and still offline; the report’spolicy.network_accessnow 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-triageruns every reply draft, built-in or from an owner template, through the lexical claims check against an optionalevidencelist in the docs map (campaign-evidence shape, at most 50 entries, httpsurl). Each draft gainsclaims_check(clean,revieworerror, with findings) and the report aclaims_checksummary;render_markdownshows 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 staysdraft_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 --noticeandupdate --checkalso readCHANGELOG.mdat 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 --jsongainschanges.update --applyis 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-activityor the generic baseline) isfilled,emptyorpastagainst the locally scheduled posts; days over--max-per-dayand posts closer than--min-spacing-hoursare flagged; optional--timing-source/--timing-metric/--timing-denominatorannotate empty slots with the account’s owntiming-evaluateresult (association only). Runs on amode=roconnection (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 fromcontentandsource_urlofapproved,scheduledandpublishedposts (--status,--account,--campaign) are grouped by UTM-stripped landing page. Offline it checks https, credentials and UTM tags; with--onlineit reads robots.txt first and sends one GET per distinct page (at most--max-links, default 25, max 50) throughlanding.fetch_landing(inspected redirects, https and same-domain rule unless--allow-origin, size caps, no image requests) and flags missingog:title/og:description/og:image. Onlybrokenandunusablelinks are errors (exit 1). Runs on amode=roconnection (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-checkknows the GitHub social preview and the DEV cover. Newgithubanddevtoimage targets with sourced rows: GitHub PNG/JPEG/GIF, under 1 MB, at least 640 × 320 (newmin_heightrule), 1280 × 640 recommended; DEV only the recommended 1000 × 420 (file type and size are not stated, so they stayunknown). Rows now carry their own verification date (older rows keep 2026-10-08) and the report addsconstraints_verified_dates;constraints_verified_dateis 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 rankedsuggestactions, and with--include-dbthe localreporttotals, to one new owner-only (0600) Markdown file. Same input flags and loading code assuggest. An existing file or symlink at--outis 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 labelledofficial,repo_policyorhypothesis; 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).doctornow prints aSETUP:note naming any ofhandle,ig_user_idorauthorization_expires_atmissing from an Instagram account (config field names only, no credential read, no network).docs/INSTAGRAM.mdrecords the 2026-10-12 re-check of the unverified Meta items. No registry,SUPPORTED_LIVEor send-gate change. -
Skill install first-use fixes.
scripts/sync_skill.pynow also bundlesconfig/accounts.example.jsonandexamples/example-post.txtinto the skill’sassets/, 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 bundledassets/accounts.example.json. SKILL.md defines theRWcommand 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 usepython3, makedoctorthe first skill command and mapexamples//config/to the skill’sassets/. Tests intests/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./pluginfolder) andplugin/(.claude-plugin/plugin.json,.claude-plugin/icon.png,hooks/hooks.jsonwith aSessionStarthook that only runsupdate --notice, a README with a data-handling disclosure, LICENSE, andskills/reachweaver, a generated byte-identical copy of the skill bundle kept in sync byscripts/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/reachweaverthen/plugin install reachweaver@reachweaver.updater.install_kind()reportsplugin; for a plugin the notice names the/plugin updatesteps andupdate --applyrefuses to touch the plugin cache. Passesclaude plugin validate .(one advisory about the root CLAUDE.md). Tests intests/test_updater.pypin manifest versions to__version__. Not yet exercised through a real/plugin installfrom GitHub. -
Skill install and update (
scripts/install_skill.py,reachweaver version,reachweaver update). Stdlib installer for~/.claude/skillsor--project, from the latest GitHub Release (SHA-256 verified) or--from-checkout.update --check, a cached once-a-day--noticethatSKILL.mdruns at session start, and--apply --yes(needs the user’s agreement; atomic swap, previous copy kept outside the skills directory). Newreachweaver/updater.py(github.com HTTPS only, no redirects off GitHub, zip-slip and symlink refusal, strictly-newer check) and.github/workflows/release.yml(on avX.Y.Ztag: 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;acceptedis not consent to publish. Provenance/license are not persisted (deferred protected item). Tests intests/test_media_upload.py; documented indocs/MEDIA-ASSETS.md. -
YouTube analytics-sampling report (issue #22 follow-up; offline only). New read-only
youtube-analytics [--account ID] [--post-id ID]andreachweaver/youtube_analytics.py. It reads stored metric snapshots of published YouTube videos and labels every metricexact,sampled(platform estimate),suppressed,delayed,not_availableorunknown, next to the video receipt fields (video id, watch URL, channel, privacy, upload status from thepublishedaudit event;nullwhen absent). Unknown is never zero, estimates are never summed with exact counts, and snapshots without an observation window are counted apart. Opened withconnect_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 composeshealth(adapter capability, budget, cooldowns, pause, dead letters, credential variable names) withops-metricstotals (claims, publishes, failures, stale sending).queue-healthandops-metricseach covered part of this andhealthlacked the metrics, so the combined view is new. Options:--json,--since,--stale-minutes,--optout,--now. Exit 1 only when the overall level isblocked. It opens the database withconnect_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. Newreachweaver/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-booleandisabledreads 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
approvedraws a fresh 128-bitposts.approval_nonce(new nullable column, additive migration) bound into the approval hash under tagapproval/2(added only when present, so nonce-less hashes are byte-identical). Newreachweaver/ledger.py:claim_sendingappends oneconsumedline for(post_id, approval_nonce)toapproval-ledger.jsonl(mode 0600,O_NOFOLLOW,O_APPEND,flock,fsync, SHA-256 hash chain) next to the database, underBEGIN IMMEDIATE, before the row flips tosending. A restored older approval row, or apublishedrow set back toapproved, is refused withapproval_replay_reapproval_required(oneapproval_replay_blockedevent, no claim) before the identity preflight and any request; a missing, unreadable, wrongly-permissioned, symlinked or chain-broken ledger refuses withapproval_ledger_unavailable. A humanunapprovethenapprovedraws a new nonce and works. Not grandfathered: approvals made before the upgrade have no nonce and are refused withapproval_nonce_missing_reapproval_requireduntil re-approved once (upgrade note).healthreportsapproval_ledger_broken, replayed rows and rows needing a nonce asattention. 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-onlycheck_connectionrequest (theconnect-checkcall class; a Bluesky run reserves onecreateSessionlikeconnect-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 columnposts.approved_identityand bound into the approval hash under tagapproval/id-1(keys added only when set, so every existing and every unbound approval hash is byte-identical).publish --liverecomputes the fingerprint from the current accounts file and refuses a difference withapproval_identity_mismatch_reapproval_requiredbefore the identity preflight and any request;claim_sendingre-checks under its lock (newidentity_fingerprintargument), and a sender that passes none (the Postiz and TikTok runners) fails closed for a bound row. Newapproval-mode --require-identitylatches identity-bound approval (tighten-only, no command removes it): plainapproveis refused withapproval_identity_required, and approvals without a stored identity are refused bypublish --live,schedule,healthand the dry run withapproval_identity_missing_reapproval_required. Existing approvals are not touched by the upgrade; the upgrade path isunapprove,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). Newreachweaver/identity.pyandtests/test_identity_binding.py(25 offline tests, socket-guarded, mocked identity check);publishers.pyis 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). Newreachweaver/sendgate.py.storage.claim_sendingregisters each committed claim in a process-local registry;publishers.publish()andpublish_video()now refuse, before any capability, credential or network step and with a fixedsend_*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, withREACHWEAVER_ALLOW_LIVE=1andREACHWEAVER_ENV=prodset. Claims already enforce the emergency pause, approval hash/MAC, provider cooldown and caps, so direct callers meet the CLI’s conditions.connections.check_connectionrecords what it returns, andpublish()/publish_video()ignore a hand-builtVerifiedConnectionand re-run the identity check. Behaviour changes: a directpublish()/publish_video()call without a claim now raisesValidationError(previously it sent); the CLI and Postiz paths are unchanged. No escape hatch exists; 14 existing suites that callpublish()as a library now import it from the test-onlytests/gate_support.py, which records the same registry entry a claim does. Newtests/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, andtests/test_fault_injection.py(5 offline tests). The fixtures are replayed throughpublish --liveand pin the cooldown source and C1/C2/C3 send class fromratelimit.py,needs_review, and that a new process, a--post-idrun 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, classambiguous, no cooldown). A process death inside the create call (KeyboardInterrupt) leaves the post insending, notneeds_review: it is never eligible again andqueue_healthreports it asstale_sendingfor 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/(202unprocessed upload,206partial poll,200ready poll,429with and withoutRetry-After) replayed by new tests intests/test_mastodon_media.py: the poll stays bounded, a never-ready upload ends inneeds_reviewwith no status request, and a429records a cooldown and is never resent.media-attach --filenow prints aWARNING:when the image carries an EXIF GPS block (media.exif_has_gps, stdlib header parse for JPEG, PNGeXIfand WebPEXIF). 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 isunspecified). The triage report gains aninboxsummary (per category, per platform, actions per category) and the Markdown sheet a category table. New fixturestests/fixtures/engagement/unified-inbox.json(five mocked sources, five categories) andunified-inbox-optout.json, and 19 offline tests intests/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).kindnever 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 intests/test_meta_provider_fixtures.pyfor 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 intests/fixtures/providers/tiktok/(hand-written, not recordings) and 9 new offline tests:ChunkUploadFixtureTests(YouTube 403forbidden/404 on a middle chunk,308withoutRange, final-chunk 500 and308, 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) andCreatorOptionFixtureTests(invalidprivacy_level_optionsandmax_video_post_duration_sec, and an unauditedSELF_ONLY-only creator). They pin that every failure after the first byte isneeds_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.mdgains 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.mddocuments that a Postizlinkedin-pageintegration is accepted although the native adapter refuses organization Pages, and records refusing it as a pending owner decision (it would changetests/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 andPOST_ID,unapprove,cancel,pause/resume,reconcile,connect-check,approval-mode), the exit codes (0sent,2blocked,3needs review), emergency stop, teardown and receipt recording. Linked from the README, the quickstart and the first-live-pilot guide. New.github/CODEOWNERSnames@MSKazemifor the two registries andaccounts.py,storage.py,publishers.pyandcaps.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 401ExpiredTokenandAuthenticationRequired, createRecord 401InvalidToken, 429 with and withoutRetry-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 noembedis 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.pyruns 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. Newscripts/pilot_benchmark.pyruns that workflow for N synthetic posts against a mocked Bluesky layer and reports local CPU seconds per step, labelledsyntheticandnot_pilot_evidence. Documented indocs/FIELD-PILOT-PROTOCOL.mdsection 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.mdkeeps 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-metricsgains the gaugereachweaver_needs_review_credential_posts{account_id,platform,cause}, countingneeds_reviewposts 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.mdlinks 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 missingw_member_social(introspection refusal, userinfo 403, post-claim 403, unverified send) and that no scope list or secret is echoed;tests/test_linkedin.pypins the 0/9/10/11/12-month boundaries.docs/LINKEDIN.mdnow gives future/malformed/too-old versions their own row (linkedin_version_invalid, previously mislabelledlinkedin_version_unsupported) and cites the registry constraint. No code or registry change. -
Source hostile-input fixtures and the exact
--sourcescaveat (issue #9 slice). New fixturesfeed-malformed-items.xml,feed-injection.xmlandchangelog-injection.mdplusHostileFixtureTestsintests/test_sources.pypin 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.pynow pins the private-link caveat: without--sourcesnothing is detected, a config with no private repository refuses nothing, and a missing or malformed--sourcesfile 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_mockstatsmaps an invented provider whose definitions differ from Bluesky/Mastodon (plays_3sto views,link_tapsto clicks,bookmarksto saves;unique_viewersandreactions_alldeliberately unmapped). Counts below the privacy threshold are stored assuppressedand the small number is dropped; withheld, not-collected and pending markers becomesuppressed,not_available,delayed; malformed values stay unknown, never 0. Fixtures intests/fixtures/providers/mockstats/threshold_cases.json.report --format markdownadds a one-linesuppressedlegend 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): withtty_hash_prefixset,approveandpublish --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: withREACHWEAVER_APPROVAL_SECRET(read viaaccounts.credential, environment orLoadCredential=, at least 32 characters), each approval also stores an HMAC-SHA256 tag over the post id and payload hash in the new nullableposts.approved_payload_mac. Claim, pre-claim gate,schedule,calendar-plan --applyandhealthverify it. A forged, copied or unkeyed row is refused withapproval_mac_mismatch_reapproval_requiredorapproval_mac_missing_reapproval_requiredand 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_unavailablerefuses every grant and claim. Unkeyed approvals keep working until the key is first provided. Dry run,showandops-metricsread 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/ttymocked, plus one real local pseudo-terminal). Owner decision 7:.claude/settings.jsonalso denies Claude Codeapproval-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.pypins the combined gate order: a failed D6 confirmation stops before the #11 BlueskycreateSessionreservation and the #14 LinkedIn introspection request, andclaim_sendingenforces the D7 tag and a #11 shared cooldown scope together. -
Shared Postiz cooldowns and a Bluesky
createSessionbudget (issue #11 follow-up; owner-approved 2026-10-09 with every recommended decision). A new optionalaccounts.jsonfield,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 insideclaim_sending. Postiz counts create-post requests per organization: the throttle key isorg.id + '_posts'in postiz-app commit4c5d42a, read 2026-10-09. No per-instance scope is added. A newcooldown_scope_memberstable, created idempotently on open, records the declared membership at each live run, so a dry run shows shared cooldowns without loading accounts. BlueskycreateSessioncalls (2 per live publish, 1 perconnect-check --online) are reserved in a local counter before any request. ReachWeaver refuses withbluesky_create_session_budget_exhausted(worker reasonsession_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 byprivacy-purge.healthshows 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-encodedclient_id,client_secret,token; token introspection, accessed 2026-10-09) inside the identity preflight, before the claim. The send is refused unless the token isactive, a3Lmember token, issued to the configured app, carriesw_member_social, and has anexpires_atmore than 1 hour away. HTTP errors fail closed and are reported as an identity preflight failure, so the post staysapprovedand nothing is sent.connect-checkthen reportswrite_permission: verified. A missing or more-than-one-day-offauthorization_expires_atprints 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_postre-checks that result offline before the write. New credential names{prefix}_CLIENT_IDand{prefix}_CLIENT_SECRETare added toaccounts.CREDENTIAL_SUFFIXES, so theenv_prefixoverlap rule always covers them. They are required (and listed bydoctor,credentials-auditandmissing_credentials) only after the opt-in,_CLIENT_SECRETvalues are redacted byprivacy.redact, and the token, secret, scope list and raw response are never printed, logged or stored. Without the opt-in, behaviour is unchanged, anddoctorprintsINFO: linkedin_write_scope_unverifiedto 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]andmedia-detach POST_IDchange a Mastodon draft’s attachments offline. Newreachweaver/attachments.pyaccepts 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 fieldsmax_media_attachments,image_size_limitandmedia_description_limitcan only lower these limits. Attachments live in a new nullableposts.media_jsoncolumn thatstorage.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.approverefuses 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/mediawith a stdlib multipart body per image, at most 8 polls ofGET /api/v1/media/:idwhile it answers 206 (47 s total, the emergency pause checked before each wait), thenPOST /api/v1/statuseswithmedia_ids[]and the existingIdempotency-Key; the receipt’s media ids must match. The write marker is set before the first upload, so any later failure goes toneeds_reviewand is never resent. Gate: the capability registry entry may list an optionallive_media_formats; the bundled registry lists none, sorequire_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.showlists 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-purgenow also clearsmedia_jsonwhen 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.pyimplements the official Instagram API with Instagram Login container flow ongraph.instagram.com/v25.0:POST /{ig-user-id}/media(image_url,caption,alt_text, JSON withAuthorization: Bearer),status_codechecks 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 fromGET /{media-id}(usernameandmedia_typemust match; the permalink is kept only in the observedinstagram.com/p/...shape). Before the claim it checksGET /me(user_idandusernamemust equalig_user_idandhandle;account_typemust be Business or Media_Creator) and the read-onlycontent_publishing_limitquota (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 newreachweaver/remote_media.pyand the commandsmedia-attach POST_ID --url HTTPS_URL (--alt T | --alt-file F) --onlineandmedia-detach POST_IDattach 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, urlper attachment; text-only posts keep theapproval/1hash byte for byte), stored in theposts.media_jsoncolumn shared with #13 as one attachment model: each record carriessource: "local"(Mastodon file, withpath) orsource: "url"(Instagram, withurl), andmedia-attachtakes--filefor Mastodon or--url ... --onlinefor Instagram. A live send downloads the URL again before the claim (changed bytes: BLOCKEDapproval_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_postadds the official caption limits (tighten-only), the token is read only from{env_prefix}_ACCESS_TOKEN(newCREDENTIAL_SUFFIXES["instagram"]), anddoctornotes the draft status and token expiry. The capability registry gains an unusedlive_mediaadapter value and optionallive_media_formats; the Instagram entry andpolicy.SUPPORTED_LIVEare unchanged, sopublish --live,connect-check --onlineandpublish()refuse Instagram before any credential read.tests/test_instagram.pyproves that and runs the happy path, image swap, 400/401/429/500, wrong or personal account, quota, pause and post-writeneeds_reviewcases 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.pyimplements creator-inbox draft upload as the first mode:POST /v2/post/publish/inbox/video/init/(FILE_UPLOAD), chunkedPUTwithContent-Range(206, then 201) andPOST /v2/post/publish/status/fetch/polling. Because the creator finishes and posts the video in the TikTok app, a delivered upload moves toneeds_reviewwith reasontiktok_creator_inbox_delivered_not_publicand is never recorded aspublished, 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 livecreator_infoprivacy_level_options, so it never defaults to PUBLIC; interactions are off by default. A post becomespublishedonly when TikTok returns a public post id. Identity is checked before the claim:GET /v2/user/info/open_idanddisplay_namemust match the config.mark_write_started()runs after init (which creates no content) and before the first chunk, so every later failure goes toneeds_reviewand is never retried. The new offline commandsvideo-attach/video-detachbind one local MP4 to a TikTok draft (posts.video_json, migrated withADD COLUMN). Approval then binds the record, including the file’s SHA-256, underreachweaver.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 ofreachweaver/video.py(kind: reachweaver_video, coreplatform, sha256, bytes, mimeand the parsed headers, plus a boundplatform_optionsobject, 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 byshow,check,approveand the dry run, and before the claim, and only the matching in-memory bytes are uploaded (256 MiB cap). The video is validated bymedia.probe_bytesagainst TikTok’s documented limits and fails closed on any unknown value. The token is read only from{env_prefix}_TIKTOK_ACCESS_TOKENviaaccounts.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_inboxandreachweaver_video_direct_poststayoffandpolicy.SUPPORTED_LIVE_VIDEOis empty.tests/test_tiktok.pyproves 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.pyimplements the official resumablevideos.insertflow: a session POST tohttps://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status(X-Upload-Content-Length,X-Upload-Content-Type), theLocationsession URI (kept on the documented endpoint, never printed, logged or stored), 8 MiBPUTchunks withContent-Range, a308 Resume IncompleteRangecheck per chunk, and a201receipt whose channel, privacy, made-for-kids and upload status must match. Before the claim,channels.list?part=id&mine=truemust return exactly the configuredchannel_id. New offlineyoutube-attach/youtube-detachcommands bind one local MP4 (validated bymedia.py; symlinks and QuickTime refused) to a YouTube draft. Approval payloadreachweaver.storage.approval/video-1(the draft’sapproval/2, renamed at integration; nullableposts.video_jsonadded bymigrate(), shared with #23 as one video record,reachweaver/video.py, with YouTubeplatform_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(defaultprivate; the API default is public) and a required humanselfDeclaredMadeForKidsanswer; the description is the post text. Posts without a video keep theapproval/1payload byte for byte. The file is re-hashed atapprove, 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, partialRange, pause between chunks, receipt mismatch, including apublicrequest returnedprivateby an unverified project) is_uncertain, goes toneeds_reviewand is never resumed or resent. Opt-out checks now also cover the title and tags.policy.validate_postadds the official 5000-byte, no-</>description rule (tighten-only). The registry gains areachweaver_video_uploadswitch (the draft’slive_videoadapter value, folded at integration into the shared video switches with #23’s; a live upload still needsvideo: api_directand sourced rate-limit facts), which must agree with the new, emptypolicy.SUPPORTED_LIVE_VIDEO; the bundled YouTube entry only gains that switch set tooff, sopublish --live,connect-check --onlineandpublish_video()refuse it before any credential read. The token is read only from{env_prefix}_ACCESS_TOKEN(no refresh flow); YouTube accounts withoutchannel_idstay manual-export metadata and expect no credential.tests/test_youtube.pyproves 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.inserthas 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 thevideos.insertpage, 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_targetcolumn, whichstorage.migrate()adds. It goes through the sameapprove(payload hash),publish --live, claim and receipt orneeds_reviewlifecycle as any post; there is no second pipeline.- Drafting.
draft --account ID --text|--file … --reply-to ITEM [--online] [--override-flagged LABEL]. Without--onlineit only plans; with it, the newreachweaver/replies.pyverifies the parent through unauthenticated, redirect-free GETs: BlueskygetPostThreadwithparentHeight=20, Mastodonstatuses/:idand/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 assuspicious,harassment,spamor moderation-labelled need an exact human--override-flaggedlabel, which is audited asreply_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_idand visibility. A changed target therefore needs re-approval. Ordinary post hashes are unchanged.approve,checkandpublishre-check the opt-out list, the account match and the visibility. - Sending. Bluesky sends
record.reply(root and parent strongRefs, per theapp.bsky.feed.postlexicon). Mastodon sendsin_reply_to_idwith the stricter of the parent’s and the account’s visibility, never wider. A receipt with a mismatchedin_reply_to_idgoes toneeds_review. Replies share the per-account rolling budget. - Gate. The registry gains an optional
reachweaver_reply_adapterfield.capabilities.require_reply_capabilityrefuses every reply claim while it isnone, and the bundled registry shipsnonefor both platforms. - Counting and docs.
reportcounts replies under a separatereplieskey,ops-metricsaddsreachweaver_reply_posts, anddata-purgeminimizes 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 BlueskycreateSessionbudget 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.
- Drafting.
-
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 passessources-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 needsREACHWEAVER_GITHUB_TOKEN, and the API must confirm the repository is private. Every signal from it carriesvisibility: "private",private: trueandpublishable: false. Text output withholds its summary, diagnostics drop its item URLs, and--mark-seenstores its ID only.signal-briefmarks its evidencesource_visibility: "private",publishable: falseand scaffolds nocta_url.validate_campaign(campaign-check, -preview, -stage) refuses that evidence until a human sets"publishable": trueon each record. A link into a private source repository is never public: campaign links,campaign-preview --utmand the newutm-link --sources CONFIGrefuse it, and staging never stores it assource_url.data-exportandcampaign-exportredact private evidence excerpts and URLs as[REDACTED:private_source]. A newtoken_scopereport flags classic-tokenX-OAuth-Scopesthat can write (for examplerepo). For fine-grained tokens the scope is reported as unknown, never as read-only. The new module isreachweaver/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),checkandapprove(everything the post publishes, plus its source URL) refuse a link into avisibility: privaterepository (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.pyimplements 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 fromGET /{media-id}that must name the configured profile. Before the claim it checksGET /me(idandusernamemust equalthreads_user_idandhandle) and the read-onlythreads_publishing_limitquota (250 API-published posts per 24-hour moving period).mark_write_started()precedes the container POST, so every later failure is_uncertain, goes toneeds_reviewand is never retried. The token is read only from{env_prefix}_ACCESS_TOKENviaaccounts.credential, and that suffix now feeds the env_prefix overlap rule andcredentials-audit.validate_postadds the official Threads 500-character (emoji as UTF-8 bytes) and 5-link limits (tighten-only).doctornotes the draft status and token expiry. The registry entry andpolicy.SUPPORTED_LIVEare unchanged, sopublish --live,connect-check --onlineandpublish()still refuse Threads before any credential read.tests/test_threads.pyproves that, and runs the happy path and the 400/401/429/500, wrong-account, quota and post-writeneeds_reviewcases 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 beforethreads_publish(a pause stops it withthreads_container_paused_not_public_uncertain, needs_review, never resent), and the newcaps.BUILTIN_PROVIDER_CAPSholds 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.pypublishes throughPOST /{page-id}/feedwithmessage. When the approved text contains anhttps://URL, the first one is also sent aslink, 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;doctorwarns 90 days ahead and preflight refuses after that date. Before the claim,GET /memust return the configuredpage_id(a User token or another Page’s token fails), andGET /{page-id}?fields=id,namemust matchpage_idandpage_name.mark_write_started()immediately precedes the single feed POST, so every later failure is_uncertain, goes toneeds_reviewand is never retried. The receipt is the returned{page-id}_{post-id}, andpermalink_urlis stored only for HTTPSwww.facebook.com. The token is read only from{env_prefix}_PAGE_ACCESS_TOKENviaaccounts.credentialand sent only asAuthorization: Bearer, never in a URL or a body. That suffix now feeds the env_prefix overlap rule andcredentials-audit. Errors are mapped by HTTP status only, because Meta documents throttling codes 4/17/32/613/80001 without statuses. The registry entry andpolicy.SUPPORTED_LIVEare unchanged, sopublish --live,connect-check --onlineandpublish()still refuse Facebook before any credential read.tests/test_facebook.pyshows 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 andneeds_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_CAPSholds 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 unusedprivacy.structured_log(newreachweaver/worker_log.py). Fixed events:run_start,gate_refused(live_gate,emergency_pause),post_skipped(fixed reason codes such asnot_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) andrun_end(counts, exit code). Each line has a sortablerun_id, aseqand a UTC ISOts. No post text, handles, credential names or values, provider error text or URL queries are logged, and every value passesprivacy.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-fileappends to an owner-only 0600 file.tests/test_structured_logs.pyshows that database rows, mocked provider requests, stdout and exit codes are identical with and without--log-jsonin a dry run and in a mocked live run. AWorkerLogLeakTestscase was added totests/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, optionalconsent,notes) is ReachWeaver’s own interchange format; no real platform export has been verified against it, andexamples/audience-activity.example.csvis synthetic. The newreachweaver/audience.pyrejects duplicate (account, weekday, hour) rows, mixed timezone/source/as_ofper 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 andunknownhours stay missing and are never zero-filled. Rows are normalised to a UTC hour of week using theas_ofweek’s offsets: a DST gap hour isnonexistentand not ranked, and a fold uses its first occurrence. Every proposed entry carriesprovenance(first_party_audience_activity, source,as_of, rank,stale,verified_by_reachweaver: false), and a warning appears whenas_ofis older than the threshold. Without the flag, output is byte-identical (golden test). The planner stays advisory; approval,--applyand 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 --scorecardprints it as a table and--scorecard --jsonas JSON. The release gate’s newreadinesscheck 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 acceptslive_receipt_verifiedonly 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_limitsgains optionalaccessed(ISO date),facts(one exactlimitperwindow_secondsin aunitfor ascope) andnot_published(limits the official page does not state). Bluesky: 5,000 write points per hour and 35,000 per day per account,createSession30 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_registryrequires sourcedfactsfor everylive_textadapter 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_reviewby class, the dead-letter total, stalesendingrows, 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--outreplaces the file atomically (temp file and rename, mode 0644) for node_exporter’s textfile collector.reachweaver/ops_metrics.pyuses new SELECT-onlystoragehelpers (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.pyandOpsMetricsLeakTestsintests/test_security_regressions.py. Approval, dry-run, emergency stop, credentials, reconciliation, opt-out and cooldown behaviour are unchanged. -
Read-only
engagement-fetchof public replies (issue #17 slice).reachweaver/engagement_fetch.pyreads the public replies under posts ReachWeaver recorded as published: Blueskyapp.bsky.feed.getPostThreadon the public AppView and MastodonGET /api/v1/statuses/:idplus/context, both unauthenticated GETs with no redirects. It writes a NEW owner-only (0600)reachweaver.engagement-export.v1file thatengagement-triagereads 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). Asubtitles[]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 thansubtitle_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. Apreserve_termsentry, 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, so1,500,1.500and Persian digits match. The JSON report addssubtitle_translationsper asset. See docs/MEDIA-ASSETS.md. -
Trustworthy metric status, privacy thresholds and report formats (issue #15 slice).
metricsandimport-metricsaccept 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) orestimated:N. A non-numeric value is never coerced to 0. Before this,import-metricscrashed onsuppressedwithinvalid literal for int(). Statuses are stored in a new nullablemetric_snapshots.metric_statusJSON column, whichstorage.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|csvwas added; the default stays the JSON envelope. CSV cells that start like a formula are neutralised (OWASP CSV Injection). New read helpersstorage.list_posts,published_posts,snapshots_for,latest_snapshotandmetric_statesreplace the raw SELECTs inreport,baseline_eval,timing_analysis,experimentsandattribution, with no change in their results. Canonical schema 1.2 adds the optionalAnalyticsSnapshot.metric_status; 1.0 and 1.1 documents stay valid, andschema-exportemits the field. See docs/ATTRIBUTION.md and docs/SCHEMAS.md. -
Repetition checks against the local posting history (issue #10 slice).
campaign-checkandcampaign-previewaccept--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 existingclaims.jaccardand the same 0.6 threshold. A match is ahistory_near_duplicatewarning naming the post id, status and date, never the earlier text.history_frequencywarns 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 samecampaign_idare skipped and counted. The database must already exist and is opened read-only through the newstorage.connect_readonly(mode=ro,query_only) and SELECT-onlystorage.posting_history; a missing path fails with exit 2 and is not created. The global--dbstays inert for these commands, and without--history-dbreports 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-campaignnear_duplicatecheck). 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.jsonmarks Bluesky and Mastodonlive_receipt_verified(one approved text post each, 2026-10-11, receipts indocs/receipts/);docs/RELEASE-PLAYBOOK.mdandreferences/skill-evaluation.md(both skill copies) now say so, andtests/test_live_receipts.pychecks 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 aremetrics-fetch --onlinepublic counters only (views, impressions and clicks unknown), and the media column namesmedia-check,media-storyboardandmanual-exportwhere the code covers each platform.tests/test_platform_matrix_docs.pyparses the delimited tables and fails when an adapter, Postiz, native-scheduling, receipt, media-tool ormetrics-fetchcell disagrees withplatform_registry.json,distribution_registry.json,policy.SUPPORTED_LIVE,postiz.PROVIDERS,media.CONSTRAINTS,storyboard.py,manual_export.MANUAL_PLATFORMSormetrics_fetch.SOURCES. platform_registry.jsonmetrics_accessfor Bluesky and Mastodon described manual import only; it now names the read-onlymetrics-fetch --onlinepublic counters, with the getPosts lexicon and Mastodon Account entity added tosources.- ADR-0002 decision 8 records the dynamic-dependency guard in
scripts/check_runtime_deps.py:[project] dynamicnamingdependencies/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.mdlists eachcredentials-auditfinding code with the owner’s fix, the owner-onlyREACHWEAVER_ENV=prodlive gate, systemdLoadCredential=and theinit/privacy-status/data-export/data-purgelifecycle;references/measurement.mdaddslanding-check;references/publishing.mdaddsschema-exportandcancel;references/skill-evaluation.mdaddsrelease_gate.py --scorecard; SKILL.md routes these requests and showsinit,credentials-audit,engagement-fetch,ops-metrics,campaign-check --history-dbandcalendar-plan --audience-activity.tests/test_skill_contract.pynow 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 enabledlive_textadapters 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, thereachweaver_reply_adapterregistry field and the shared Postiz workspace cooldown and BlueskycreateSessionbudget, 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 ondraft,media-attach,youtube-attach,checkandapproveruns 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
doctornames its permissions, registry descriptions refreshed (private #12, #14, #19, #20, #21, #43; no adapter enabled).doctoron a Facebook account now printsINFO: facebook_write_scope_unverifiednamingpages_manage_posts,pages_read_engagementandpages_show_listas unverified until checked live, and aSETUP:line whenpage_idorpage_nameis 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.mdgains the Facebook Page credential row (<P>_PAGE_ACCESS_TOKEN), thepage_id/page_namefield row and Facebook in the Meta revocation step;tests/test_account_onboarding_doc.pyfails when any platform with a credential variable inaccounts.CREDENTIAL_SUFFIXEShas no row. Official Meta pages were re-fetched withcurlon 2026-10-15 and every quote matched withgrep: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) anddocs/INSTAGRAM.md(IG User Media and Instagram Login re-read),docs/FACEBOOK.md.reachweaver/platform_registry.jsondescriptive 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_listin 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_dateandpolicy.SUPPORTED_LIVEare unchanged;tests/test_live_receipts.pypins the note. Mirrored indocs/PLATFORMS.md.
Changed
Section titled “Changed”- A public YouTube upload that is still processing says so (private #22). When an upload approved as
publicreturnsuploadStatus=uploadedrather thanprocessed, theNOTEline afterPUBLISHEDnow adds that processing is not confirmed yet and how to check: YouTube Studio, or a latervideos.listread withpart=status(Video resource and videos.list docs, checked 2026-10-15). ReachWeaver makes no such read itself. Text only (youtube.visibility_notegains an optionalupload_status): the receipt check, send, retry, pause and reconciliation are unchanged. Tests: three new intests/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 wereunverified. They are nowsourcedfrom 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 stayunverifiedand 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. reportaggregates exact values only and no longer prints a fabricated 0 (issue #15). A metric with no exact value on any published post is nownullin JSON (it was 0) andunknownin Markdown and CSV. Each platform gains ametricsblock with value,exact/totalcoverage, counts by status (a published post with no snapshot counts asunknown) and a separately labelledestimatedblock that is never added to the value.import-metricsis 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 thereport --format markdownoutput, and its fixture imports Bluesky and Mastodon views and impressions asnot_available.
ops-metrics --outcould overwrite the state database (issue #26, review F1).--outatomically replaced any path, so--out state/reachweaver.sqlitereplaced the database with metrics text, set it to mode 0644 and printeddatabase unchanged.ops_metrics.write_textfilenow refuses, with exit 2 and nothing written, the database itself (same path, same resolved path or a hard link, viaos.path.samefile), any symbolic link, a directory, and an existing file whose name does not end in.promor.json; a new file may have any name. Tests:OutTargetTestsintests/test_ops_metrics.py.- Read-only commands no longer create or migrate the database (issues #26, #17, review F1).
ops-metricsandengagement-fetchopened the database withstorage.connect, which creates the file and its directory and runsALTER TABLEmigrations. Both now usestorage.connect_readonly(SQLitemode=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-metricsnames the missing columns (newstorage.schema_gaps) and exits 2 without migrating, andengagement-fetchreads it unchanged (it needs only the originalpostscolumns).metrics-fetchwrites snapshots and still usesconnect. Tests:ReadOnlyDatabaseTestsintests/test_ops_metrics.pyand the read-only cases intests/test_engagement_fetch.py. ops-metrics --since 999999dcrashed with anOverflowErrortraceback (issue #26, review F1). The relative amount is now bounded by the 3650-day limit before any date arithmetic,--nowmust lie between 1970 and 9998, andstorage.parse_utcturns an out-of-range date such as0001-01-01T00:00:00+01:00into a ValidationError (exit 2) for every command that parses a date.engagement-fetchcut 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 asjohn.doe@exampor 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-fetchexport fail (issue #17, review F1).write_exportrejected 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 asreply_without_author_identity_skipped. - A credential with a control character could leak into
ERROR:output (issue #5, review F1). A$CREDENTIALS_DIRECTORYfile ending in\r\nkept its\r,http.clientrefused the header withValueError("Invalid header value b'Bearer <token>\\r'"), and the CLI printed that message, token included.accounts.credentialnow strips exactly one trailing\nor\r\nfrom 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,publishersturns aValueError,http.client.InvalidURLor unknown-scheme error raised while building or sending a request into the fixedPublishError("invalid_request")(and a laterhttp.client.HTTPExceptionintoinvalid_api_response_uncertain), with the original message dropped; the CLI’s top-levelERROR:line now passes throughprivacy.redact. Ambiguous-send classification is unchanged: a failure aftermark_write_startedis still C3ambiguousand goes toneeds_review. Tests:ControlCharacterCredentialTests,ControlCharacterCliTests,RequestBuildErrorTestsintests/test_hardening.py.
Not done
Section titled “Not done”- 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).
