Architecture and state protocol
Owned repos / release notes / research / website / first-party analytics | vClaude Code skill (/reachweaver): brand + audience + source-checked claims | vVersioned campaign JSON -> offline validator -> account/brand allowlist | +--> manual draft package vAtomic/idempotent SQLite draft staging (never authorizes a send) | vTrusted human reviewer: claim check / rights / account / timing / approve | vLocal CLI + owner-only sqlite state -> explicit online account identity preflight | vScheduled due claim (BEGIN IMMEDIATE), optionally exact --post-id | +--> Dry-run default; global emergency pause | +--> Live sender (explicit flag + environment opt-in + production marker) | +--> Bluesky / Mastodon / Telegram / LinkedIn-member text APIs +--> Optional Postiz backend (acceptance = needs_review) +--> Registry-gated, OFF until a live receipt (mock-tested only): | Threads text, Facebook Page text/link, Instagram image, | Mastodon images, Bluesky/Mastodon replies (registry switch), | YouTube / TikTok video (SUPPORTED_LIVE_VIDEO + registry switch) +--> Unsupported: manual-only, no fake success | v Remote receipt or needs_review (never blind retry) | v Platform-native analytics export -> snapshots -> coverage-aware report | v Matched-age paired directional evaluator -> next hypothesisOne ranked, read-only next-action report (Issue #6)
Section titled “One ranked, read-only next-action report (Issue #6)”reachweaver/suggest.py composes existing validated profile/post audit summaries, engagement.triage_engagement rule labels, attribution.seo_conflicts, growth.next_hypotheses, and (only with --include-db) storage.queue_health / storage.report. CLI parsing resolves optional files and uses storage.connect_readonly (SQLite mode=ro + query_only=ON). No storage.connect, migrations, HTTP requests, authorization or social side effects are allowed.
Priority is static workflow urgency, not a platform/ranking score: P0 remote uncertainty/opt-out, P1 blockers, P2 owner-confirmed profile/creative fixes or measurement coverage, P3 preregistered experiments. The report withholds raw commenter text/identities and private SEO URLs; account-level snapshot sources are not conflated with platform-level aggregated SQLite metrics. Missing observations are unknown, and a recommendation is never execution authorization. See next actions.
reachweaver/digest.py renders a suggest result (and, only with --include-db, the storage.report envelope through report_format.render_markdown) as one Markdown file. The CLI loads inputs through the same branch as suggest. digest.write_digest is its only side effect: it creates a NEW file with privacy.write_private_file (mode 0600, O_EXCL, O_NOFOLLOW) and refuses an existing path or symlink. No network, no storage.connect, no send path; free text is escaped with report_format.markdown_cell and identifiers are reduced to a safe charset. See next actions.
Attested normalized native CSV evidence overlay
Section titled “Attested normalized native CSV evidence overlay”reachweaver/profile_metrics_csv.py loads a bounded, owner-authorized local CSV only when the operator explicitly supplies --metrics-csv --owner-authorized to post-audit or suggest. It does not call provider APIs, perform OAuth, open SQLite or write files. The strict schema joins only to accounts already in the profile snapshot; duplicate post keys are refused and metric source labels are operator assertions, not verified provider signatures. Dates are timezone-aware and normalized to UTC; missing metrics stay missing. The resulting in-memory posts go through the existing matched-age post-audit logic. See CSV profile analytics.
Independent offline social profile/post audits
Section titled “Independent offline social profile/post audits”reachweaver/profile_audit.py and profile_audit_registry.json are a local/read-only sidecar. The commands profile-audit and post-audit execute before any SQLite connection and never import authorization credentials, write account metadata, stage drafts, schedule posts or invoke a social API. The strict versioned owner-snapshot contract rejects credential-like keys/values, unknown fields/metrics, invalid booleans, negative/nonfinite counts and overly large inputs. User-supplied fields are unverified observations.
A registry rule defines each network’s applicable field checklist, officially sourced platform capabilities or editorial hypotheses, format experiments, and one action/exposure metric pair. The profile output exposes checklist coverage and produces a completeness percentage only when enough fields are known; it is not a provider algorithm or a traffic prediction. The post audit only compares native metrics inside same platform/account/format/source and matched post age; missing denominators, source or dates are excluded rather than zero-filled. It never runs experiments or blindly promotes the “winning” idea. Human-only channels remain human-only.
reachweaver/creative_preflight.py (creative-preflight) and reachweaver/creator_amplification.py (creator-match, creator-kit, creator-coverage) follow the same pattern: they dispatch before any SQLite connection, read one bounded local JSON file, use no network, credential or environment variable, and generate no wording for human-only destinations. Only creator-kit writes, a NEW 0700 folder through privacy.write_private_file. See creative preflight and creator amplification.
reachweaver/social_web.py (social-web-plan) reads social_web_registry.json (sourced protocol, federation, bridge and identity facts keyed by distribution_registry.json platform ids), the public accounts metadata and an optional owner-written setup file, and returns protocols, a canonical-home hypothesis, duplicate-reach warnings, an identity checklist and gaps. It imports no storage, publisher, credential or network module; bridge_duplicate_risk is advice for planning code and is not connected to sending or the repeat guard. See OPEN-SOCIAL-WEB.md.
reachweaver/platform_risk.py (platform-risk) reads platform_risk_registry.json (sourced risk flags per platform, the TikTok US market notice, owned-channel ids and the editorial concentration bands, validated by validate_registry) and the published posts, metric snapshots and shared_material rows through storage.connect_readonly. Each post contributes one value: baseline_eval.matched_snapshot at the matched age, else its latest exact non-windowed snapshot; a scope with an unmeasured platform falls back to post counts. It returns HHI-based concentration overall, per account and per persona, plus hypotheses; --site/--setup call social_web.build_plan for the canonical home (read-only). market_notices is a pure helper that calendar-plan and distribution-plan call after planning to attach a sourced flag; it changes no slot or ranking. No network, queue or send path. See PLATFORM-RISK.md.
reachweaver/review_evidence.py (eu-review-evidence) lists the approval evidence of published posts: row columns selected only when PRAGMA table_info shows them (an authorship column is read under any of a fixed set of names), the fixed confirmation= token of the approve audit event, ledger.audit and the ledger entries read through ledger._problem (the ledger’s own O_RDONLY open and hash-chain check; nothing is appended or repaired). The database is opened with storage.connect_readonly. The Art. 50(4) wording comes from ai_disclosure_registry.json (secondary source). See AI content labels.
reachweaver/voice.py (voice-profile, voice-check) computes plain per-post statistics (lengths, sentence length, emoji, hashtags, mentions, links, first-person rates, punctuation, line breaks and lists) and aggregates them into median, quartiles and 10th/90th percentiles, overall and per platform (5+ posts), plus habit shares, openings and recurring words not in a small built-in common-English list (3+ posts). Below 10 posts it returns insufficient_sample. write_profile writes a NEW 0600 file through privacy.write_private_file; no post text, ID or link is stored. Posts come from a bounded local file or, with --from-db/--post-id, from storage.connect_readonly (mode=ro, query_only, never created). voice-check compares a draft with the profile (outside p10-p90 is warn) and with reachweaver/voice_tells.json, a versioned heuristic style list (phrases matched on whole words, regex patterns, structural thresholds; not an AI detector). A tell found in 20%+ of the owner’s posts is downgraded to info. Findings never block and the command exits 0; it calls no send, approval, scheduling or network code. creative-preflight --voice-profile reuses check_text per candidate. The CLI branch returns before the connect block. See voice check.
reachweaver/ai_labels.py (ai-label-check) reads reachweaver/ai_disclosure_registry.json (schema reachweaver.ai-disclosure.v1): per platform (YouTube, Instagram, Facebook Pages, Threads, TikTok, LinkedIn, X) a decision for realistic AI-made images and video, for non-realistic media and for AI text, the native control, the API field the repo already uses (status.containsSyntheticMedia, post_info.is_aigc), whether the platform reads C2PA and the stated consequence, each citing a source id with URL, exact quote and checked_on; plus the EU AI Act Art. 50 entry (sources marked secondary) and a pointer to china.LEGAL["ai_label"]. validate_registry rejects a decision that cites no source actually read, an unverified platform answering anything but unknown, and EU sources not marked secondary. Inputs are a draft, variant or campaign file or, with --post-id, one post read through storage.connect_readonly; media flags come from a media manifest (realistic_synthetic, provenance.created_by) or the stored media_json/video_json (realism is never stored, so tool-made media is unknown). Per target the worst of the platform, China and optional EU reasons wins (required, unknown, recommended, not_required). Advisory only: exit 0 after a check, SEND_PATH = None, and it is not wired into approval, check or the send path. See AI content labels.
See profile and post audits, analytics protocol and user privacy. Do not add online reads to these offline commands without a dedicated security review and owner-approved scopes.
Boundaries
Section titled “Boundaries”reachweaver/policy.py: platform catalogue, conservative text lint (placeholders, length, control characters, bidi overrides/isolates and invisible format characters), URL origin guardrails.reachweaver/inputs.py: reading the owner’s local JSON files (read_json_file,loads_text: a leading UTF-8 BOM is accepted, nesting too deep for the stdlib decoder becomes aValidationError) and comma lists in repeatable options (split_list_args, used byshareandaccount-kit). No network, credentials or state.cli.main()is the last resort for what escapes a command:sqlite3.Error(unreadable, corrupt or locked database),RecursionErrorandOverflowErrorend in oneERROR:line and exit 2; a closed stdout pipe exits 141 quietly.reachweaver/accounts.py: schema for public account metadata, theenv_prefixoverlap rule (env_prefix_conflicts, enforced byload_accounts) and the single credential accessorcredential/require_credential: environment variables, or only$CREDENTIALS_DIRECTORY/<NAME>files when systemdLoadCredential=sets that directory (issue #5). See account and credential hardening.reachweaver/storage.py: SQLite posts, event log, snapshots, emergency settings. POSIX state directory/file permissions are enforced (0700/0600; symlinks rejected); writes are local and persistent.BEGIN IMMEDIATEatomically claims sending eligibility; concurrently scheduled clients cannot claim the same post twice.migrate()runs insideconnect()and only adds missing nullable columns (metric_snapshots.window_start/window_end, and the approval binding columnsposts.approved_payload_sha256/approved_by/approval_channel, plus the #7 D7posts.approved_payload_mac,posts.media_jsonfor image attachments (#13, #20)posts.video_jsonfor the one video record (#22, #23) andposts.reply_targetfor a reply (#17)) to an older database.reachweaver/storage.pymetric snapshots (#15): an exact count lives in its INTEGER column;suppressed,not_available,delayedandestimated:Nleave it NULL and are recorded in the nullable JSONmetric_statuscolumn (added bymigrate(), idempotent). Integer-column readers therefore see exact values only. Read helperslist_posts,published_posts,snapshots_for,latest_snapshotandmetric_statesreplace raw SELECTs inreport,timing_analysis,baseline_eval,experimentsandattribution.report()aggregates exact values only:nullat zero coverage, per-metric coverage and status counts, estimates in a separate labelled block.reachweaver/report_format.py: pure renderer forreport --format json|markdown|csv. JSON keeps the existing envelope; Markdown prints exact values, incomplete coverage by status and estimates as separate tables (escaping pipes and HTML); CSV prints one row per platform and metric and prefixes formula-like cells with'(OWASP CSV Injection).reachweaver/sendgate.py(wave 9 item 3, owner-approved 2026-10-10; proposal): the send gate for direct Python callers.storage.claim_sendingregisters each committed claim (post id, account, platform, content hash, media hash list, caps, and the bound send-options fingerprint, whichconsumecompares with the account entry it is given) after COMMIT;publishers.publish()andpublishers.publish_video()callsendgate.consumeas their first statement, before the capability registry, credentials or any request. A missing, mismatched, reused or stale (15 minutes) claim, a claim made without caps in the CLI’s range (1..10 per 24 h, gap of 15 minutes or more), or a closedREACHWEAVER_ALLOW_LIVE/REACHWEAVER_ENV=prodgate refuses with a fixedsend_*code and nothing sent. The CLI loop andpostiz.run_live_sendalready claim first, so their behaviour and signatures are unchanged.connections.check_connectionrecords the objects it returns, so a hand-builtVerifiedConnectionpassed topublish()is ignored and the identity is checked again. In-process only; see THREAT-MODEL.md T8 for the limit.reachweaver/publishers.py: platform-specific HTTP adapters. Sanitizes failures so Telegram bot token URLs and OAuth bearer tokens are never printed/logged. Redirects are rejected for all API methods. A sanitized error may carry an HTTPstatusand a boundedretry_hint(issue #11). Issue #13 (owner-approved 2026-10-09; disabled until a live receipt) adds a stdlibmultipart_bodyencoder and the Mastodon image flow (POST /api/v2/media, boundedGET /api/v1/media/:idpolling that honours the emergency pause, thenPOST /api/v1/statuseswithmedia_ids[]); the write marker is set before the first upload.publish()refuses media unless the account is a direct Mastodon account andcapabilities.require_capability("mastodon", "image", "publish")passes, which the bundled registry refuses.reachweaver/attachments.py(issues #13/#20, owner-approved 2026-10-09; disabled until a live receipt): the shared image attachment model; offline validation of local image attachments for Mastodon drafts: header-detected PNG/JPEG/WebP via themediaparsers, size/count/alt-text limits frompolicy.mastodon_media_limits(owner pins only lower), focal point parsing, the strictposts.media_jsoncodec, andread_approved_bytes, which reads a file once and returns its bytes only if they still hash to the recorded SHA-256.storagestores the list in the nullableposts.media_jsoncolumn (added bymigrate());approval_payloaduses the image tagreachweaver.storage.approval/media-1only when attachments exist, adding each attachment’s SHA-256, MIME type, alt text and focus in order, so text-only hashes are unchanged.published_text(post text plus alt texts) feeds every opt-out check. The capability registry entry may listlive_media_formats(validated againstcapabilities.LIVE_MEDIA_FORMATS); none does.reachweaver/video.py(issues #22/#23, owner-approved 2026-10-09; disabled until a live receipt): the shared video record in the nullableposts.video_jsoncolumn: corekind, version, platform, path, sha256, bytes, mime, duration_seconds, width, height, video_codec, frame_rate_fpsplus aplatform_optionsobject that each platform module validates.bound_view(everything exceptpath) is whatapproval_payloadbinds underreachweaver.storage.approval/video-1. A post carries images (media_json) or one video, never both (set_post_media,set_post_video,approveand the live loop refuse both), and the record’splatformmust equal the post’s. Video sending is gated only bycapabilities.require_video_mode(policy.SUPPORTED_LIVE_VIDEOplus the platform’s registry video switch), never byreachweaver_adapter.reachweaver/ratelimit.py: pureRetry-After/X-RateLimit-Reset/Telegramretry_afterparsing, the C1/C2/C3 send-failure classification, cooldown deadlines (60 s–24 h) and bounded jittered backoff for the read-only pre-claim identity preflight only (issue #11). It never retries or resends a claimed post. Since #11 (2026-10-09): shared Postiz workspace cooldown scopes and the pre-network BlueskycreateSessionbudget.reachweaver/connections.py: explicit online account identity checks, before claiming a live send. Bluesky authenticates and verifies the handle, Mastodon reads the authenticated user, Telegram checks bot/channel/permissions, LinkedIn compares theuserinfosub. The Instagram, TikTok, YouTube, Threads and Facebook checks exist butcheck_connectionrefuses those platforms first (outsideSUPPORTED_LIVE/SUPPORTED_LIVE_VIDEO). No secrets/remote responses persisted. Identity proof is not permission proof on Bluesky/Mastodon.reachweaver/credential_hygiene.py: offlinecredentials-audit. Reports credential variable names per account (set/missing), orphans, placeholder values, the same secret reused across variables (salted in-memory digests, never output), accounts whoseenv_prefixmakes them read the same or an overlapping variable, unsafe credential files or directories, environment credentials shadowed by a credentials directory, and, only for history files named with--history, the line numbers of literalREACHWEAVER_*secret assignments. It reads every value throughaccounts.credentialand reportscredential_source. See threat model and account onboarding.reachweaver/campaigns.py: versioned brand/source/variant contracts, error/warning preflight, safe manual export and transactional idempotent draft-only stage. Does not fact-check linked webpages automatically.reachweaver/experiments.py: preregistered-plan declaration and matched-snapshot readout with missingness, cohort/guardrail checks, no causal claims.reachweaver/ops_metrics.py: read-only operational metrics (ops-metrics, issue #26). Counts from the auditevent_logand current post/queue state through SELECT-onlystoragehelpers (post_status_counts,queued_posts,event_activity,needs_review_details) plusqueue_health. Renders JSON or the Prometheus text format and replaces an--outfile atomically;--outrefuses the state database, symlinks and any existing file not named*.prom/*.json. The CLI opens the database withstorage.connect_readonlyand fails (exit 2) on a missing database or on one whose schema lacks a column it reads (storage.schema_gaps), never creating or migrating it. Labels are allow-listed (account id shape, known platform, status, fixed reason/class/stage codes); anything else becomesinvalid/other/unknown.reachweaver/audience.py: read-only loader for the owner-exported audience-activity CSV (ReachWeaver interchange format, issue #16) and its advisory weekly slots with provenance forcalendar-plan --audience-activity. No network, no database.reachweaver/history.py: opt-in repetition checks of campaign variants against the local posting history (campaign-check/campaign-preview --history-db, issue #10). Reads an existing database throughstorage.connect_readonly(mode=ro,query_only, never created) and the SELECT-onlystorage.posting_history; reusesclaims.jaccard. Emitshistory_near_duplicateand thehistory_frequencybrand-hygiene heuristic as warnings only, naming post ids, statuses and dates, never prior text.reachweaver/worker_log.py:publish --log-json [--log-file PATH]structured worker logs (issue #26). Oneprivacy.structured_logJSON line per decision with fixed event names (run_start,gate_refused,post_skipped,post_claimed,post_published,post_needs_review,preflight_retry,run_end) and reason codes, a sortablerun_idand aseq. Identifiers are allow-listed by shape, errors reduced to sanitized codes, remote URLs to scheme/host/path; post text, handles and secrets are never passed in. Purely observational: the destination is opened (0600,O_APPEND|O_NOFOLLOW) andrun_startwritten before the due query, account load or claim, so an unusable log refuses the run (exit 2); later write failures are counted, never raised. The only database access is twoSELECTs after a Postiz hand-off, made only when logging is on. Retry metrics (security decision item 3, owner-approved 2026-10-21):WorkerLog.retry_hook(post)is theon_retryobserver that the CLI loop,postiz.run_live_send(on_preflight_retry) and the TikTok runner pass toratelimit.preflight_with_backoff; it writes onepreflight_retryline (post triple,attempt,delay_ms, sanitizedcode) and countspreflight_retriesinrun_end. Without--log-jsonit returnsNone, so no observer is passed. No database write.reachweaver/youtube_analytics.py: read-only YouTube analytics-sampling report (youtube-analytics, issue #22 follow-up). SELECT-only overposts,metric_snapshotsand thepublishedaudit event throughstorage.metric_states; opened withconnect_readonly, no network, no credential. Labels each metric exact / sampled / suppressed / delayed / not_available / unknown, repeats the video receipt fields, never turns unknown into zero and never sums estimates with exact counts. See YouTube analytics sampling report.reachweaver/youtube.py: YouTube video upload adapter (issue #22, owner-approved 2026-10-09; disabled until a live receipt), unreachable: the registry keeps YouTube’sreachweaver_video_uploadswitchoffandpolicy.SUPPORTED_LIVE_VIDEOis empty. Its record is the shared video record (video.py) with YouTubeplatform_options. Video spec validation and SHA-256 hashing foryoutube-attach,channels.list?mine=trueidentity, and the resumablevideos.insertupload. See YouTube video upload.reachweaver/replies.py(issue #17, owner-approved 2026-10-09; disabled until a live receipt): reply targets fordraft --reply-to ITEM --online. It reads the parent through unauthenticated, redirect-free GETs: BlueskygetPostThreadwithparentHeight=20, Mastodonstatuses/:idand/context. It refuses the draft unless the thread root is a post ReachWeaver published for this account. It also re-triages the parent text, wheresuspicious,harassment,spamor a Bluesky moderation label need an exact--override-flaggedlabel, and applies the opt-out list to the parent author (never overridable). The output is canonical JSON stored in the nullableposts.reply_targetcolumn, whichmigrate()adds.storage.approval_payloadhashes that string, so a changed target needs re-approval.target_problemsre-checks it atapprove/check/publish.capabilities.require_reply_capabilityrefuses every reply claim while the registry entry’sreachweaver_reply_adapterisnone, which is the bundled value. The send reusespublishers.publish(reply_target=...): Blueskyrecord.replyholds root and parent strongRefs, and Mastodon getsin_reply_to_idwith the stricter of the parent’s and the account’s visibility. There is no second pipeline. A reply is text-only: attaching images or a video to a reply row, approving one, or sending one is refused (storage.REPLY_MEDIA_REFUSED). See SECURITY-REVIEW-PROPOSAL-17-reply-send.md.reachweaver/cli.py: explicit human interfaces, default dry-run, no daemon.reachweaver/confirmation.py(#7 D6, owner-approved 2026-10-09: proposal): an opt-in human-presence check (settings.approval_confirmation = tty_hash_prefix, off by default, set withapproval-mode). When it is on,approveandpublish --live(native and Postiz paths, pre-claim and pre-network) require terminals on stdin/stdout and the first 8 hex characters of the payload sha256, typed on/dev/tty(never stdin), compared in constant time, with 3 attempts. It adds friction and does not prove a human is present: a pseudo-terminal can pass it. The keyed half (D7) lives instorage.py: an optionalposts.approved_payload_macHMAC-SHA256 tag underREACHWEAVER_APPROVAL_SECRET, read viaaccounts.credential, verified wherever the plain hash is.reachweaver/review_loop.py(#11, owner-approved 2026-10-21 with the review-loop proposal’s recommended D1-D5):reachweaver review, an owner-only guided approval loop with no approval logic of its own. It refuses before reading or printing anything unless stdin and stdout are terminals and/dev/ttyopens. It selectsdraftrows (optionally by--campaign/--account), oldest first bycreated_atthenrowid, and leaves reply drafts (posts.reply_targetset) out. For each post it parses and runs the realshowandcheckcommands throughcli.execute, reads one answer from/dev/tty(a/s/c/q; no default, end of input quits), re-reads the row and acts only if it is byte-for-byte the row that was shown, then parses and runs the realapprove POST_ID(only--approver,--persona,--optout,--sources,--verify-identity --onlineare passed through; never--allow-repeat) or, after a typedCANCEL,cancel POST_ID. The global options of thereviewrun are copied onto each parsed command, soapprovekeeps its own pre-checks,transitioncall, approval nonce and, when enabled, the D6 typed prefix per post. The outcome is read back from the row, not inferred from exceptions. Catalog tagsowner_only+interactive; the agent guard and the MCP server’sNEVER_EXPOSEDboth list it. Tests:tests/test_review_loop.py(non-terminal refusal, per-post flow with a fake/dev/tty, skip/quit/end of input, no approval without an answer, repeat guard, D6 per post, a spy showingtransitiongets the same arguments as a typedapprove)..claude/skills/reachweaver: on-demand procedural intelligence, bundled executable copy.
Status transitions
Section titled “Status transitions”DRAFT --approve (binds hash H)--> APPROVED --schedule (verify H, rebind time)--> SCHEDULED ^ | | | | | +---unapprove (clears H)-----------+------------------unapprove (clears H)--------+ | | | | +--claim (H must match) --+--claim (H must match, due) | v +---reconcile-not-published<--NEEDS_REVIEW<--(uncertain)--SENDING--> PUBLISHED
DRAFT/APPROVED/SCHEDULED --cancel (clears H)--> CANCELLEDunapprove/cancel of an approval with a nonce: one "withdrawn" ledger line firstclaim of a nonce the ledger holds (consumed or withdrawn): refused, status unchangedAPPROVED/SCHEDULED with H missing or not matching: claim and schedule refused, status unchanged
mark-shared --url (record_manual_publish) ==born==> PUBLISHED_MANUAL (terminal: no edge in, no edge out)published_manual: posted by hand, measurable (owner-approved 2026-10-21). storage.record_manual_publish (used by mark-shared --url; the later “Mark as posted” button of share --web is meant to call it too) inserts a posts row directly in published_manual, with platform, account ('' when not given), text, remote_url = the link, remote_id = a platform id derived offline from the link or NULL (metrics_fetch.platform_id_from_url: Mastodon links on the configured server and handle, Bluesky bsky.app/profile/did:.../post/... links), send_started_at = the declared publish time (--at, never more than 10 minutes in the future) and every approval column NULL. In the same BEGIN IMMEDIATE it writes the mark-shared ledger entry exactly as before, linked by the new nullable shared_material.post_id, and a manual_publish_recorded event (provenance=owner_declared_manual_publish). A link already recorded for the platform (canonical form, published or published_manual) is refused. The record can never be sent: transition’s table has no published_manual key or value, so approve, unapprove, cancel and schedule refuse it; reconcile accepts only sending/needs_review; pending, claim_sending and every runner select only approved/scheduled; the media/video setters need draft; queue_health, dead-letter, approval_rebind_required and ledger.audit look only at the send states. The repeat guard compares the linked ledger entry (unchanged account scope: no account = every account) and leaves the row itself out of repeat_candidates, so nothing is matched twice and a record never matches its own entry. measured_posts (published + published_manual) feeds report (a posted_by_hand count inside published; CSV column posted_by_hand), insights (cohort posted_by_hand), metrics-fetch, the journal (source: posted_by_hand, the linked ledger entry not repeated) and the dashboard’s Posted and Results pages, all labelled “posted by hand”. schemas.STORAGE_STATUS_MAP maps it to no canonical state, so schema-export never invents a PublishJob or an approval for it; the privacy purge redacts its text like published text. Schema upgrade: a database created before this version keeps its older CHECK until the first record_manual_publish, which rebuilds posts once with SQLite’s documented procedure (foreign keys off, one BEGIN IMMEDIATE: create, copy every column by name, drop, rename, recreate idx_posts_due, foreign_key_check, row-count check; any problem rolls everything back) and logs posts_status_check_widened. Approval columns are copied byte for byte, so every stored approval hash and MAC still matches. Tests: tests/test_manual_published.py.
posted URL (reachweaver/posted.py, 2026-10-21). One command for mark-shared --url plus metrics: the platform comes from the link’s host through the explicit posted.HOSTS map (every value a policy.PLATFORMS id; a host also matches its subdomains, never a look-alike suffix) or a configured Mastodon account’s server; an unknown host is refused. The account is picked offline (posted.pick_account: the handle the link names, else the only non-placeholder account of that platform; several and no clue is refused; none records ''). Metric values are validated with storage.parse_metric_value before any write. A link not yet recorded goes through record_manual_publish(..., link_is_enough=True) (text optional: the link identifies the post; its ledger entry then holds no material, and repeat_guard._compare needs text on both sides, so it never matches) and then store_metrics (source entered-by-hand, observed now) under _atomic. A link already recorded (find_manual_duplicate: published or published_manual) gets only a new store_metrics snapshot. It never calls transition, pending, claim_sending or a publisher, and mcp_server.NEVER_EXPOSED lists it. At a terminal (stdout) its last lines are results.week_summary (SELECT only: storage.report and results.post_results for report_since("7d"), rendered by report_format.summary_line, the function that also writes the first line of report --week) and More: reachweaver report --week; piped output keeps Next: reachweaver report --week (2026-10-22). Tests: tests/test_posted.py, tests/test_ux5_cli.py.
Unapproved posts are never eligible; scheduled posts are not eligible before due timestamp. SENDING may remain after process termination because the remote outcome is unknown. Never reset that state automatically. Posts cannot be edited after approval; recreating a draft preserves review integrity.
Approval payload binding (issue #7, owner-approved 2026-10-08). Each transition in the diagram treats the hash H as follows:
approve(draftonly) storesposts.approved_payload_sha256. This is a SHA-256 over the canonical JSON (theschemas.canonical_jsonserialization) of the binding tagreachweaver.storage.approval/1,account_id,platform,content(byte-exact),scheduled_at(always null at approve time),source_urlandcampaign. A post with images uses the tagreachweaver.storage.approval/media-1and addsmedia(per image, in order:sha256, mime, alt, focus, plusurlfor a hosted image); a post with a video usesreachweaver.storage.approval/video-1and addsvideo(the shared record without its local path). A reply row (#17) also includesreply_target; the key is absent for ordinary posts, and a reply is always text-only, so it never carriesmediaorvideo. A text-only post’s payload, and so its hash, is byte-identical to before; a post never carries both kinds. The #7 D7 keyed tag MACs this hash, so it covers image and video bytes exactly as it covers text. It also storesapproved_by/approval_channel(approve --approver NAME, otherwise$USER/$LOGNAME, otherwise unattributed). These two are attribution, not authentication, and are not hashed.approveprints the hash prefix and the bound fields.showprints the binding state.scheduleandcalendar-plan --applyfirst verifyHagainst the current row. A post that changed, or that has no hash, is refused with nothing changed (the calendar batch rolls back). After the check passes,His rebound to the newscheduled_at, so tampered content is never laundered into a fresh hash.unapproveandcancelclearHand the attribution, and close the approval’s nonce in the ledger (next bullet).reconcileis unchanged (owner decision D9).- Approval ledger (
reachweaver/ledger.py; wave 9 item 1, owner-approved 2026-10-10; withdrawal: security decision item 12, owner-approved 2026-10-21). Everyapprovedraws a fresh 128-bitposts.approval_nonce, bound intoH(tagapproval/2).approval-ledger.jsonlnext to the database is append-only and hash-chained (each line’sprevis the SHA-256 of the previous line; mode 0600,O_NOFOLLOW,O_APPEND,flock,fsync) and holds at most one line per(post_id, approval_nonce):consumed, appended byclaim_sendingunder itsBEGIN IMMEDIATEbefore the row flips tosending, orwithdrawn, appended bytransitionforunapprove/cancelof a row with a nonce, under the same lock and before the row changes.ledger.check(pre-claim gate and claim) andledger.consumerefuse a consumed pair withapproval_replay_reapproval_requiredand a withdrawn one withapproval_withdrawn_reapproval_required(oneapproval_replay_blockedevent,last_error, no claim). So a database restored from an older backup cannot resend a used approval or re-arm a withdrawn one, whileunapprovethenapprovedraws a new nonce and works. A crash after a ledger line and before the row change leaves the row approved with a closed nonce, which is refused (safe direction). Granting fails closed on an unusable ledger (ensure); withdrawing never does: the row is withdrawn, the event recordsledger=unrecordedand the CLI warns.ledger.audit(read-only, inqueue_health/health) reports broken chains, approved rows whose nonce is closed and sent rows whose nonce is not consumed. Ledgers with onlyconsumedlines stay valid; older code reads awithdrawnline as a broken chain (fail closed). The same Unix user can still rewrite the whole chain. A stale hash on a reconciled draft is inert, because onlyapproved/scheduledrows are claimable andapprovealways recomputes the hash. claim_sendingrecomputesHinside itsBEGIN IMMEDIATE, after the pause check and before the rolling budgets. The sender also passesexpected_sha256, the hash of the copy it is about to send, so a row read before the lock cannot differ from the bound row that gets claimed. A missing hash (approval_hash_missing_reapproval_required, a row approved before the upgrade) or a changed one (approval_hash_mismatch_reapproval_required) fails closed. The post staysapproved/scheduled(D5),last_erroris set, oneapproval_binding_failedevent is written (hash prefixes only, deduplicated), nothing is claimed, no budget is used, andValidationErroris raised. A table without the columns fails closed withapproval_binding_unavailable. On such a tableapprove/scheduleare refused too, whileunapprove/cancelkeep working.- Both live senders, the CLI
publish --liveloop and Postizrun_live_send, callcheck_approval_bindingbefore the identity preflight. An unbound or changed post isBLOCKEDwithout any network call.publishdry-run flags such posts without writing anything. migrate()adds the three nullable columns to older databases and does not grandfather existing approvals (D2).queue_health/healthlist every post that needs re-approval asattention(D10).- Re-approval is always
unapprove,show,approve(thenscheduleagain if the post was scheduled). There is no in-place rebind and no bypass switch. See worker operations and SECURITY-REVIEW-PROPOSAL-7-11.md. - Send options (owner-approved 2026-10-21). Account settings that shape the request are bound with the text: a direct Mastodon account’s
visibility(defaultpublic),languageandspoiler_text, and a Postiz account’spostiz_base_urlandpostiz_integration_id.reachweaver/send_options.py(pure, offline) computes a SHA-256 fingerprint over the account id, platform and those effective values;approvestores it in the nullableposts.approved_send_optionsand the payload gainssend_options_binding(tagreachweaver.storage.approval/options-1) andsend_optionsonly when the column is set, so every other approval keeps its hash. Both live senders passsend_options.fingerprint(account)of the account they send with tocheck_approval_bindingandclaim_sending; a difference (approval_send_options_changed_reapproval_required), or an account with send options and a row without a fingerprint (approval_send_options_missing_reapproval_required, a pre-binding approval, never grandfathered), is recorded and refused like any binding failure before any request. The claim also hands the bound fingerprint to the send gate, andsendgate.consumerefuses apublish()call whose account entry does not fingerprint to it, so the request is built from the approved values.approverefuses a Mastodon post whose account entry cannot be loaded.health, the dry run andshowreport an approved Mastodon row without a fingerprint without loading accounts (every Mastodon account has send options).postiz_api_prefixis not bound. - Repeat guard at send time (owner-approved 2026-10-21, decisions D1-D4).
claim_sending, after the account hold and before the budgets, under itsBEGIN IMMEDIATE, refusesrepeat_of_shared_materialwhenrepeat_guard.blockingfinds an exact video, image or wording match on the same account (D3) with apublished,sendingorneeds_reviewpost (D4) or amark-sharedentry. Nothing is written, no budget is used and the post stays approved/scheduled; because a claimed sibling is alreadysendingunder the same lock, two copies approved before either was sent can never both be claimed. The CLI loop, the Postiz runner and the TikTok runner run the same read-only check before their identity preflight (worker reasonrepeat_of_shared_material), and the dry run shows it.approve --allow-repeatis bound (D2): the nullableposts.approved_allow_repeatholds1and the payload gainsrepeat_binding(tagreachweaver.storage.approval/repeat-1) andallow_repeat, sounapprovewithdraws it and an edited column is a hash mismatch. Only that bound value passes; therepeat_overrideaudit event, now written only after the approval succeeded, is never read as permission. - AI-content label precondition (owner-approved 2026-10-21, decisions D1-D4 and D6;
reachweaver/ai_label_gate.py). Three nullablepostscolumns hold the owner’s declarations:authorship(human|ai_assisted|ai_generated) andrealistic_synthetic_json(canonical{attachment sha256: true|false}), both written only on a draft byai-declare, andapproved_ai_label(applied|not_needed), written only byapprove --ai-label. For a post with images or a video and at least one of them set, the payload gainsai_label_binding(tagreachweaver.storage.approval/media-3, an add-on next to the media-1/media-2/video-1 base tag) andai_label_declarations(the three stored values as they are); a text-only post, and a media post with all three NULL, keep their payload byte for byte, so no earlier approval changes hash.transition("approve")computesai_labels.assessfor the post’s own platform (EU Art. 50 excluded, D4) on the exact values it binds and refusesai_label_unansweredwhen a post with media isrequiredorunknown(D2) and no--ai-labelis given;--ai-labelon a text-only post is refused (D1: text stays advisory).schedule,check_approval_binding,claim_sending(after the send-options check, before the ledger),approval_rebind_required(sohealthlists it) and the offline dry-run view re-run the same pure decision on the bound values with the bundled registry (no fetch, no network) and refuseai_label_unansweredwhen no answer is bound: an approval made before this change is not grandfathered. The registry file is not bound, so a registry update never silently invalidates an approval, but it can make a previously unneeded answer needed (fail closed).unapprove/cancelclearapproved_ai_label;schedulekeeps it. Theapproveaudit event recordsai_label_decision=andai_label=. No platform label field is set (D5 deferred).healthwarns (ai_disclosure_registry_aging) when the registry’s oldest checked date is more than 90 days old (D6), never blocking.
By default the hash is unkeyed: anyone who can write the database can change the content and recompute the hash. It detects unsanctioned mutation (naive writes, scripts, future code paths); it does not authenticate the approver. The opt-in keyed HMAC binding (D7, approval-mode --require-key with REACHWEAVER_APPROVAL_SECRET, owner-approved 2026-10-09) closes that gap only while the key stays out of the writer’s reach; a signed binding is still open (threat model T7/T8), while replay and re-arming a withdrawn approval are covered by the approval ledger above, up to a same-user rewrite of the whole chain. Opt-in approve --verify-identity --online (wave 9 item 2, owner-approved 2026-10-10) additionally binds a fingerprint of the verified account entry (approval/id-1, reachweaver/identity.py); publish --live refuses a changed entry before any request, and approval-mode --require-identity makes it mandatory. It binds which account, not who approved.
Offline account-aware calendar planner
Section titled “Offline account-aware calendar planner”reachweaver/calendar.py is the pure/offline proposal layer between approved drafts and the existing durable outbox. It accepts an explicitly configured account ID, an audience IANA timezone, a future date window, optional user-supplied weekly slots/blackouts and a minimum spacing rule. The starter windows are low-confidence observational priors, not an ML ranking model. It creates no remote post or schedule receipt.
calendar-plan --apply takes the proposed plan, acquires a SQLite BEGIN IMMEDIATE lock, rechecks each account/platform/approval, emergency stop, future timestamp and existing schedules, then applies all transitions or rolls back all. It is restricted to the four text-only locally supported publishing adapters (policy.SUPPORTED_LIVE: Bluesky, Mastodon, Telegram, LinkedIn member); manual-only networks cannot be applied. It never enables a worker or remote auth. DST ambiguous/nonexistent hours raise errors rather than silently folding.
reachweaver/audience.py (issue #16) loads an owner-exported hour-of-week audience-activity CSV in ReachWeaver’s own interchange format (reachweaver.audience-activity.v1; no platform export has been verified against it). It is stdlib-only and read-only: a bounded read (1 MiB, 10,000 rows), strict columns, one timezone/source/as_of per account, duplicate (account, weekday, hour) rows rejected, missing or unknown hours kept missing (never zero), formula-injection prefixes rejected (OWASP), and source/consent redacted through privacy.redact. utc_hour_of_week normalises each row with the UTC offsets of the as_of week; DST gaps are nonexistent and folds use fold=0. derive_slots ranks known, positive hours into weekly slots with provenance and a staleness flag (default 90 days). propose_calendar(..., audience_activity=...) uses those slots instead of the generic prior, attaches provenance to every entry, adds a top-level audience_activity summary, and skips (and lists) DST-gap/fold occurrences instead of failing. With audience_activity=None the code path and output are unchanged; a golden-output test enforces this. The module opens no database and makes no network call.
See calendar quickstart. Future ML optimization and durable worker operation remain separate from this safe baseline.
Scheduler
Section titled “Scheduler”Install on a single trusted persistent host where the DB and credentials survive restarts. Example (user sets env securely via process supervisor, not crontab plaintext):
*/15 * * * * cd /path/to/reachweaver && /path/to/venv/bin/reachweaver publish --live --scheduled-only --max-lateness-minutes 180 --max-per-24h 2 --min-gap-minutes 60Only enable after user-approved credentials, reviewed scope, and testing. Multiple hosts against a shared SQLite file over NFS are not supported; production scale requires PostgreSQL/leases, retries respecting idempotency and reconciliation. Platform external receipts are the source of truth.
Rolling live budgets, provider cooldowns, due-time guard and queue health (issue #11)
Section titled “Rolling live budgets, provider cooldowns, due-time guard and queue health (issue #11)”In storage.claim_sending, optional max_per_24h and min_gap_minutes are checked inside the existing SQLite BEGIN IMMEDIATE lock, before transitioning an eligible post to sending. CLI publish --live supplies conservative defaults of two attempts per account per rolling 24 hours and 60 minutes between attempts. All send_claimed events in the durable audit log count for the owning account, including uncertain and later human-reconciled attempts. The budgets are application-level, not spend controls; caps.json version 2 can tighten them per provider, and provider 429s add the cooldowns described below. Direct library callers may still omit the budgets in claim_sending itself, but since wave 9 item 3 publishers.publish() refuses (send_caps_required) a claim made without caps in the CLI’s range, and refuses any call without a prior claim; the cooldown check in claim_sending always applies.
pending(..., scheduled_only=True, scheduled_not_before=...) powers unattended safe mode: approved-but-unscheduled posts cannot be sent by that worker query and scheduled items older than the selected lateness window cannot starve fresh due work. Missed items remain scheduled and show up in queue-health for human review. The CLI validates 5–10080 minutes of lateness, defaulting to 180 for scheduled-only mode.
queue_health reads status totals, account attempt counts, overdue schedules, sending older than an owner-chosen threshold, all needs_review records, approved/scheduled posts whose approval binding needs re-approval (approval_rebind_required, issue #7) and active provider cooldowns (provider_cooldowns); it never contacts providers or mutates/retries posts.
Provider rate limits (owner-approved 2026-10-08, proposal part 2, D11–D16). reachweaver/ratelimit.py holds the pure parts; nothing in it can move a claimed post.
- Error capture (D15).
publishers.http_errorturns anHTTPErrorinto the same sanitizedhttp_<code>as before, plus two typed attributes onPublishError:statusandretry_hint(RetryHint(delay_seconds, source)).ratelimit.hint_from_http_errorreads, for 429/503 only, aRetry-Afterheader (RFC 9110 delay-seconds or HTTP-date), Telegramparameters.retry_afterfrom at most 4 KB of the error body (only when the caller passestelegram_body=True: TelegramsendMessageand thegetMe/getChat/getChatMemberpreflight), or MastodonX-RateLimit-Reset. Values longer than 64 characters, non-numeric text and past dates are ignored. Blueskyratelimit-resetis not used (unit unverified). The no-redirect opener, the 1 MB body cap and the rule that no URL, header or body text reaches an error are unchanged.postiz._callandlinkedin._actionablekeep the attributes through their own codes (PublishError.with_code). After the sanitized error is built, every handler closes theHTTPError(publishers.close_http_error, best effort, reads nothing): Python 3.14 otherwise emits aResourceWarningquoting the error’s reason phrase when it is garbage-collected, visible under-W,-X devorPYTHONWARNINGS(tests/test_http_error_close.py). - Classification (D11). Each adapter calls
publishers.mark_write_started()immediately before it hands the content-create request to the network (BlueskycreateRecord, Mastodonstatuses, TelegramsendMessage, LinkedInrest/posts, PostizPOST /posts); the senders callreset_write_marker()beforepublish.ratelimit.classify_send_failurereturnscertain_not_sentwhen the marker is unset,likely_not_createdfor a 429 from a direct Bluesky, Mastodon or Telegram create, andambiguousotherwise (unexpected exceptions are alwaysambiguous). The label is appended to theneeds_reviewevent (uncertain_send(..., send_class=)) and shown bydead-letter. It never changes state. - Cooldown (D12). On a 429 (pre-claim preflight or post-claim create),
ratelimit.record_rate_limitcomputes the deadline withcooldown_deadline(provider hint, else LinkedIn’s next 00:00 UTC for a direct LinkedIn call, else 15 minutes; clamped to [floor, 24 h], floor 60 s or higher from caps) andstorage.record_provider_cooldownappends oneprovider_cooldownevent:account=<id> until=<UTC> source=<label> reason=http_429 stage=preflight|send [class=<label>] [clamped=ceiling], withpost_idNULL (preflight) or the claimed post.storage.provider_cooldownsreturns the latest active deadline per account (cooldowns only extend; malformed rows are ignored). Both live senders checkactive_provider_cooldownafter the pause, opt-out and approval-binding gates and before the capability check, credentials and identity preflight, printingSKIPPED ... account_provider_cooldown_activewith no network use.claim_sendingre-checks inside itsBEGIN IMMEDIATE, after the pause and the approval hash and before the budgets, and raises without writing anything.privacy.apply_purgekeepsprovider_cooldownevents younger than 48 hours, likesend_claimed. - Preflight backoff (D13).
ratelimit.preflight_with_backoffwraps only the identity check (check_connection,postiz.verify_integration). It retries a 429, 500, 502, 503, 504 or network error at mostpreflight_retries(platform, caps)times (≤ 2; 0 for Bluesky, whose check isPOST createSession), sleepinguniform(0, min(30, 2·2^n))or the server delay if that is larger and ≤ 30 s. A server delay above 30 s stops at once.is_pausedis checked before and after every sleep. All of this happens beforeclaim_sending. An optionalon_retry(retry, delay_seconds, code)observer is called just before each sleep with the sanitized code (worker_log.error_code); anything it raises is swallowed, so it cannot change the retry count, delay, pause checks or claim (the worker log’s retry metrics). - Shared scopes and the
createSessioncounter (owner-approved 2026-10-09, proposal).ratelimit.cooldown_scopes(account)maps an owner-declaredpostiz_workspacetopostiz-workspace:<slug>.record_rate_limitwrites it asscope=on theprovider_cooldownevent.storage.active_provider_cooldown(con, account_id, scopes=)matches the account’s own events plus any event of its scopes, united with thecooldown_scope_memberstable that each live run refreshes throughsync_cooldown_scopes.claim_sending(..., cooldown_scopes=)uses the same check under its lock.ratelimit.reserve_bluesky_sessionscallsstorage.reserve_create_sessions, which countsbluesky_create_sessionevents perbsky-account:<sha256 prefix>in each documented window (session_windows, registry facts, ⌊50%⌋) underBEGIN IMMEDIATE, and records the reservation or abluesky_create_session_refusedevent before any request. Both additions only add waiting. - Budgets (D14).
caps.jsonversion 2 adds aprovidersblock (caps.providers_for,effective_caps(..., providers=),effective_rate_limits); see worker operations.caps.BUILTIN_PROVIDER_CAPS(owner decisions D19-4 and D21-5, 2026-10-09) is applied byeffective_capswith or without a file: Threads at most 2 per 24 h, Facebook at most 2 per 24 h and at least 60 minutes apart. - Unchanged (D16). Every post-claim failure still calls
uncertain_sendand ends inneeds_review; there is no resend,release-claimor automatic reconcile path. Deployment is a separate explicit owner action, via the worker runbook and inertdeploy/systemd/samples. Do not run the older barepublish --liveas unattended cron, because it can also publish approved unscheduled posts by design for supervised pilots.
Current engineering limitations
Section titled “Current engineering limitations”- No automatic OAuth wizard or long-lived refresh token rotation; Bluesky App Password and user-provisioned credentials read through
accounts.credential:{env_prefix}+_IDENTIFIER/_APP_PASSWORD(Bluesky),_TOKEN(Mastodon),_BOT_TOKEN(Telegram),_ACCESS_TOKEN(LinkedIn member; plus_CLIENT_ID/_CLIENT_SECRETonly with the #14scope_checkopt-in),_POSTIZ_API_KEY(optional Postiz backend), and, reserved for the disabled adapters,_ACCESS_TOKEN(Threads, Instagram, YouTube),_TIKTOK_ACCESS_TOKEN(TikTok) and_PAGE_ACCESS_TOKEN(Facebook Page) (accounts.CREDENTIAL_SUFFIXES). The optional #7 D7 approval keyREACHWEAVER_APPROVAL_SECRETis read the same way but belongs to no account. All live adapters are mock-tested only; no real owner account receipt exists. - No enabled image/video upload: the Mastodon image (#13), Instagram image (#20), YouTube (#22) and TikTok (#23) paths are mock-tested and refused by the capability registry (and, for video, the empty
policy.SUPPORTED_LIVE_VIDEO) until a live receipt. No multi-post threads, editing/deleting posts or media/accessibility generation.media-checkvalidates owner-supplied media manifests, alt text, captions and rights offline only, andmedia-storyboardwrites placeholder-only storyboard scaffolds with no media generation (#18). - No automatic analytics fetch, follower discovery, live comment reading or social listening.
engagement-fetch --onlinereads public replies under your own published Bluesky/Mastodon posts (and, with--quotes, public Bluesky quote posts of them) on explicit request only, andengagement-triagelabels exported comments and writes reply/issue drafts only, with no send path (#17);attribution-reportjoins owner-exported analytics/GitHub traffic files, and GA4 and Search Console CSV downloads, read-only (#15, roadmap P2); nothing is fetched from Google. - No web dashboard, multi-operator RBAC, cross-device synchronization or queue worker daemon.
- Conservative platform caps may reject content that provider would accept; verify live API limits when adding adapters.
- Mastodon Idempotency-Key is supported upstream for up to one hour only; all ambiguous sends still need review.
- Bluesky defaults to the bsky.social entryway. An optional owner-configured HTTPS
serviceorigin selects another PDS (#12), but this is mock-tested only and has no live PDS receipt. - Existing draft content is not encrypted in SQLite; keep DB on a private protected filesystem and minimize private data.
Campaign staging invariants (v0.2)
Section titled “Campaign staging invariants (v0.2)”campaign_id+variant_idis unique incampaign_variants; a staged campaign fingerprint covers exact brand/campaign/variant input. Re-running unchanged content returns existing IDs without new posts; mutation requires a new version/ID. The operation is wrapped in oneBEGIN IMMEDIATEtransaction to avoid partial inserts.- Staging verifies a brand allowlist and that the configured account platform matches the requested post; it never calls a network API and creates only
draftrows. It snapshots the declared evidence links/claims for audit, but cannot prove those statements are true. - Only
textvariants can be staged. Other creative deliverables (Reels, videos, images, carousels) are human-reviewed briefs until independent rights/media/API implementations exist. - Portable standalone skill mirrors the canonical Python files under
scripts/reachweaver; CI rejects divergence.
Observational timing evaluation (issue #16)
Section titled “Observational timing evaluation (issue #16)”reachweaver/timing_analysis.py adds offline, read-only grouping of existing native source-labeled metric snapshots from an explicitly named account. It checks IANA audience timezone and optional local date/campaign filters, requires a single numerator/denominator metric definition and compares the nearest available cumulative snapshot at a fixed post age (24–720 hours) with a strict tolerance. A missing snapshot, zero denominator or different source is excluded, never treated as zero; snapshot totals are not summed. It reports sample counts, per-daypart median and IQR, plus conservative exploratory flags requiring two minimum-sized groups. Six-hour dayparts are not exact publish-time prescriptions, and these are unrandomized associations subject to major unmeasured confounding. There is no network, queue modification or automatic selection of a calendar-plan slot. See timing documentation.
reachweaver/calendar_gaps.py (CLI calendar-gaps, public issue #9) is a read-only report built on calendar’s candidate slots (operator --slot, owner audience-activity, or the generic baseline) and the posts in scheduled status. It labels each slot filled, empty or past, flags days over --max-per-day and posts closer than --min-spacing-hours, and can annotate empty slots with timing_analysis output. It runs on a connect_readonly connection (never migrates, never creates the database), makes no network request and cannot approve, schedule or publish. It cannot see posts queued in a platform’s native scheduler.
Evaluation against the non-personalized baseline (issue #16 slice)
Section titled “Evaluation against the non-personalized baseline (issue #16 slice)”reachweaver/baseline_eval.py (CLI baseline-eval) compares account-specific --slot arms and --campaign-arm variant campaigns with posts at calendar-plan’s generic slot, read through the new read-only accessor calendar.generic_baseline_slots(platform). The period (--since/--until, audience-local send date) is required. Rates use the same nearest-age, exact-source snapshot rule as timing_analysis.py, and missing snapshots are counted, never zeroed. Statistics are stdlib-only and deterministic: a percentile bootstrap seeded by --seed plus the arm id, a two-sided permutation test of the median difference (exact up to 20,000 relabellings), and Holm–Bonferroni across every tested comparison in the run. Groups below --min-posts report insufficient_data with null estimates. Saved experiment-readout --json files are added as experiment items: their labels are copied unchanged from growth.classify_result, whose strict sign-test gate is not modified, and separated_after_holm requires directional. Results are split into correlational and randomized (owner-declared, via growth_readout.describe_design). The CLI branch is an early branch that opens the database only to SELECT (plus storage.get_post) and returns; it imports no publisher, connection or network code and cannot approve, schedule or publish. See TIMING-EVALUATION.md.
What works for you (insights)
Section titled “What works for you (insights)”reachweaver/insights.py (CLI insights) compares the owner’s own published top-level posts within one cohort (account x platform x exact snapshot source; accounts are never pooled and sources never mixed). Each post’s result is read with baseline_eval.matched_snapshot, the nearest-age, exact-source, non-windowed snapshot rule shared with baseline-eval (refactored out of _matched_rate, behaviour unchanged); a post without one is excluded and counted, never zero. The default metric is interactions (likes + replies + reposts, all exact in one snapshot), the counters every recording path has. Features are derived locally from the stored row (send time in --timezone, text, attachment columns via timing_analysis.post_format); each feature value is compared as median of the group vs median of the rest, labelled too little data below --min-posts (default 5) and observation otherwise, with an optional seeded bootstrap interval (baseline_eval.bootstrap_independent). Up to three lines per account are phrased as hypotheses that point to the experiment-* commands; groups that are mostly the same posts are folded together. The CLI branch opens the database with connect_readonly after a schema_gaps check and only runs SELECT; it imports no publisher, approval or network code. top_insights() is the hook for other views; the read-only dashboard’s Results page uses it. See INSIGHTS.md.
Learning loops. insights._analyze also returns each cohort’s per-post rows, and is otherwise the same as analyze. Three read-only helpers expose the same matched-age evidence (default method, no bootstrap) to other commands:
tag_evidenceis used bydiscovery.add_your_results, fromdiscovery-plan.time_evidenceis used bycalendar.your_results_for, from acalendar-planproposal only.typical_resultsis used byfollowups.derive_rules, fromfollowup-suggestwithout--rules.
suggest.build_suggestions turns top_insights lines into P3 what_works_for_you items. growth_readout.results_from_readouts turns saved experiment-readout --json files into experiment-next results.
None of these paths writes the database or feeds a score, slot, approval or schedule. discovery-plan and report open the database with connect_readonly only when it exists. report no longer goes through connect, so it never creates or migrates a database.
Experiment readout invariants (v0.2)
Section titled “Experiment readout invariants (v0.2)”- Declared experiment plan fixes the primary and guardrail metrics, denominator, age/tolerance window, decision thresholds and minimum number of pairs. Plan timestamp is self-reported; for stronger auditability, store a timestamped/signed pre-publication copy.
- Each pair must use the same account, platform and metric data source; pairs across different cohorts are not aggregated. Both metrics are drawn from a single native snapshot nearest to the target elapsed time since
send_started_at(not guaranteed exact publish time). - Incomplete pairs, missing values, zero denominator, source conflicts, post-plan violations and insufficient repetitions are reported as excluded/inconclusive, never silently zero-filled.
- Organic feed distribution is confounded and unrandomized.
directional_positivereports a directional association only; not causation, p-value significance or future reach guarantees.
Account verification and first pilot safety (v0.3)
Section titled “Account verification and first pilot safety (v0.3)”doctor never accesses the network. connect-check --account ID --online explicitly authorizes authentication/read-only requests without creating content. Live publish --live runs the same verification before the durable claim; failed identity checks leave the draft approval untouched. publish --post-id ID only evaluates one approved/due post; show ID prints the complete exact proposed text/destination for owner review. Post-claim network failures still transition to needs_review without automatic retry. Session establishment and provider permissions can change after verification, so a supervised native receipt remains mandatory. See FIRST-LIVE-PILOT.md.
Account and credential hardening (issue #5)
Section titled “Account and credential hardening (issue #5)”Owner-approved on 2026-10-09. These four changes tighten the credential-isolation and live-publishing rules; no other approval, dry-run, emergency-stop or reconciliation rule changed.
publish --live |-- policy.require_live_gate(): REACHWEAVER_ALLOW_LIVE == "1" and REACHWEAVER_ENV == "prod" | else ERROR (exit 2): no account loaded, no credential read, no request made |-- load_accounts(): per-account validation, then env_prefix_conflicts() -> fail closed |-- per post: pause, opt-out, approval hash, cooldown, capability, missing_credentials ... vadapters (publishers, connections, linkedin, instagram [disabled], tiktok [disabled], youtube [disabled], threads [disabled], facebook [disabled], postiz, metrics_fetch, sources) '-- accounts.credential(NAME) / require_credential(NAME) $CREDENTIALS_DIRECTORY set? yes -> only the file $CREDENTIALS_DIRECTORY/NAME no -> only the environment variable NAME- Live gate.
policy.require_live_gateis the only place the two environment opt-ins are compared, andcli.executecalls it in thepublishbranch right after--liveis seen, before_accounts(), for native adapters and the Postiz backend alike. Without--livethe command is a dry run whatever the environment says.policy.runtime_environmentechoes onlydev,stagingorprod; anything else isunrecognized(orunset) indoctor,health,privacy-status,credentials-audit, the dry run and the refusal message. No other command makes a write request:connect-check,postiz-check,metrics-fetch --onlineandsources-scan --onlineare identity checks or GETs, and engagement triage has no send path. - env_prefix overlap.
accounts.env_prefix_conflictscompares every pair of accounts overALL_CREDENTIAL_SUFFIXES(every native adapter suffix plus_POSTIZ_API_KEY, whatever the account’s platform). Identical prefixes, or any candidate variable that equals or is a string prefix of the other account’s candidate, are refused byload_accountswith the two account ids and the variable names.credentials-auditreports the same rule for raw account lists. - Credential accessor.
accounts.credentialis the one read path;require_credentialturns a missing value intomissing_credentials: NAME. With$CREDENTIALS_DIRECTORYset it opens the directory (absolute;fstatrefuses group/world-writable), then the file relative to that descriptor withO_NOFOLLOW | O_NONBLOCK, refuses non-regular files, reads at most 64 KiB + 1 byte, decodes UTF-8 and strips one trailing newline. It never falls back to the environment while the directory is set.CredentialErrormessages name the variable only. Values read from files are kept in process memory soprivacy.redactstrips them exactly like secret environment variables.tests/test_hardening.pyasserts that no other module reads a credential from the environment. - Agent harness.
.claude/settings.jsondenies Claude Code Bash commands containingapprove,publish --live,REACHWEAVER_ALLOW_LIVE=1orREACHWEAVER_ENV=prod, plus (#7 decision 7)approval-mode ... --confirmation offand any command or file read namingREACHWEAVER_APPROVAL_SECRET. That is a harness configuration, not a ReachWeaver control. The plugin carries the same rule as aPreToolUsehook:reachweaver/agent_guard.py(standard library only, run aspython3 -I <file>so it imports nothing from the package) tokenizes eachBash,PowerShellandMonitorcommand withshlexand exits 2 for owner-only ReachWeaver actions (docs/SECURITY.md item 16), including thereviewloop (#11), matched as a whole word soreview-packet,review-commentandreview-statusare not affected. All CLI parsers setallow_abbrev=False.
Canonical schemas and replaceable connectors (issue #4)
Section titled “Canonical schemas and replaceable connectors (issue #4)”reachweaver/schemas.py defines versioned (schema_version: "1.2"; 1.0 and 1.1 documents stay valid) stdlib-validated contracts: SourceSignal, Evidence, ContentBrief, Draft, PlatformVariant, MediaAsset, Account, Approval, PublishJob, PublishReceipt, AnalyticsSnapshot and Experiment. It also defines a pure transition_job state machine for draft > reviewed > approved > scheduled > publishing > published|failed|unknown (+ cancelled). It is offline and side-effect free, and it does not replace or modify the SQLite outbox. storage.claim_sending, the emergency stop, budgets and reconciliation still govern every real send.
Decision: connectors stay replaceable because no vendor-specific type crosses the connector boundary. Upstream documents use plain policy.PLATFORMS values. Provider knobs live only in PlatformVariant.platform_options, and unknown top-level fields are rejected. A connector consumes a validated variant and emits a vendor-neutral PublishReceipt. A human Approval binds account_id, platform and the SHA-256 of the canonical-JSON payload. Any payload edit, account or platform mismatch, revoked or expired approval, or replay of a published/unknown idempotency key is rejected before publishing. unknown can only be resolved by a receipt, never by re-sending. Secret-like keys are rejected in every document. See SCHEMAS.md for field rules, the storage status mapping (sending ≈ publishing, needs_review ≈ unknown), migration rules and the schema-validate CLI.
Offline engagement triage (issue #17 slice)
Section titled “Offline engagement triage (issue #17 slice)”reachweaver/engagement.py is a pure, offline labeler for comments, replies and mentions that the owner exports into reachweaver.engagement-export.v1 JSON. It runs deterministic keyword and regex rules and records the matched rule ids as the explanation. It orders a review queue (bug reports and questions first, spam last) and writes reply drafts and Markdown GitHub-issue drafts to new local files (open(..., "x"), never overwritten). Comment text is untrusted: control and format characters are stripped and length is capped. Before matching, text is folded: NFKC, casefold, accents and Arabic harakat removed, apostrophes unified, Persian/Arabic letter variants (ي/ی, ك/ک), tatweel and ZWNJ normalized. Prompt-injection directives become suspicious, with the excerpt withheld. All whitespace is collapsed before matching, so directives split across lines are still caught. Injection detection is best-effort keyword matching and can be evaded; human sign-off on every draft is the real control. Drafts are fixed templates plus owner-supplied docs-map URLs. They never quote comment text or handles. Spam, harassment, suspicious items, owner comments, opt-outs, threads that already have a draft, and the human-authored-only platforms (DEV, HN) get no draft.
Rules come in hand-written packs for en, de, fr, es, it, pt and fa (RULE_PACKS, OPT_OUT_PACKS, compiled once on first use by rule_packs() / opt_out_packs() so CLI startup does not pay for them; non-English rule ids are <lang>_<rule>). Each pack covers every label plus opt-out phrases. guess_language() is a transparent heuristic: script letter counts plus stopword overlap, confidence = min(0.95, share × min(1, hits/4)), with candidates/mixed for mixed-language comments. It is reported per item as language_guess and never as certain. Safety labels (suspicious, harassment, spam) and opt-out phrases run from every pack on every comment. Content rules run from English plus the guessed language or languages, and from every pack when confidence is below 0.5. Reply drafts stay English unless the owner adds templates.<lang> to the docs map. Such a template is used only for a confident, single-language guess, and {docs} is substituted with str.replace, never str.format. Every draft records language_guess, template_language, machine_translated: false and a native-speaker review note. There is no translation path. Per-language rule approach and false-positive caveats are in ENGAGEMENT.md.
The module imports no network, publisher or subprocess code and defines no send, reply, follow, like, DM or issue-filing path. SEND_PATH = None and the prohibited-action constants are asserted in tests/test_engagement.py. Drafts carry status: "draft_requires_approval". Posting stays a manual owner action in the official app. The live reply adapter (replies.py, see above) was owner-approved on 2026-10-09 and is gated off in the registry until a live receipt (reachweaver_reply_adapter: none). It reuses the ordinary approval, receipt and ambiguous-send rules. See ENGAGEMENT.md.
Read-only reply fetch (issue #17 slice)
Section titled “Read-only reply fetch (issue #17 slice)”reachweaver/engagement_fetch.py turns the public replies under your own published posts into a reachweaver.engagement-export.v1 file for engagement-triage. CLI engagement-fetch [--online --out FILE] [--account ID] [--post-id ID] [--max-posts N] [--depth N] [--max-replies N] [--quotes [--max-quotes N]] opens the database with storage.connect_readonly (SQLite mode=ro; a missing database is exit 2, an older schema is read as it is and never migrated). Without --online it prints the planned requests (no network, no file).
posts (published, remote_id) + accounts.json -- metrics_fetch.select_posts (shared eligibility, newest first) +-> bluesky -> GET public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri&depth&parentHeight=0 no auth | root author DID == URI DID; breadth-first walk <= depth, <= max_replies | (--quotes) -> GET .../app.bsky.feed.getQuotes?uri&limit[&cursor] no auth, <= 5 cursor pages, | <= max_quotes; record must embed the post; kind quote_post, in_reply_to null +-> mastodon -> GET {server}/api/v1/statuses/:id (owner check) -> GET .../:id/context no auth descendants linked to the post via in_reply_to_id, <= depth, <= max_replies; HTML -> text | engagement.redact_private_data on <= 8000 chars, then cap 4000 (split fragments dropped); dedupe; <= 5000 items vengagement.load_export (same contract as triage) -> privacy.write_private_file (NEW 0600 file)Invariants: every request goes through publishers.request_get_json without headers (GET, no redirects, bounded body and timeout, sanitized http_NNN errors), so no credential is ever read or sent. The module writes no database row and imports no publish, claim or cooldown code. A 429 raises metrics_fetch.RateLimited, which stops the run; the provider delay is reported read-only and no cooldown event is written. Tests replace publishers._send_http and assert GET-only, header-free requests, the 0600 mode and an unchanged database. See ENGAGEMENT.md.
Privacy lifecycle: redaction, export and retention (issue #26 slice)
Section titled “Privacy lifecycle: redaction, export and retention (issue #26 slice)”reachweaver/privacy.py is offline and stdlib-only. redact() removes bearer/basic credentials, JWTs, Telegram bot tokens (including /bot<token>/ URLs), Bluesky app-password shapes, GitHub ghp_/github_pat_ tokens, URL user-info passwords, secret-named key=value pairs and, verbatim, the values of any REACHWEAVER_*_TOKEN|_PASSWORD|_APP_PASSWORD|_BOT_TOKEN|_SECRET|_API_KEY environment variable; emails are optional. structured_log() writes one sorted JSON line with a UTC ts, redacting every string value (emails by default); worker_log.py uses it for publish --log-json. export_data() returns posts, events, campaign variants, metric snapshots and settings with schema_version; data-export --out creates a new 0600 file with O_EXCL/O_NOFOLLOW and never overwrites.
Retention is two-phase. plan_purge(con, policy, now) is read-only; apply_purge(con, plan) runs in one BEGIN IMMEDIATE transaction with PRAGMA secure_delete=ON and re-checks every hard rule in SQL, so a stale or edited plan cannot widen the purge:
| Policy key | Effect when older than N days | Never touched |
|---|---|---|
metric_snapshots_days |
delete snapshot rows | — |
event_log_days |
delete audit rows | send_claimed newer than 48 h; events of sending/needs_review posts |
cancelled_drafts_days |
delete cancelled post (+ its snapshots/events) | posts referenced by campaign_variants or with any send_claimed event: content becomes [purged] instead |
published_content_days |
content → [purged] |
id, account, platform, remote_id, remote_url, status, timestamps |
campaign_evidence_days |
evidence_json minimized to evidence ids |
variant rows and fingerprints (idempotency); variants of active posts |
Posts in draft/approved/scheduled/sending/needs_review are never altered or deleted. A privacy_purge event records only counts. claim_sending, transition, reconcile and the rolling budget are unchanged. privacy-status reads PRAGMA database_list for the DB path and reports file/dir modes, size, row counts, oldest records, retention configuration, the names (never values) of secret env vars present, and live-safety switches. See privacy lifecycle.
Campaign links, metric definitions and attribution (issues #15, #25)
Section titled “Campaign links, metric definitions and attribution (issues #15, #25)”reachweaver/attribution.py is pure and offline. build_utm_link normalizes a public HTTPS destination: it lowercases the host, drops the fragment, utm_* parameters and click trackers, and sorts the remaining query. It then appends utm_source, utm_medium=social, utm_campaign, an optional utm_content and an optional utm_term in a fixed order. The result is deterministic and idempotent, and an optional exact-origin allowlist is enforced. A versioned per-platform metric table (metric_definition) records the native field, definition, denominator, availability, source URL and verified_date only where the source was fetched and checked (LinkedIn entries describe member post analytics). Every other metric reports unknown. Snapshots may carry an optional UTC observation window; report adds per-platform window_coverage without changing its other keys. WEEKLY-REPORT-EXAMPLE.md is regenerated from SYNTHETIC fixtures by its test.
attribution_report reads the posts and campaign_variants tables (SELECT only) and joins them to an owner-exported analytics CSV and saved GitHub traffic JSON files. The output keeps three classes apart:
- exact post- or campaign-level UTM matches. These require a published post and a consistent source, medium and campaign.
- domain-level
referrer_onlyrows, which are never credited to a post. unattributedrows, grouped by reason.
Blank sessions and conversions stay None and are counted separately from known values. Landing URLs pass through report_landing_page before they reach a bucket’s landing_pages: only scheme, host, port, path and the canonical utm_* parameters survive, and the rest goes through privacy.redact, so tokens and e-mail addresses in an exported URL never reach the report (#26). Coverage gaps and non-causal warnings are always included. load_seo_briefs and campaign_destinations implement the SEO integration contract. They take a local JSON file (credential-like keys are rejected) and return the highest-priority canonical landing page. They also flag duplicate canonical URLs, and overlapping non-ended campaigns that share a URL or primary keyword. None of this code makes network calls, writes to the database, changes the queue or publishes. See ATTRIBUTION.md.
reachweaver/web_analytics.py (roadmap P2, 2026-10-20) parses owner-downloaded Google exports for attribution-report --ga4-csv and --gsc-csv: a GA4 report or exploration CSV (leading # comment lines, optional # Start date/# End date, first table only, totals row kept apart) and Search Console Performance tables (Pages, Queries, Dates, Filters). It is a pure parser: it imports only policy and privacy, opens no database and makes no network, Google API or OAuth call. Files are capped at 16 MiB and 100,000 data rows; unknown, duplicate or missing columns, ragged rows and bad quoting are refused with messages that name a line and column but never a cell value; numbers are locale-tolerant and a blank cell stays None. combine_ga4 refuses overlapping GA4 periods and combine_gsc a second table of the same kind. The join stays in attribution.py: attribution_report(..., ga4=, gsc=) runs GA4 rows through the same _classify UTM join as analytics rows into a separate ga4 section (sessions, engaged sessions and key events, never added to --analytics sessions), and matches Search Console pages to destinations linked from published posts by host and path into a search_console section that is search-side context only, never credited to a post. Both sections are labelled owner-supplied with their export date range. See ATTRIBUTION.md.
Offline media manifest validation (issue #18 slice)
Section titled “Offline media manifest validation (issue #18 slice)”reachweaver/media.py is a read-only, offline pre-publication gate for images, videos and subtitle sidecars declared in a JSON manifest. It parses real headers with the standard library only: PNG IHDR, JPEG SOF, GIF, WebP VP8/VP8L/VP8X, and ISO BMFF ftyp/moov/mvhd/trak/tkhd/hdlr, applying the rotation matrix. For video it also reads the stsd sample-entry fourcc of the first video and audio track (H.264, HEVC, AV1, VP9/VP8, AAC via the esds object type, Opus, AC-3/E-AC-3), the average frame rate from mdhd timescale plus stts deltas (variable frame rate only when the deltas show it), the audio sample rate and channel count, and whether moov precedes mdat. The ISO BMFF walk is bounded: a 64 MiB moov read cap, 16 levels of container nesting, 200,000 boxes, 1,000,000 stts entries, and a stop at the first malformed box size. Each guard that fires becomes a warning and leaves the value unknown. It also checks file size, extension against magic bytes, and SRT/WebVTT well-formedness and monotonic timing. Translated subtitle tracks (a subtitles[] locale other than the asset’s) are compared cue by cue with the asset’s first well-formed source-locale track by compare_subtitle_tracks: cue count, missing or extra cues and start/end drift beyond subtitle_timing_tolerance_ms (default 500) are errors (warnings for a track marked alignment: "independent"), and a dropped preserve_terms entry, number or URL is a warning. Values it cannot parse are reported as unknown, and the checks that depend on them are skipped with warnings. CONSTRAINTS is a per-target, per-kind table in which every row carries a value (or None when official docs do not establish one), a source URL, a verified_date and a severity. None rows are never enforced and always produce a skip warning. Codec and frame-rate rows are errors for Instagram Reels and TikTok (documented codecs, 23–60 fps checked against the average) and warnings for YouTube’s recommended encoding. They are unknown for Bluesky, Mastodon and LinkedIn’s Videos API. Accessibility rules (alt text required for images, sidecar captions for videos), rights and provenance rules (rights_confirmed must be true, third-party media needs a license and attribution, tool output needs a tool name) and English-first locale and translation warnings are applied per asset. Paths must stay inside the manifest directory. media-check MANIFEST [--json] returns 2 on errors. The module creates no SQLite rows, makes no network calls, and performs no upload, render, transcode or generation. Media publishing remains unimplemented; see MEDIA-ASSETS.md.
reachweaver/storyboard.py builds on it without changing its rules. build_storyboard reads one campaign file and returns a deterministic scaffold: timed scenes (hook, up to three evidence beats, CTA), SRT (and, for WebVTT-only targets, WebVTT) skeletons that validate_subtitle_text accepts, a schema-1 media manifest and per-platform image specs copied from CONSTRAINTS. Every creative field is a {{write: ...}} placeholder and rights_confirmed is false; the builder refuses to return a manifest that validates or that fails for any reason other than missing files, placeholders or unconfirmed rights. check_safe_area maps each target’s UI-overlay zone (fractions of a 9:16 viewport) into frame coordinates for a cover (centered crop) or contain (letterbox) fit and flags boxes outside it: text is an error, a focal box a warning. Built-in SAFE_ZONES are sourced from first-party paid-ad specs (TikTok’s In-Feed safe-zone template, Meta’s Reels ad specs, the Google Ads vertical-video safe-zone image; accessed 2026-10-09) and carry placement: "ads", the stated numbers and the evidence; each check warns that organic overlays may differ. --safe-zones FILE overrides them, and an entry counts as owner_sourced only with a public https source and a verification date; otherwise it stays unverified and every check that uses it adds a warning. --check re-reads an edited storyboard with zones from the built-in table or the override, never from the edited file. CLI media-storyboard is an early branch that returns before the connect block; it writes a NEW 0700 folder of 0600 files and makes no network call. validate_media_manifest now also rejects a subtitle file that still contains a placeholder.
Lexical claim grounding (issue #10 slice)
Section titled “Lexical claim grounding (issue #10 slice)”reachweaver/claims.py is a pure, deterministic, offline checker that validate_campaign calls after its structural checks. The same checker runs alone as claims-check CAMPAIGN --brand BRAND [--json]. For each variant it extracts number-like tokens and risky phrases from the NFKC-normalized text, with URLs masked. It then matches them against the claim and optional excerpt text of the evidence IDs that variant cites. A token or phrase passes if a match is found. A finding records its severity, the original-text span and offsets, and the evidence IDs that matched.
- Error (blocks staging and export): unsupported numbers, percentages, versions, dates or multipliers; release language without cited
kind: "release"or releases-URL evidence; a brandbanned_phraseshit; a missing brandrequired_disclosure. - Review (
needs_human_review, shown as a warning): testimonial or quote, partnership or endorsement, customer-count and superlative language that cited evidence does not lexically contain, and feature claims (category: "feature").find_feature_claimsreads the object of a trigger such asadds,now supportsorintegrates withup to the clause end (at most 8 words), drops stopwords, generic words and numbers, and requires each remaining word, with case and simple plurals folded, in cited evidence. Negated and interrogative claims are skipped.landing.pyskips groundedfeaturespans. - Warning: near-duplicate variants (word 3-gram Jaccard ≥ 0.6 by default); a declared
cta_urlorcta_phrasemissing from the text; with--history-db,history_near_duplicate(same account’s local posts) and thehistory_frequencyheuristic (reachweaver/history.py).
The contract extensions are optional and backwards compatible: evidence excerpt and kind, campaign or variant cta_url, variant cta_phrase, and brand banned_phrases and required_disclosure. This is lexical grounding, not truth verification. The checker never fetches sources and trusts the excerpt as typed. See claim grounding.
Opt-in source ingestion and opportunity ranking (issue #9)
Section titled “Opt-in source ingestion and opportunity ranking (issue #9)”reachweaver/sources.py reads an explicitly opted-in source list (sources.reachweaver.json). Each source declares its sensitivity. The readers parse GitHub REST JSON (releases, tags, merged PRs, closed-as-completed issues with owner-selected labels), RSS 2.0/Atom feeds, website changelog pages (html.parser, <article> or <h2> sections), Keep a Changelog CHANGELOG.md files and Crossref DOI records, and normalize them into ranked SourceSignal dicts. Each signal has a stable sha256 ID, an RFC 3339 date, evidence URLs, a cleaned and truncated summary, confidence, uncertainties, exponential-decay freshness, novelty (from a local seen-state file and near-duplicate titles) and keyword relevance. The rank breakdown is transparent. The scan runs before with connect, so it never touches the SQLite outbox and cannot draft, approve, schedule or publish. Its only possible write is the owner-chosen --state file, written with --mark-seen.
The default is offline: the scan reads only local fixtures. --online makes GET requests through publishers._send_http (no redirects), with bounded reads and sanitized error codes. GitHub requests must first confirm that the repository is public. List endpoints follow RFC 8288 rel="next" links (read through the header-aware _fetch_response helper) only while they stay on https://api.github.com, up to the per-source max_pages (default 3, max 10). Non-API web fetches (feeds, changelogs) consult robots.txt once per host per run (RobotsCache) and skip the source as robots_disallowed. --online DOI sources GET https://api.crossref.org/works/{doi}, adding mailto only when the owner sets crossref_mailto. The optional REACHWEAVER_GITHUB_TOKEN is sent only to api.github.com and is never printed. Feeds are rejected before parsing if they contain DOCTYPE/ENTITY declarations or use an encoding that could hide them. Private sources, bot or trivial PRs, drafts and prereleases (unless opted in) are excluded. Instruction-like source text stays inert data: it only lowers confidence. See SOURCES.md.
reachweaver/briefs.py (signal-brief) is the hand-off from a signal to the campaign contract. It reads a saved sources-scan --json file as untrusted input and normalizes one signal again: clean_text, a fresh INSTRUCTION_LIKE check, URL checks through campaigns.public_url plus a Markdown-safe character set, and scores only in 0..1. It then builds a deterministic scaffold. Evidence records carry the signal URLs with a mapped kind, the summary as an excerpt marked untrusted, and checked_by: "unverified", with no verification or checked_at. Each account in both the accounts config and the brand’s approved_accounts whose platform has a live or manual path (preview.capability over platform_registry.json, or manual_export.MANUAL_PLATFORMS) gets one variant whose text, angle and cta are {{write: ...}} placeholders. audience and each claim are placeholders too. campaigns.validate_campaign rejects a placeholder in those fields, and validate_post rejects one in post text, so the scaffold cannot be staged. build_brief runs validate_campaign itself and refuses to write a scaffold that would pass. The sidecar .brief.md shows source text only inside a fenced data block whose fence is longer than any backtick run in that text. Both files are written with privacy.write_private_file (new, 0600, O_EXCL/O_NOFOLLOW). The command runs before with connect, so it opens no database, and it uses no network and no model. No copy is generated, and nothing is staged, approved, scheduled or published.
reachweaver/release_brief.py (release-brief, release-to-draft) applies the same hand-off to a release. Its input is a saved GitHub REST release object (the user runs gh api ... > release.json; the module itself has no network path) or one Keep a Changelog section. The file is read size-capped as untrusted data: a draft or a list is refused, html_url must pass campaigns.public_url plus the Markdown-safe character set and be a /releases/ page on github.com, the tag must match a conservative pattern, and --repo must match the URL. The body is reduced to at most 25 plain-text highlights of at most 240 characters (HTML comments, fenced code and images removed, link text kept, then sources.clean_text). A line that matches sources.INSTRUCTION_LIKE or is addressed to an AI assistant is withheld: its text is replaced by a fixed marker in both outputs, never quoted. The scaffold has one kind: "release" evidence record with release_tag, release_published_at, an untrusted excerpt and checked_by: "unverified" (no verification/checked_at), and, with --brand/--accounts, the same placeholder variants and budgets as signal-brief (briefs.account_capability, briefs._budget). build_release_brief refuses a scaffold that validate_campaign would pass. write_release_brief creates a NEW 0700 folder and two NEW 0600 files through privacy.write_private_file, removing them if a write fails. The command runs before with connect, so no database is opened. examples/github-workflows/release-to-draft.yml is a template for users’ repositories, deliberately outside .github/workflows/: contents: read, the default token only, SHA-pinned actions, and an artifact upload as its only output (tests/test_release_to_draft_workflow.py).
reachweaver/private_sources.py (issue #9, owner-approved 2026-10-09) holds the exfiltration guards for private GitHub sources. A private repository is read only with all three opt-ins: visibility: private and sensitivity: private on the source, plus sources-scan --allow-private. Without them, sources.scan_sources skips the source before any read, or refuses it after the metadata request (repository_not_confirmed_public). Private signals carry private: true and publishable: false. briefs.build_brief copies that to evidence as source_visibility: "private" and scaffolds no cta_url. campaigns.validate_campaign refuses such evidence until a human sets publishable: true, and it refuses any link into the private repository. cli._private_link_refusal applies the same rule to draft, media-attach, youtube-attach, check and approve, but only when --sources CONFIG is passed; without it those commands do not know the private repositories and cannot detect such a link. attribution.build_utm_link(private_repos=...), preview.utm_rewrite and utm-link --sources refuse to tag those URLs. privacy.export_data and campaigns.export_campaign redact private evidence excerpts and URLs. token_scope_report classifies a classic token’s X-OAuth-Scopes header by scope name. A missing header (fine-grained tokens) means “scope unknown”. The module imports only policy and touches no network, credential, approval or publishing path.
Review packets and reviewer comments
Section titled “Review packets and reviewer comments”reachweaver/review_packet.py (review-packet, review-comment, review-status) lets a co-reviewer comment on the exact text of stored posts. Every command opens the database only through storage.connect_readonly (mode=ro, PRAGMA query_only), so it cannot write posts, event_log or any other table and never creates or migrates a database. review-packet writes a NEW 0700 folder with packet.json, README.md and one post-ID.md/post-ID.json per post through privacy.write_private_file (removed again if a write fails). The check section is the check --json document, built by the shared cli._check_findings/_check_report helpers that check itself uses; cli.py passes them in so the module never imports cli. The digest is storage.approval_payload_sha256(row, scheduled_at=None, use_row_schedule=False) on a copy of the row with approval_nonce and approved_identity set to None: for a draft it equals show’s approve_would_bind_sha256, and it stays the same across approve and schedule. Private-source handling reuses private_sources (redact_private_evidence, url_in_private_repo) with the repos from --sources plus those behind private evidence in campaign_variants, as data-export does; a post whose published strings link into such a repo, or match privacy.redact, is refused. Comments live in the packet’s comments.json (0600, replaced with os.replace from a NEW temporary file), bound to the packet digest; review-status compares that digest with the current one and reports stale. No approve-side module (storage, ledger, confirmation, sendgate, publishers, post_view) imports the module, and the approve branch of cli.py does not mention it; tests/test_review_packet.py asserts both, and that approve succeeds with the review readers patched to fail.
Claude Code helpers: ideas inbox and status line
Section titled “Claude Code helpers: ideas inbox and status line”reachweaver/ideas.py (idea add|list|drop|promote) keeps an ideas inbox in a private JSON file, ideas.json, next to the database (storage.database_path(--db).parent, the same place as the approval ledger). It is deliberately not a table: an idea has no account, platform or approval state, so nothing in the post queue, schema_gaps, migrations or the approval path changes, and adding an idea never creates or opens the database. Writes take an fcntl lock on a hidden .ideas.json.lock, refuse a symlinked or non-private file or folder (same 0700/0600 rule as storage.connect), and replace the file atomically through a new 0600 temporary file. Input is capped (text 2000 characters, 8 tags, 2000 ideas, 8 MB file); a source must pass campaigns.public_url, and text that privacy.redact would change is refused. promote builds a scaffold in the campaign contract shape (idea text in brief.idea_text, {{write: ...}} placeholders for audience, claims and post text, checked_by: "unverified" evidence without verification/checked_at), runs campaigns.validate_campaign and refuses to write a scaffold that would pass, then writes a NEW 0700 folder with two privacy.write_private_file files. It never calls stage_campaign or add_post (SEND_PATH = None, asserted in tests). The privacy commands cover the file through privacy.ideas_file(con) (the directory of the database con opened): data-export adds an ideas_inbox block with the same redaction as the tables, privacy-status reports its mode, size and counts (never text), and data-purge plans closed_ideas_delete for dropped or promoted ideas older than the optional closed_ideas_days; --apply calls ideas.remove_closed, which re-checks the status under the inbox lock and never removes an open idea. workspace lists the file with its permissions. idea "a" "b" goes through ideas.add_many: every text is checked first, then all are appended under one lock and one atomic replace (all or none). At a terminal, ideas.after_saving prints rank 1 of whats_next.build_next(limit=1) (read-only, the same ranking next prints).
reachweaver/statusline.py (statusline) renders one line from SELECT status, COUNT(*) FROM posts and the paused setting, opened with SQLite mode=ro, PRAGMA query_only and a 0.25 s busy timeout; a missing database prints RW: no workspace and nothing is created. It imports only home, storage and ideas; python3 -m reachweaver.statusline runs it without importing cli, and cli.main routes a leading statusline argument to it before building the full parser. It never reads stdin, prints only counts and the version, and turns every error into a short RW: ... line with exit 0.
The plugin’s read-only fact-check subagent (plugin/agents/fact-check.md, hand-maintained like plugin/hooks/hooks.json; scripts/sync_skill.py only mirrors plugin/skills/) has the tools Read, Grep, Glob, WebFetch and no shell. It is not copied to .claude/agents/; repository users get it through the plugin.
Milestone follow-up suggestions (followup-suggest)
Section titled “Milestone follow-up suggestions (followup-suggest)”reachweaver/followups.py validates an owner rules file (metric from storage.METRIC_FIELDS, integer threshold, optional within_hours, platform/account filters, one of five kinds; unknown keys, duplicate ids, non-public links and human-only cross-post targets are refused, all or nothing) and opens the database with storage.connect_readonly after a schema_gaps check on posts, metric_snapshots and event_log. It selects published top-level posts (reply_target IS NULL; publish time as in journal_posts) and their snapshots, and for each rule takes the latest observation whose metric is exact per storage.metric_states (no later than publish time + within_hours), so estimated, suppressed, delayed and unavailable values are never compared. Posts on Reddit, Hacker News and every registry human_only platform are skipped, and those platforms are never cross-post targets. @-mentions of the original are matched against optout.listed_entry; listed ones are counted, never named, and post text is never printed. The output is a brief plus next commands of the normal flow (show, draft, repeat-check, idea add); the module imports no publisher, approval or network code (SEND_PATH = None, asserted). The optional --state file holds (post_id, rule_id, recorded_at) only, refuses a symlink or non-private file, and is replaced atomically with mode 0600 only under --record. See FOLLOWUPS.md.
Engagement scout (engage-scout, engage-log)
Section titled “Engagement scout (engage-scout, engage-log)”reachweaver/engage_scout.py is the offline core: strict config validation (reachweaver.engage-scout-config, unknown keys refused, inputs.loads_text nesting limit, bounded reads), candidate validation in one owner-file shape (reachweaver.engage-candidates) that the online adapters also produce, and a pure scout() pipeline: hard filters (own posts, optout.listed_entry, quiet accounts, bots, authors’ not-discoverable signals, already marked or recorded, locked/deleted/reply-restricted, too old, spam/harassment/“do not contact” via engagement.analyze, low relevance) -> five shown factors (relevance from discovery._normalize phrase matches, half-life freshness, follower/reply audience fit, contribution signals, risk) -> caps (daily, same account per run and per window, link ratio from the recorded replies) -> shortlist. Platforms that distribution_registry.json marks human_only (plus Reddit, Hacker News and DEV) go to read_it_yourself with no angle and no link. Post text passes engagement.sanitize_text, redact_private_data and a per-line withholding of instruction-like text before any use; the angle is built only from config terms. The module imports no publisher, approval, ledger or network code (only storage.parse_utc from storage) and never opens the database (SEND_PATH = None, AUTO_REPLY = False; subprocess and AST import tests). The state file (--record, engage-log --mark) holds ids, platforms, timestamps, a link flag and a 16-character author hash, refuses a symlink or non-private file and is replaced atomically with mode 0600. readout() lines recorded replies up with owner-recorded outcome snapshots per week and labels the result correlational.
reachweaver/engage_sources.py holds the three --online adapters (Bluesky searchPosts + getProfiles on api.bsky.app, a Mastodon hashtag timeline on the configured server, GitHub /search/issues). get_json builds the only Request (method GET, User-Agent and Accept headers only, no body), refuses non-HTTPS or non-allow-listed hosts, follows no redirect, uses a 15 s timeout and a 2 MB cap, and turns 429 (or GitHub’s 403 with no remaining quota) into a stop with no retry. Tests replace _open with recorded synthetic fixtures under NetworkGuard. Sources and their 2026-10-21 verification are in ENGAGE-SCOUT.md.
Fail-closed platform capability registry (issue #6)
Section titled “Fail-closed platform capability registry (issue #6)”reachweaver/platform_registry.json is a versioned, offline research snapshot with one entry for every platform in policy.PLATFORMS. Each entry records its official docs, account types, auth method and scopes, approval requirements, format statuses (api_direct/api_gated/manual_export/unavailable/unknown), metrics, edit/delete, rate limits with a source, fees, compliance constraints, verified_date and HTTPS sources. reachweaver/capabilities.py validates completeness, enums, ISO dates and provenance. It also checks that the live_text adapters exactly match policy.SUPPORTED_LIVE and that Reddit keeps its moderator-only, human-only constraint. require_capability(platform, fmt, action) raises ValidationError for any unknown platform, format or action, and for any status other than api_direct; publish also needs a text live_text adapter, or an image format listed in the entry’s optional live_media_formats (none is). Video goes through require_video_mode and replies through require_reply_capability (reachweaver_reply_adapter), both off in the bundled registry. publishers.publish() and the publish --live loop call it before identity preflight, the durable claim or any network request. This is a strengthening guard only: approval, dry-run, emergency stop, credential isolation and ambiguous-send reconciliation are unchanged. The offline capabilities CLI lists the registry, exits nonzero if it is invalid, and warns on entries older than --max-age-days (default 120). The skill bundle carries a byte-identical copy. The registry never grants permission. See PLATFORMS.md for the table, the X/Reddit/Pinterest go/no-go decisions (#24) and the update process.
Preregistered growth experiments (issue #16 slice)
Section titled “Preregistered growth experiments (issue #16 slice)”reachweaver/growth.py is a pure, offline guard layer around the matched-age readout in experiments.py, and reachweaver/growth_readout.py connects it to the database read path and adds simulation. growth.py has four parts:
check_variant_setaccepts 2–3 arms with exactly one control. Every arm declares onediffers_indimension (hook,framing,format,ctaortiming) that equals the plan dimension. Declared labels and the CTA must match the control in every other dimension. Links in the text (host + path, query ignored) must match unless the CTA is under test. Arms that are near-duplicates underclaims.jaccardare rejected.preregistervalidates the plan. The hypothesis must not overclaim. The cohort is one account and one platform. The primary metric comes from a qualified-signal allowlist, and vanity metrics are allowed only as guardrails. The plan also needs an observation window,minimum_pairs(with a warning below the 6-pair sign-test heuristic), a stopping rule that cannot stop early on a win, and notes acknowledging six named confounders. It returns a record with a sha256plan_hashover canonical JSON and a self-reportedregistered_at.write_preregistrationcreates a new 0600 file withO_EXCL/O_NOFOLLOWand never overwrites.verify_preregistrationrecomputes the hash and compares it in constant time.classify_resultmapsevaluate_experimentoutput toinsufficient_data,inconclusiveordirectional. Adirectionallabel requires the preregistered minimum and at least 6 pairs, a strict-majority direction with a descriptive sign-test p of at most 0.05 (MAX_DIRECTIONAL_SIGN_P), the minimum useful lift and no guardrail breach. It flags first-pair novelty and single-outlier dominance. Noprovenlabel exists. A plan may declareassignment(randomized,alternatingorunrandomized), which is covered by the hash.next_hypotheseskeeps negative results and suggests the next dimension using fixed, ordered rules: finish pending, replicate a single positive, take the next untested dimension, then the least tested. It is not a learned recommender.
growth_readout.py adds four pieces:
growth_plan_to_experiment(record, post_map, con)refuses a record whose hash does not verify. It also refuses a post that is unknown, unpublished or outside the cohort’s account and platform. Otherwise it copies metric, denominator, window, tolerance, thresholds and minimum pairs into anexperiment-evaluateplan that carries theplan_hash. It onlySELECTs.describe_designreturns the readout’sdesignblock. Only an owner-declaredrandomizedassignment is more thanassociational, and it is neververified.run_readoutruns verify → convert →evaluate_experiment→classify_result.simulate_readoutusesrandom.Random(seed)to build in-memory synthetic pairs and reports the label distribution with Wilson intervals. It never touches the database.
experiment-preregister PLAN --out FILE, experiment-verify PLAN --hash H, experiment-next --results FILE and experiment-simulate ... are early offline CLI branches: they return before the SQLite outbox is opened. experiment-readout --prereg FILE --posts MAP runs inside the connect block and only reads. Neither module imports publisher, connection or network code. Registration has status: registered_not_approved. Publishing every arm still goes through per-item approval, the dry-run default, per-account caps, the emergency stop and ambiguous-send reconciliation, none of which change. The hash proves the plan is unchanged, not when it was written. See GROWTH-EXPERIMENTS.md.
Optional Postiz publishing backend (issue #30 slice)
Section titled “Optional Postiz publishing backend (issue #30 slice)”reachweaver/postiz.py adds one opt-in backend: an account entry with "backend": "postiz" keeps its real platform and adds postiz_base_url (public HTTPS origin only, via policy.require_https_public_server), optional postiz_api_prefix (/public/v1 cloud or /api/public/v1 self-hosted), postiz_integration_id and handle. The only credential is {env_prefix}_POSTIZ_API_KEY, read at request time. Wiring is additive: accounts.load_accounts delegates validation for backend entries, missing_credentials returns the backend key name, publishers.publish() dispatches backend accounts to postiz.publish, connections.check_connection refuses them, doctor prints backend=postiz, and the publish --live loop hands a backend post to postiz.run_live_send. Accounts without backend follow the unchanged native path.
run_live_send keeps every existing gate: --live plus REACHWEAVER_ALLOW_LIVE=1 and the production marker (checked by the CLI before any account is loaded, issue #5), an approved and due post, the emergency stop (checked before any request and again inside claim_sending), text lint and the rolling budget. Before the durable claim it runs the opt-out gate (issue #26; an @-mention of a listed handle or domain is BLOCKED), a fail-closed backend capability gate (a Postiz provider mapping, registry text api_direct/api_gated, platform status supported/requires-review; Reddit always refused, X refused until #24) and a read-only GET /integrations identity preflight (configured id exists, provider maps to the platform, profile equals handle, not disabled). Preflight failure prints BLOCKED and leaves the post approved. After the claim it sends one POST /posts with type: now, one integration, the exact approved text and shortLink: false. Postiz draft/schedule and Postiz approvals are never used, so pause stays authoritative.
A Postiz acceptance is not a platform receipt. A well-formed [{postId, integration}] response raises postiz.BackendAccepted (a PublishError). The CLI records needs_review, remote_id = postiz:<postId>, remote_url = NULL and last_error = postiz_accepted_platform_receipt_unverified, plus a needs_review event with the same detail. Every other post-claim outcome (HTTP 4xx/5xx, timeout, malformed or mismatched response, unexpected exception) is needs_review with a sanitized postiz: code, and nothing is retried. The storage schema and state machine are unchanged: sending -> needs_review already exists, and only a human reconcile with the native id/URL can reach published. postiz-check --account ID --online [--post-id ID] is read-only: it lists integrations (id, identifier, profile, disabled) and, optionally, looks up the job’s backend-reported state and releaseURL (public HTTPS only) via GET /posts; it never reconciles. Requests go through publishers._send_http (no redirects, 25 s timeout, 1 MB cap). Errors carry status codes only, never the key or a response body. The publishing-path contract (preview, create, receipt lookup, reconciliation hand-off) is declared as publishers.PublishingBackend, with publishers.NATIVE_PATH and postiz.POSTIZ_BACKEND as its two implementations and publishers.backend_for naming the path an account selects; it is declarative and changes no dispatch, and tests/test_postiz_contract.py checks both paths against it. For a backend account, show uses that contract’s offline preview as its platform_preview (cli._platform_preview): no key and no network. It never shows a native record that the account would not send. Verified API facts, unknowns, AGPL-3.0 licensing and the MCP decision are in POSTIZ.md. No Postiz instance or account has been connected or live-tested.
Bluesky and Mastodon adapter hardening (issues #12, #13 slice)
Section titled “Bluesky and Mastodon adapter hardening (issues #12, #13 slice)”reachweaver/richtext.py is pure and offline. It holds the conservative grapheme segmenter (a stdlib unicodedata approximation of UAX #29 that never under-counts), Bluesky link and hashtag facets at UTF-8 byte offsets, plain-text mention detection, the deterministic TID record key and the Mastodon URL-weighted length. policy.validate_post(platform, content, account=None) uses these for Bluesky’s real limits (300 graphemes, 3000 UTF-8 bytes). For Mastodon it uses min(450, account max_characters). Draft creation, campaign staging, check, the publish --live preflight and publishers.publish() all pass the account, so a pinned instance limit can only tighten the check.
Bluesky requests go to accounts.bluesky_service(account): the default https://bsky.social or an owner-set service, re-validated by require_https_public_server before any credential is read. Session didDoc endpoints are never followed. createRecord sends a TID-syntax rkey derived from the post id and the draft created_at, so a re-send of the same post should collide on the PDS rather than duplicate. That expectation comes from the AT Protocol repo semantics and has not been observed live. Mention facets are never built, because they need DID resolution and ReachWeaver performs none. Mastodon sends a minimal form body: status, visibility (public, unlisted or private; direct is rejected at config load and again in the adapter), plus optional owner-pinned language and spoiler_text. It keeps the Idempotency-Key, which Mastodon honours for one hour only.
Receipts are checked after the request and every mismatch is uncertain. A Bluesky uri must name the session DID, the app.bsky.feed.post collection and the requested rkey, and cid must be present. A Mastodon receipt needs id and an HTTPS url/uri, and any returned account.username and visibility must match. Every such error is raised as a PublishError after the durable claim, so the CLI moves the post to needs_review and never retries. Adapters return publishers.Receipt, a two-tuple subclass whose sanitized extra fields (Bluesky cid, Mastodon visibility) storage.finish_send appends to the published audit event. No schema migration is needed.
connect-check --online for Mastodon makes one more call, an unauthenticated GET /api/v2/instance (connections.mastodon_instance_limits). It reports status and media limits and exits 2 when the effective text limit would exceed the instance’s. It never writes config. show adds an offline platform_preview with the exact destination details: service, handle and record key for Bluesky; server, handle and visibility for Mastodon. Approval, dry-run, emergency stop, credential isolation and ambiguous-send reconciliation are unchanged. See the adapter details in PLATFORMS.md.
Manual export for review-gated platforms (issues #19-#23 slice)
Section titled “Manual export for review-gated platforms (issues #19-#23 slice)”reachweaver/manual_export.py is the offline creator-draft fallback for Threads, Instagram, Facebook Pages, YouTube and TikTok. All five need app review or an API audit that the owner has not obtained. Each now has a mock-tested adapter (Threads #19, Instagram #20, Facebook Pages #21, YouTube #22, TikTok #23; owner-approved 2026-10-09, disabled until a live receipt), and the capability registry keeps every one unreachable, so manual export stays the publishing path. platform_preflight(platform, variant, account_meta=None) returns blocking errors and warnings from dated, sourced official limits (FACTS). These include Threads’ 500 characters and 5 links; Instagram’s 2,200-character, 30-hashtag and 20-@-tag caption; YouTube’s 100-character title, 5,000-byte description and </> ban; and TikTok’s 2,200 UTF-16-unit caption. Preflight also enforces the required media format and rejects any Facebook target that is not a Page (profiles and groups are refused). Facts that the official pages did not establish are unknown; facts on which they disagree are conflicting (the YouTube unverified-project private lock). Neither kind is ever relied on. build_export validates an optional media manifest through media.validate_media_manifest instead of duplicating media limits. It records the registry status and the refusal from require_capability(..., "publish"). It lists human_decisions (TikTok privacy, interactions, commercial and AI labels; YouTube privacy, made-for-kids and synthetic-content disclosure) with selected: null, and ignores any variant value that tries to pre-select them. The status is always manual_export_not_published, and the wording never claims that a post exists. write_export and CLI manual-export write only to a new 0700 directory of 0600 files (O_EXCL/O_NOFOLLOW), and write nothing when preflight blocks. The module imports no publisher, connection or network code (SEND_PATH = None); it creates no SQLite rows and changes no approval, dry-run, emergency-stop, credential or reconciliation rule. See MANUAL-EXPORT.md.
reachweaver/manual_package.py (private #44, item C2) extends manual-export to every other manual_only destination in the distribution registry. It reads only registry fields: the label, the scheduling official_source when one exists, the capability registry’s compliance_constraints, and the media.py CONSTRAINTS table for the destination. A limit that none of them records is printed as limit unknown — check the platform, and the text limit always is, except where reachweaver/china.py has a sourced fact. For the twelve Chinese destinations in china.PROFILES, the package gains a china block: dated facts with a status (verified blocks; verified_client and verified_api only warn; unknown is not checked), CJK-aware counting (Xiaohongshu’s weighted title count), topic rendering to topics.txt without touching the post text, and ai_generated_label, advertising_label and originality decisions with selected: null. china.py has no send path or network import either (CHINA.md). human_only destinations get a NOTES.md notes form and no agent-written text (a variant text blocks). It uses the same status, new-directory and 0600 rules, and has no send path, delivery mode or network import (SEND_PATH = None). The five dedicated packages are pinned byte for byte by golden files in tests/fixtures/manual_export_golden/.
reachweaver/manual_batch.py and reachweaver/delivery_options.py were salvaged from draft PR #65 without its browser-handoff parts. manual-export-batch runs build_export or build_package for 1 to 25 items, writes nothing unless every item passes, refuses human_only destinations, and records the SHA-256 of each exact-text file in batch-manifest.json after re-reading it from disk. delivery-options is a read-only view over onboarding.blocker_audit (the source of the troubleshooting blocker table) plus an official_api_adapter route for the four mock_tested_text channels. Neither has a send path (SEND_PATH = None), and neither adds a delivery mode or a registry field. Every package’s exact text also passes the Trojan Source check of policy.validate_post (bidi controls and invisible format characters block).
LinkedIn member text publisher (issue #14)
Section titled “LinkedIn member text publisher (issue #14)”reachweaver/linkedin.py holds all LinkedIn logic; publishers.py only gains publish_linkedin (one PUBLISHERS entry) and a backward-compatible request_json_with_headers helper that returns (status, lower-cased headers, JSON or {}) with the same 25 s timeout, no-redirect opener, 1 MB body bound and sanitized http_<code> / network_or_timeout_uncertain errors as request_json. It exists because the Posts API returns the post URN only in the x-restli-id header, often with an empty body.
accounts.json (person_id, linkedin_version, optional authorization_expires_at, scope_check) env: {prefix}_ACCESS_TOKEN (+ _CLIENT_ID, _CLIENT_SECRET only with scope_check) | | v vload_accounts: member id pattern, YYYYMM format, ISO expiry, organization fields refused |publish --live (flag + REACHWEAVER_ALLOW_LIVE=1 + REACHWEAVER_ENV=prod, approved + due, not paused) |check_connection -> linkedin.verify_identity | offline: organization author? version >= 12 months? token expired? -> BLOCKED, post stays approved | GET https://api.linkedin.com/v2/userinfo -> sub == person_id (exact) else BLOCKED | only with "scope_check": "introspect" (opt-in): linkedin.verify_write_scope | POST https://www.linkedin.com/oauth/v2/introspectToken (form: client_id, client_secret, token) | active + 3L + own client_id + w_member_social + expires_at > now + 1 h else BLOCKED | (<= 1 request per account per run, <= 3 incl. retries; result in memory only, 15 min) vclaim_sending (BEGIN IMMEDIATE, rolling budgets) |POST https://api.linkedin.com/rest/posts (Linkedin-Version, X-Restli-Protocol-Version: 2.0.0) | body: author urn:li:person:{id}, escaped little-text commentary, PUBLIC, MAIN_FEED, PUBLISHED v201 + x-restli-id urn:li:share|ugcPost -> published (remote_id = URN, remote_url = /feed/update/{urn}/)anything else (non-201, missing/odd header, HTTP/network error) -> needs_review, never retriedInvariants:
-
Member only. The author is always
urn:li:person:{person_id}.urn:li:organizationvalues and any organization/page/author configuration field are refused at config load and again in the preflight.w_member_socialnever impliesw_organization_social. -
Version lifecycle. LinkedIn supports each monthly version for a minimum of one year.
version_statuswarns at 10 months and returns an error from month 12; a version in the future, malformed or older than the first versioned release (202206) is also an error. The live path fails closed on error.doctorreports the status offline. -
Token lifecycle. The optional, non-secret
authorization_expires_atfield records when the owner’s OAuth grant expires. Its value must parse as an ISO-8601 timestamp with an offset and it is accepted only on LinkedIn accounts. The unchangedSECRET_FIELDrule still rejects every key containingtoken,secret,passwordand so on, includingtoken_expires_at. Expired tokens are refused before any request; tokens within 7 days of expiry produce adoctorwarning. A missing value is reported as unknown. -
Literal text. Every little-format reserved character (
| { } @ [ ] ( ) < > # \ * _ ~) is backslash-escaped, so approved text cannot create mentions. A#at a word start followed by a letter or digit is kept as a documented hashtag element. -
Credential isolation. The bearer token is read through
accounts.credential(environment or$CREDENTIALS_DIRECTORY) at call time and never appears in error strings, which are fixed codes plus fixed guidance (401/403/426/429 hints) only. -
Ambiguity. The Posts API documents no idempotency key. Every post-claim failure ends in
_uncertain, which the CLI records asneeds_reviewfor human reconciliation. -
Opt-in write-scope check (owner-approved 2026-10-09). Only an account with
"scope_check": "introspect"reads{prefix}_CLIENT_ID/{prefix}_CLIENT_SECRET(both inaccounts.CREDENTIAL_SUFFIXES, so theenv_prefixoverlap rule always covers them;accounts.account_credential_suffixeslists them as required only after the opt-in). Introspection runs insidecheck_connection, before the claim, and fails closed on any refusal or HTTP error.publish_member_postre-checks the in-memory result offline beforemark_write_started. See SECURITY-REVIEW-PROPOSAL-14-linkedin-scope.md.
Known gaps: no OAuth wizard or refresh flow (LinkedIn currently issues 60-day access tokens), no default w_member_social scope probe before sending (userinfo proves identity only; the introspection check is opt-in), no media/article/document posts, no edit/delete, no analytics. The public post URL format is the one LinkedIn documents for ugcPost URNs and is assumed for share URNs; the URN is the receipt.
Instagram single-image adapter (issue #20, owner-approved 2026-10-09; disabled until a live receipt)
Section titled “Instagram single-image adapter (issue #20, owner-approved 2026-10-09; disabled until a live receipt)”Two new modules. reachweaver/remote_media.py handles owner-hosted image URLs: URL rules (HTTPS, public DNS host via require_https_public_server, port 443, printable ASCII, no userinfo, query or fragment), a read-only download (DNS answers must all be global addresses, no credentials or cookies, no redirects, at most 8,000,000 bytes, through publishers._open), JPEG header checks (media._parse_jpeg, aspect ratio 4:5 to 1.91:1) and the source: "url" record of the shared attachment model (source, url, sha256, mime, bytes, width, height, alt, focus; codec in attachments.py). reachweaver/instagram.py holds the Graph API logic. publishers.py gains publish_instagram, MEDIA_ONLY_PLATFORMS and media=/paused= arguments on publish(); connections.py gains _instagram. Both sit behind the unchanged SUPPORTED_LIVE gate and require_capability("instagram", "image", "publish").
Approval binding: the nullable posts.media_json column (migration) and, for a post with an attachment, the image payload (reachweaver.storage.approval/media-1) that adds media: [{sha256, mime, alt, focus, url}]. Text-only payloads are byte-identical to approval/1. Column, tag, codec and refusal code approval_media_changed_reapproval_required are shared with #13 as one attachment model (owner decision D20-7, 2026-10-09): attachments.MEDIA_PLATFORMS fixes the source each platform accepts (Mastodon local, Instagram url), check_media_list dispatches to the platform’s own limits, and bound_view adds url only for hosted images.
Registry: capabilities.ADAPTERS gains live_media (a media-only platform, whose text format is unavailable); the optional, non-empty live_media_formats list is limited to LIVE_MEDIA_FORMATS (mastodon: image, instagram: image) and needs a live adapter and api_direct formats. The bundled registry uses neither, so Mastodon images and Instagram stay refused.
publish --live: live gate -> pause -> account -> SUPPORTED_LIVE -> opt-out (caption + alt) -> approval hash -> cooldown -> image capability -> media record rules -> GET image URL, sha256 == approved (changed: BLOCKED approval_media_changed_reapproval_required, post stays approved) -> credentials -> GET /me (user_id, username, account_type) -> GET content_publishing_limitclaim_sending -> adapter: GET image URL again, sha256 == approved (else C1, no write)mark_write_started()POST /{ig}/media {image_url, caption, alt_text} (Bearer) -> container idGET /{container}?fields=status_code (<= 5 checks, 60 s apart; pause re-read before each wait)POST /{ig}/media_publish {creation_id} -> media id = remote_idGET /{media}?fields=id,permalink,username,media_type -> remote_url if the shape is recognizedany failure after mark_write_started() -> *_uncertain -> needs_review, never retriedMeta fetches the image URL itself after the container POST, so the last ReachWeaver hash check cannot prove which bytes Meta received (proposal, residual risk). See INSTAGRAM.md.
TikTok video delivery (issue #23, owner-approved 2026-10-09; disabled until a live receipt)
Section titled “TikTok video delivery (issue #23, owner-approved 2026-10-09; disabled until a live receipt)”reachweaver/tiktok.py holds all TikTok logic: account validation, fail-closed MP4 checks on media.probe_bytes output, the TikTok platform_options of the shared video record (reachweaver/video.py, also used by YouTube), identity (GET /v2/user/info/), the creator_info query, init, chunked PUT (publishers.request_put_bytes), status polling and run_live_send. Two registry switches gate it, reachweaver_video_inbox and reachweaver_video_direct_post, both validated by capabilities.validate_registry and enforced by require_video_mode(). The inbox switch must agree with policy.SUPPORTED_LIVE_VIDEO, which is empty, so neither switch can be on today. The CLI hands a post to run_live_send only when its platform is in that set. connections.check_connection and publishers.publish keep refusing TikTok.
video-attach (offline, drafts only) -> posts.video_json = shared video record (video.py: platform, sha256, bytes, mime, parsed headers, platform_options {mode, privacy_level, ...})approve -> approval/video-1 hash = /1 fields + the record without its path (text-only posts keep /1)publish --live -> require_live_gate, pause (CLI) -> run_live_send: require_video_mode -> opt-out -> check_approval_binding (keyed, #7 D7) -> D6 confirmation -> cooldown (+#11 scopes) -> re-read file (O_NOFOLLOW, <= 256 MiB), sha256 == record, else approval_binding_failed (BLOCKED) -> GET user info (+ creator_info for direct_post) before the claim, bounded read-only retries -> claim_sending(expected_sha256) pause, binding, budgets under lock -> init (publish_id, upload_url; no content) -> mark_write_started() -> PUT chunks (206..201) -> status polls: inbox SEND_TO_USER_INBOX -> needs_review tiktok_creator_inbox_delivered_not_public direct PUBLISH_COMPLETE + public id -> published; anything else -> needs_reviewAn inbox upload is never published. It stays in needs_review (the Postiz acceptance pattern, with no schema change to the state machine) until a human reconciles it with the native video. So report, metrics and attribution cannot count a draft that nobody can see. See TIKTOK.md and the proposal.
YouTube video upload (issue #22, owner-approved 2026-10-09; disabled until a live receipt)
Section titled “YouTube video upload (issue #22, owner-approved 2026-10-09; disabled until a live receipt)”reachweaver/youtube.py holds all YouTube logic. publishers.publish_video (separate from the text publish), connections._youtube and the CLI sit behind two gates that are both closed in the bundled build: policy.SUPPORTED_LIVE_VIDEO is empty, and the YouTube registry switch reachweaver_video_upload is off (the validator allows it only on YouTube, needs video: api_direct and sourced rate_limits.facts for live, and requires the live set to equal SUPPORTED_LIVE_VIDEO). capabilities.require_video_mode("youtube", "upload") is the gate; require_capability(p, "video", "publish") never enables video. The draft’s live_video adapter value was folded into this switch at integration (2026-10-09). A draft gains a video through youtube-attach (nullable posts.video_json, added by migrate()), stored as the shared video record (video.py); the approval hash then binds reachweaver.storage.approval/video-1: the text fields plus the record without its path (file SHA-256, bytes, MIME, parsed headers and platform_options {title, tags, category_id, privacy_status, self_declared_made_for_kids}; the description is content). Posts without a video keep the approval/1 payload byte for byte.
publish --live: live gate -> pause -> account -> post_video (malformed -> BLOCKED) | SUPPORTED_LIVE_VIDEO + video spec? else SKIPPED manual_only | opt-out over content + title + tags -> approval hash -> file re-hash (changed -> BLOCKED, event) | D6 confirmation (#7, opt-in) -> cooldown (+#11 scopes) -> require_video_mode(youtube, upload) | -> credentials -> validate_postcheck_connection -> GET /youtube/v3/channels?part=id&mine=true ids == [channel_id] else BLOCKEDclaim_sending (re-checks pause + video-1 hash, keyed tag when on, under lock)youtube.upload_video: re-open (O_NOFOLLOW) + re-hash pass 1 -> mismatch: refuse, nothing sent POST /upload/youtube/v3/videos?uploadType=resumable&part=snippet,status -> 200 + Location Location must stay on https://www.googleapis.com/upload/youtube/v3/videos (never stored) mark_write_started() PUT chunks (8 MiB, Content-Range) while hashing; 308 Range must equal the chunk end pause re-read between chunks; digest compared BEFORE the final chunk 201 video resource: id, snippet.channelId, status.privacyStatus, uploadStatus checkedany failure after mark_write_started() -> *_uncertain -> needs_review; never resumed or resentSee YOUTUBE.md and the proposal.
Threads text adapter (issue #19, owner-approved 2026-10-09; disabled until a live receipt)
Section titled “Threads text adapter (issue #19, owner-approved 2026-10-09; disabled until a live receipt)”reachweaver/threads.py holds all Threads logic; publishers.py gains publish_threads and connections.py gains _threads, both behind the unchanged SUPPORTED_LIVE and require_capability(..., "publish") gates. The bundled registry keeps Threads at reachweaver_adapter: "none" and text api_gated, so neither entry can be reached. policy.validate_post adds the official 500-character (emoji as UTF-8 bytes) and 5-link limits for Threads, which only tightens the check. accounts.CREDENTIAL_SUFFIXES["threads"] reserves {env_prefix}_ACCESS_TOKEN for the overlap rule and credentials-audit.
check_connection -> threads.verify_identity (before claim_sending) | offline: id/handle shape, unknown keys, authorization_expires_at past -> BLOCKED | GET /me?fields=id,username id == threads_user_id, username == handle | GET /{id}/threads_publishing_limit quota_usage < quota_total, else BLOCKED vclaim_sending -> mark_write_started()POST /{id}/threads (media_type=TEXT, text) -> container idsleep 30 s; GET /{container}?fields=status -> FINISHED (<= 5 checks, 60 s apart) | emergency pause re-read before each wait, after it and before publishing (D19-2)POST /{id}/threads_publish (creation_id) -> media id = remote_idGET /{media}?fields=id,permalink,username,owner,text -> remote_url if it names the handleany failure after mark_write_started() -> *_uncertain -> needs_review, never retriedThe C1/C3 boundary sits before the container POST (conservative; proposal). See THREADS.md. The mocked contract cases for issue #49 (successful post, rejected permission, rate limit, expired token, and a dry-run preview whose receipt is stored once and cannot be reconciled twice) are mapped to tests in THREADS.md.
Facebook Page adapter (issue #21, owner-approved 2026-10-09; disabled until a live receipt)
Section titled “Facebook Page adapter (issue #21, owner-approved 2026-10-09; disabled until a live receipt)”reachweaver/facebook.py is registered in PUBLISHERS and connections._CHECKS, but it is refused before either lookup: facebook is not in SUPPORTED_LIVE, and the registry adapter is none.
check_connection -> facebook.verify_identity (before claim_sending) | offline: page_id/page_name shape, unknown keys, Graph v25.0 past 2028-07-29 -> BLOCKED | GET /v25.0/me?fields=id,name id == page_id (a User token fails here unless | page_id is that person's own id; FACEBOOK.md) | GET /v25.0/{page_id}?fields=id,name id == page_id, name == page_name vclaim_sending -> mark_write_started()POST /v25.0/{page_id}/feed message (+ link = first https URL of the approved text)id must be {page_id}_{digits} -> remote_idGET /v25.0/{post_id}?fields=id,permalink_url,from,message -> remote_url if www.facebook.comany failure after mark_write_started() -> facebook_feed_*_uncertain -> needs_review, never retriedThe token is sent only in Authorization: Bearer. page_id is digits only, so the single write is always POST /{page_id}/feed, never /me/feed or a group feed. doctor prints the eligible Page (facebook_eligible_page) and the optional owner-declared declared_permissions (facebook_declared_permissions, never verified, never sent, not in the approval binding). See FACEBOOK.md and the proposal.
Field pilot protocol and operations-log readout (issue #27 slice)
Section titled “Field pilot protocol and operations-log readout (issue #27 slice)”reachweaver/pilot.py is a pure, offline reader for the owner-maintained CSV operations log of a field pilot. The log has one row per unit of work with date, arm (manual, scheduler or reachweaver), task and nine measures: minutes_spent, posts_published, duplicates, unrecoverable_errors, interventions, factual_errors_found, clicks, impressions and downstream_conversions. load_pilot_log rejects a file that lacks a column. It reports, and excludes, any row with a bad date, an unknown arm, a negative or non-numeric value, a blank task, too many or too few cells, or more duplicates than published posts; it never repairs a row. A blank cell is None (unknown), never zero. pilot_report returns per-arm totals with known_rows/unknown_rows/coverage, seven rates computed only over rows where both numerator and denominator are known (each with its numerator, denominator and row counts; a zero denominator gives no rate), distinct ISO weeks and minutes by task. Its arm comparison is labelled descriptive_not_causal and is refused unless at least two arms are logged and every logged arm covers min_weeks ISO weeks (default 2). Differences are reported against the manual arm only. There is no winner, lift or significance field.
CLI pilot-report --log FILE.csv [--min-weeks N] [--json] is an early offline branch: it returns before the SQLite outbox is opened, and exits 1 when malformed rows were reported. The module imports only policy.ValidationError and the standard library: no storage, publisher, connection or network code. It changes no approval, dry-run, emergency-stop, credential-isolation or ambiguous-send rule. The preregistered protocol (consent, stop conditions, baseline, counterbalanced arm blocks, outcome windows, metric definitions, threats to validity and decision criteria) is in FIELD-PILOT-PROTOCOL.md, and the gated release-to-campaign command sequence is in RELEASE-PLAYBOOK.md. No pilot has been run, so neither document contains results.
Personas, usernames and the account kit
Section titled “Personas, usernames and the account kit”Three small offline modules, no database, no network and no send path (SEND_PATH = None, asserted in tests). reachweaver/profile.py owns config/profile.json (reachweaver.profile/1): a closed schema of person, personas, one handle per (canonical platform, persona) and preferences.default_persona. It refuses unknown fields and any key that looks like a secret or an identity document, never puts a value in an error, and saves atomically with mode 0600. Platform ids are canonicalised (facebook_pages to facebook, dev to devto) against the account-advisor channels and policy.PLATFORMS. accounts.py accepts an optional persona id on an account; this is public metadata and touches no credential rule. reachweaver/persona_guard.py resolves an account’s persona from that field, else from its username against the profile, and check_account returns a refusal sentence (with the account or username to switch to) or None. cli.py calls it through _persona_gate from draft (single and multi), check and approve, as an extra refusal only: it never changes the approval payload, the approval/id-1 binding, the dry-run, emergency-stop or reconciliation paths, an invalid profile fails closed, and with no --persona and no profile nothing changes. reachweaver/account_kit.py plus signup_registry.json build one packet per platform and persona: only the fields the platform may ask for (may_ask, status expected), the interface language’s display name and bio, username candidates or a nickname, a form-label glossary (zh-Hans, ja, ko, ReachWeaver’s own words) and fixed human steps. A plan is hashed (sha256 over canonical JSON of the packets, tag reachweaver.account-kit/1), and write_plan writes a NEW 0700 folder of 0600 files only for a matching hash of at least 12 characters: the single approval for every platform in the plan. Its flag --approve-plan matches the project’s Claude Code deny rule for approve, so the approval is the person’s (pinned in tests/test_persona_cli.py). It opens no site, fills no form, reads no password and verifies no username (browser-assist decision). See PERSONAS-AND-ACCOUNT-KIT.md.
Onboarding audit and versioned release gate (issue #27 slice)
Section titled “Onboarding audit and versioned release gate (issue #27 slice)”reachweaver/onboarding.py is a pure, offline audit over the two research registries. blocker_audit() gives every capability-registry platform without a live text adapter exactly one reviewed alternative from CAPABILITY_ALTERNATIVES: manual_export (only for manual_export.MANUAL_PLATFORMS), draft_export (campaign-export, only for policy.PLATFORMS ids) or human_only. Every distribution-registry channel without a mock-tested adapter is classified from its own fields: not_applicable becomes unsupported, a human_only channel becomes human_only, and other manual channels become manual_export/draft_export when they map to a ReachWeaver platform id, otherwise native_manual (no export exists). It returns errors when a blocked platform has no entry, an entry is stale or names a path that cannot work, a human-only venue would receive generated drafts, the two registries disagree, or Reddit or Hacker News stops being human_only. render_markdown() produces the table embedded between markers in TROUBLESHOOTING.md. The module imports only the registry loaders, manual_export.MANUAL_PLATFORMS and policy; it has no CLI subcommand, opens no database and has no send path (SEND_PATH = None).
scripts/release_gate.py (make release-gate [TAG=vX.Y.Z]) is the offline release gate. It checks that pyproject.toml, reachweaver/__init__.py and the bundled skill copy carry one X.Y.Z version (and that --tag matches it), that CHANGELOG.md has a non-empty section for that version (dated before tagging), that required documents exist, that README links the onboarding documents and names every sub.add_parser subcommand of cli.py, that relative Markdown links resolve, that the skill bundle is byte-identical, that the blocker audit is clean and its table current, that the release-readiness scorecard docs/release-readiness.json is valid, and finally that python3 -m unittest discover -s tests exits 0 with OK. Tests run only when every other check passed. --skip-tests makes the best possible result incomplete (exit 3), never a pass. The gate uses no network, credential, version-control command or outbox. On a pushed vX.Y.Z tag, .github/workflows/release.yml runs it with --tag and the tests in a read-only verify job, together with CI’s hygiene checks and scripts/release_ref_guard.py (owner’s tag at the current origin/main commit, from GITHUB_* variables and local git rev-parse only); a separate owner-only publish job, the only one with contents: write, re-runs the guard and creates the GitHub Release (maintainer release checks). tests/test_onboarding.py copies the repository (tracked and untracked non-ignored files), runs every python3 -m reachweaver line of QUICKSTART.md in a fresh interpreter with a minimal environment, a throwaway HOME and patched sockets, and executes each manual_export/draft_export alternative. Approval, dry-run, emergency-stop, credential-isolation and ambiguous-send rules are unchanged.
The release-readiness scorecard docs/release-readiness.json has one row per platform (every capability-registry id, plus Hacker News from the distribution registry), per backend (Postiz) and per product area (sources, content, scheduler, analytics, engagement, media, security, privacy/ops). Each row carries a status from not_started, offline_mock_tested, manual_export_only, live_receipt_verified or blocked, its issue number (or null when no issue owns it), evidence as repository paths or tests/file.py::ClassName references, and blockers of kind owner, live or policy. python3 scripts/release_gate.py --scorecard [--json] validates and prints it (exit 0 valid, 1 invalid), and the gate’s readiness check runs the same validation. The validator rejects unknown statuses or fields, evidence paths that do not exist or escape the repository, test names the file does not define, a capability-registry platform with no row, a missing required area, and statuses the registries contradict: offline_mock_tested needs a live_text adapter, manual_export_only needs a manual_export/draft_export alternative in onboarding.CAPABILITY_ALTERNATIVES, and a human-only distribution channel must stay blocked. Every status short of live_receipt_verified must name at least one blocker. live_receipt_verified is accepted only with a receipt field naming a checked-in JSON file with area, an https remote_url, a time-zoned observed_at, source and first_party: true. No such receipt exists, so no row claims that status, and a test asserts it.
Campaign channel previews (issue #10 slice)
Section titled “Campaign channel previews (issue #10 slice)”reachweaver/preview.py builds a deterministic, offline review report for every variant of a campaign. build_preview(campaign, brand, accounts=None, *, utm=False) reads its inputs and never mutates or writes them. It imports no publisher, connection, storage or network code (SEND_PATH = None), and it generates no copy.
campaign.json + brand.json (+ optional public accounts.json) |validate_campaign(..., grounding=False) structural errors/warnings, split per variant / campaigncheck_claims(campaign, brand) lexical findings + grounded[] (per-account brand rules merged)load_registry() format status: text, thread->text, carousel_outline->carousel |per variant: pieces[] (text | thread parts | caption + slides) measure() Bluesky graphemes + UTF-8 bytes; Mastodon 23-char URLs vs account max_characters; conservative cap text_features() links, hashtags, mentions; Bluesky facets at byte offsets utm_rewrite() optional build_utm_link(..., content=variant id), side by side first_part_check / carousel status review item / registry warning compute_confidence() high | medium | low + rationale (+ editor rationale) |render_text | render_markdown | JSON (CLI campaign-preview; exit 2 when any variant is low)Contract changes are optional and backwards compatible:
claims.variant_bodydefines the checked body forthreadparts and for acarousel_outlinecaption plus slides, so grounding, banned phrases, prohibited claims and near-duplicate checks cover every part.validate_campaignchecks the shape ofpartsandslidesand validates each part withvalidate_post. It still emits the unchangedTEXT_ONLY_STAGINGerror for every non-text format, socampaign-stageandcampaign-exportstage and export text variants only, as before.claims.effective_brand_rulesmerges the optional brandaccounts[account_id]override additively: the union of banned phrases, every distinct disclosure, and the lowestmax_hashtags. A newtoo_many_hashtagserror appears only when a cap is set. Brand files without these fields give the same findings as before.
The confidence level follows rules, not a model, and inherits the limits of lexical grounding. Capability statuses are registry research, not consent. Approval, dry-run, emergency stop, credential isolation and ambiguous-send reconciliation are unchanged. See campaign preview.
Offline health, dead-letter view, opt-out list and caps file (issue #26 slice)
Section titled “Offline health, dead-letter view, opt-out list and caps file (issue #26 slice)”Four small offline modules, none of which contacts a provider:
accounts.json + env (names) + platform_registry.json + SQLite (read-only) + caps.json |health.build_health -> per account: adapter (require_capability / require_backend_capability), | registry staleness, credential names, LinkedIn version/expiry, | Postiz config, queue counts, rolling attempts vs effective budget, | needs_review / stale sending / overdue -> ok | attention | blocked +-> health.dead_letter -> needs_review grouped by sanitized code + reconcile steps
SQLite (SELECT only: event_log + posts + settings) + optional optout.json |ops_metrics.collect -> counters (claims, publishes, failures by send class, reconciles, binding | failures, cooldowns) + gauges (status, needs_review, stale sending, due age, | cooldowns, rebind, opt-out blocks, pause) with allow-listed labels +-> render_json | render_prometheus -> stdout | write_textfile (temp + os.replace, 0644)
optout.json -> optout.load_optout -> engagement.triage_engagement (listed author: no reply/issue draft) -> claims.check_claims (optout_mention error) -> validate_campaign -> campaign-check / claims-check / campaign-stage / campaign-export -> optout.optout_mentions -> draft (refused) / check (BLOCKED) / approve (refused) -> optout.publish_block_reason (loaded once per publish run) -> publish dry run: lists due posts that would be BLOCKED -> publish --live, direct adapters: account match -> SUPPORTED_LIVE -> OPT-OUT GATE -> capability gate -> credentials -> lint -> identity preflight -> claim_sending -> send -> publish --live, Postiz run_live_send: pause -> OPT-OUT GATE -> lint + capability + GET /integrations -> claim_sending -> POST /posts
caps.json -> caps.effective_caps(min max_per_24h, max min_gap) -> claim_sending(max_per_24h, min_gap_minutes)Invariants:
-
Read-only health.
health.pyreusesstorage.queue_health, the capability registry checks andlinkedin.version_status/token_status. It runs onlySELECTstatements, checks only whether credential variables are set, and prints names, never values.blockedexits 1;attentionexits 0 so routine states (missing credentials before onboarding, a stale registry entry) do not page anyone. A missing accounts file, an invalid caps file or an invalid registry is reported asblockedinstead of crashing. -
Dead letter never retries.
dead_lettergroupsneeds_reviewposts bysanitize_error_code(last_error): the stored text is redacted, then only the first one or two[a-z][a-z0-9_]*segments before:are kept, so provider text and secrets cannot reach the report. Age comes from the latestneeds_reviewevent. The only remedy it offers is humanreconcile. -
Opt-out fails closed and over-matches. A named opt-out file that is missing or malformed stops the command. Entries hold only
handleordomain,platformandrecorded_on. Bare handles match any instance, and domains match sub-domain handles and whole instances, because a false positive only removes a draft. Mentions are@tokens not preceded by a word character,@,.or/, so e-mail addresses and URL paths are ignored.draftrefuses a mention (no row is created),checkreports it, andapproverefuses it.publishloads the list once per run before any claim, so a malformed file aborts the run. Inpublish --livethe opt-out gate is a pre-claim, pre-network check, placed like the capability gate: in the direct-adapter loop it runs before the capability gate, credential check, identity preflight andclaim_sending; in Postizrun_live_sendit runs right after the pause check and before the read-onlyGET /integrations. A blocked post printsBLOCKED, gets nosend_claimedevent and staysapproved.run_live_send(optout=None)loads the default list itself, so a direct caller cannot skip the gate. Publish output names only a mention count, never the listed handle. The gate can only withhold a send;claim_sending,transitionandreconcileare unchanged. -
Caps tighten only.
effective_capsreturns the lowestmax_per_24hand the longestmin_gap_minutesof the CLI flag, the file defaults and the account entry. A looser file value cannot win, and a malformed file stopspublishbefore any claim. The CLI passes the result into the existingclaim_sendingparameters for both the direct adapters and the Postiz backend.claim_sending,transition,reconcile, the emergency pause and the live opt-in are unchanged. -
Read-only metrics with allow-listed labels.
ops_metrics.collectreusesqueue_healthand four SELECT-onlystoragehelpers; it never writes, retries or contacts a provider. Eventdetailtext can hold provider error text (needs_reviewreasons), so only fixed tokens are parsed from it (the trailingclass=, a leadingreason=, the cooldown’saccount=/stage=) and each is checked against a fixed list before it becomes a label. Post text is read only to count opt-out mentions; it is never emitted. Without--since, event counts are all-time Prometheus counters (*_total); with it they become*_in_windowgauges.data-purgecan delete events, which Prometheus treats as a counter reset.
Not implemented: alerting itself (only example rules; loaded into a running Prometheus 3.15.0 against synthetic ops-metrics output on 2026-10-16, not run in production), traces, and per-provider spend ceilings in money. See worker operations, the runbook and the opt-out list.
Canonical export of the legacy outbox (issue #4 slice)
Section titled “Canonical export of the legacy outbox (issue #4 slice)”reachweaver/schema_export.py is the migration path from the SQLite outbox to the canonical schema 1.2 documents (current SCHEMA_VERSION). export_documents(con, accounts) sets PRAGMA query_only for the duration of the export and restores it afterwards. It reads posts, event_log, campaign_variants, metric_snapshots and the public accounts.json mapping, and returns validated documents plus an unmapped list with a source row and reason for each skipped document. CLI schema-export [--out FILE] [--type T] is a DB branch inside the connect block. It prints JSON lines to stdout, or writes them to a new 0600 file through privacy.write_private_file, and refuses an existing path.
posts (+campaign_variants) --> PlatformVariant + PublishJob (state via STORAGE_STATUS_MAP; locale "und")posts published + event --> PublishReceipt (api_response | owner_verified_native_account)metric_snapshots --> AnalyticsSnapshot (null = unknown; metric_status -> 1.2 metric_status)evidence_json --> Evidenceaccounts.json (public) --> Account (only with brand_id + owner consent)Draft, Approval --> unmapped (authorship / payload hash never recorded)The module imports no publisher, connection or vendor code. It changes no storage schema, state machine, claim, transition, reconcile or publish semantics. It never mints an Approval: exported jobs reference an approval_id that is listed as unmapped, so transition_job fails closed on them. Documents with secret-looking text are skipped, not altered. ADR-0003 records the connector-replaceability decision and its fitness tests. SCHEMAS.md has the full mapping table.
Read-only public counter polling (issue #15 slice)
Section titled “Read-only public counter polling (issue #15 slice)”reachweaver/metrics_fetch.py turns published Bluesky and Mastodon receipts into metric snapshots. It reads public counters only, not private analytics. CLI metrics-fetch --online [--account ID] [--post-id ID] [--max-posts N] [--dry-run] is a DB branch inside the connect block. Without --online it refuses, unless --dry-run is given. --dry-run reads the outbox and prints the planned requests with no network and no write.
posts (published, remote_id) + accounts.json (bluesky|mastodon, no backend) | select_posts: newest first, max 50 (<=200), skipped/deferred with reasons +-> bluesky -> GET public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=..(<=25) no auth | author.did == DID in at:// URI, else author_mismatch +-> mastodon -> GET {server}/api/v1/statuses/:id no auth 401/404 + {env_prefix}_TOKEN set -> exactly one bearer retry id, account.username and local acct == configured handle | map_counts: documented fields only; missing/invalid -> NULL (unknown) vstore_metrics(post_id, observed_at=utc_now(), source=bluesky-public-api|mastodon-api) -> UNIQUE(post_id, observed_at, source) upsert -> report / experiment readoutsInvariants:
- No platform writes. Every request goes through
publishers.request_get_json: GET, no redirects, bounded body and timeout, and sanitizedhttp_NNNerrors. Tests replacepublishers._send_httpand assert that each request is a GET togetPostsor/api/v1/statuses/, and thatrequest_json(POST) is never called. Credentials go only in the one Mastodon retry header. They are never printed or stored, and the dry-run shows only the variable name. Bluesky credentials are never read. - Unknown is not zero. Only
likes,reposts,repliesand (BlueskybookmarkCount, when present)savesare filled.views,impressions,clicksandsignupsstay NULL. A response with no documented counter writes no row. The field mapping is cross-referenced toattribution.metric_definitionin ATTRIBUTION.md. - Polite and non-retrying. Requests run one at a time with a per-run cap. A 429 raises
RateLimited, which stops the run at once. Earlier snapshots are kept and the remaining posts are listed undernot_attempted. Apart from the single documented Mastodon token retry, nothing is retried. - Outbox state untouched. The module calls only
store_metrics(which adds ametrics_recordedevent). It never changesposts.statusand cannot claim, send or reconcile. The approval, dry-run, emergency-pause, credential-isolation and ambiguous-send rules are unchanged.
Not implemented: owner-authorized private analytics (LinkedIn memberCreatorPostAnalytics, X non_public_metrics, Meta insights and others), follower-count polling, scheduled or background polling, and Telegram view counts (which need the MTProto client API, not the Bot API).
Landing-page checks (issue #25 slice)
Section titled “Landing-page checks (issue #25 slice)”reachweaver/landing.py checks the pages that campaigns send readers to. CLI landing-check --briefs FILE [--campaign CAMPAIGN.json] [--online] [--allow-origin O...] [--json] is an early branch that returns before the connect block, so it never opens the database. The offline part reuses attribution.normalize_destination, validate_utm_link, load_seo_briefs and seo_conflicts, plus claims.check_claims for the grounded claim phrases.
reachweaver/ai_visibility.py (CLI ai-visibility) reports what the owner’s own pages ask AI answer engines and search crawlers to do. Crawler meanings come from reachweaver/ai_search_registry.json, where every entry carries an official URL, a checked date and a verbatim quote; validate_registry enforces this and keeps the search, training and user-fetch roles apart (for example, Google-Extended must cite that it does not affect Google Search inclusion, and llms.txt must stay optional_unconfirmed with affects_ranking: false). Offline it reads a saved HTML file and robots.txt. --online checks only the briefs’ canonical pages: robots.txt is read once per host through sources.RobotsCache (which now keeps the text in documents, so there is no second request), the page goes through landing.fetch_landing, and /llms.txt is requested once per origin through the same guard. robots.txt matching follows RFC 9309 (named group, then *; longest match; allow wins ties). It is an early branch with no database, and it never publishes.
reachweaver/link_health.py (CLI link-health, public issue #10) applies the same guarded fetcher (landing.fetch_landing: robots.txt first, inspected redirects, https and registrable-domain rules, size cap) to the links already stored in local posts. It runs on a connect_readonly connection (never migrates, never creates the database), groups links by UTM-stripped destination (printed URLs keep only origin, path and utm_* parameters and are redacted; the fetch uses the full destination), and sends at most one GET per distinct page and at most --max-links pages (1..50) per run, only with --online. Statuses: ok, redirected, broken, blocked_by_robots, unusable, not_checked. Only broken and unusable are errors (exit code 1). It cannot approve, schedule, publish or write.
briefs.json --canonical_url_findings--> per-page errors (https, fragment, utm/trackers, normal form)campaign.json --cta_url + tagged links--> validate_utm_link -> strip tags -> brief canonical_url? else cta_no_brief --check_claims grounded[]--> phrases per linked page | --online only vRobotsCache.skip_reason(url) (sources.py; per host, per run) -> publishers._send_http GET (no auto-redirect, 25 s timeout) 3xx: inspect Location -> <=5 hops, https, same registrable domain or --allow-origin 2xx: Content-Length and read() capped at 2 MB -> html.parser (_LandingHTML) -> rel=canonical, title, meta description, og:*, noindex, html lang, lexical claim phrases -> page status ok|warning|error; exit 1 on any errorInvariants:
- Network only behind
--online. Without it, no request is made. With it, every request is a GET with the ReachWeaver User-Agent and no credentials, sent throughpublishers._send_http, which is the single point tests mock. Tests cover a 404, an off-domain redirect, a canonical mismatch, a missingog:image,noindex, a robots.txt disallow, an oversized body and missing claim phrases without contacting any host. - Redirects are decisions, not side effects. The opener refuses redirects, so each hop is checked before it is fetched. A refused target is recorded in the chain and never requested, and neither is its robots.txt.
- Untrusted page content. Values from pages pass through
sources.clean_textand are truncated. Script, style, template, noscript and svg content is excluded from page text. - Lexical, not truth.
claim_phrase_not_on_pageis a wording mismatch warning. Nothing here verifies a claim. - No outbox effect. The module imports no storage code and cannot draft, approve, schedule, publish or reconcile. The approval, dry-run, emergency-pause, credential-isolation and ambiguous-send rules are unchanged.
Not implemented: HEAD requests (one bounded GET is enough), JavaScript rendering, per-platform preview rendering, and a full Public Suffix List.
Local dashboard and terminal presentation (GUI/UX slice)
Section titled “Local dashboard and terminal presentation (GUI/UX slice)”reachweaver ui serves a read-only dashboard; start, the tiered --help (everyday commands; --help all for every command by task), --plain, and terminal tables/colour make the CLI easier to read. User guide: DASHBOARD.md.
browser --GET /<token>/api/*--> ui_server.Handler (loopback, token, Host/Origin/Fetch-Metadata checks, GET only, CSP) | one short-lived connection per request v ui_data.open_database -> storage.connect_readonly (mode=ro, query_only, never creates/migrates) | overview -> ops_summary.build_summary posts -> storage.list_posts post -> post_view.build_post_view (+ event_log) accounts -> accounts.missing_credentials (NAMES only) channels -> distribution.build_distribution_plan (offline) next -> start.build_guide (read-only; the dashboard drops its own `ui` step) journal -> journal.build_entries (verify_files off: no file is opened) schedule -> storage.list_posts + approval max age safety -> ledger.audit + workspace.inspect (hashes unsent attachments; cached 60 s per server) results -> storage.report + latest_snapshot/metric_states (recorded values only; unknown never 0) campaigns, campaign?name= -> storage.list_posts + campaign_variants (grouping only; no shared approval) search?q= -> list_posts + shared_entries, substring match in Python (never SQL text); schedule?days=1..62 suggest -> suggest.build_suggestions(con=...) minus publishing-safety items (already status reasons) attention -> ui_data.schedule_view + list_posts: status counts and the posts needing a human, one kind each (nav counts, Needs you) owner-actions -> start.build_guide + attention_view + next_steps: owner-only commands as text, each tagged catalog.owner_runs activity -> event_log (action, time, post's account/platform/excerpt; never the detail) + ideas.load (ideas.json, never created) ideas?status= -> ideas.load: text, tags, state, source HOST only prompts?page=..., prompt?intent=...&text=... -> ui_data.prompt_context (real ids) -> ui_prompts.build_prompt (pure string; /reachweaver, ids, start commands that are never owner-only, owner-only boundary, stored text quoted between hash-derived UNTRUSTED DATA markers); nothing is stored, run or sent to a model icon.svg -> a static tab icon (shapes only, no script) | every document through privacy.redact_value v JSON; ui_assets.APP_JS inserts it as text (never HTML)Modules: post_view.py holds the offline views moved out of cli.py (show and the dashboard share build_post_view); ui_data.py (read-only data layer), ui_prompts.py (the pure prompt builder of the “Work with Claude” page: no model call, no environment read, no file), ui_server.py (HTTP), ui_assets.py (page, CSS, JS as Python strings so sync_skill.py ships them in the bundle), start.py (state-aware next steps; reads the workspace marker, accounts file, database read-only, ideas inbox and snapshot count, and names accounts still holding the example placeholders through accounts.placeholder_accounts), show_view.py (the terminal review sheet and the length/approval-sentence facts the dashboard shares), term.py (colour, tables, control-character cleaning), completion.py (bash/zsh completion generated from the parser) and cli_help.py (the tiered help: EVERYDAY for --help and help, GROUPS for --help all, the -h/--help [TOPIC] action, error hints; the top-level parser also turns a mistyped command into a short suggestion). cli_parser.py builds the whole argument parser (every subcommand, flag and help text; moved out of cli.py on 2026-10-20 to keep each file under the plugin directory’s 256 KiB limit); cli.py re-exports make_parser and dispatches, and scripts/release_gate.py reads subcommands from both files.
Invariants:
- Read only, twice. Only GET/HEAD are routed, and the connection is
mode=rowithPRAGMA query_only. A missing or pre-schema database is refused, never created or migrated. - No send, approval or credential path.
ui_data,ui_server,ui_assetsandstartdo not namepublish,claim_sending,transition,set_paused,require_credentialor a network opener;tests/test_ui_data.py,test_ui_server.pyandtest_start.pycheck this by parsing the code (identifiers and imports, not strings).post_viewis the existing offlineshowassembly: for a backend account it lazily importspublishers.backend_forto render that backend’s offline preview (no credential, no network), exactly asshowalways did. Credential information shown is limited to the names of missing variables; whether a variable is set is checked (asdoctorandhealthdo) but its value is never displayed. - Loopback and token. The listener binds
127.0.0.1only (no host option). Every request needs the per-run random token in the path (compared in constant time) and a loopbackHost; a cross-originOriginor a cross-site non-navigation fetch is refused. Request lines are not logged because they contain the token. - Exact text is exact.
post_detail,posts_list,journal_viewandschedule_viewreturn the stored post text (or its excerpt) unredacted (a reviewer must see what would be sent, and the approval hash covers that text) and setcontent_looks_like_secret/looks_like_secretwhen the redactor would have changed it; every other field (errors, events, previews) is redacted. - Untrusted text. Post text, errors and URLs reach the page only through
textContent; the CSP forbids inline script/style, remote resources and framing;tests/test_ui_server.pyrejectsinnerHTML,eval, write methods and remote URLs in the assets. - Presentation is not behaviour. Plain output (not a TTY, or
--plain) is byte-identical to before;--jsonnever passes throughterm.py; hints appear only on a TTY.term.pyreads no environment variable (soNO_COLORis not honoured):tests/test_hardening.pypins every environment read, and widening it is a credential-isolation rule change that needs the owner’s security review. - No outbox effect. Nothing here changes approval, dry-run, emergency stop, credential isolation or reconciliation. Approving or pausing from the dashboard would be a separate security-reviewed proposal and is not implemented.
- Claude Code stays in its own app. The “Work with Claude” page builds prompts for the owner to paste into their own Claude Code session; the dashboard starts no process and calls no model. The composer text travels only as a GET query to the loopback server (not logged, not stored) and comes back inside the prompt. Start commands are refused by
ui_promptsifcatalog.owner_runsmarks them owner-only, andtests/test_ui_companion.pyparses every suggested command with the real parser.
Not implemented: any write action in the browser, authentication beyond the URL token, TLS (loopback only), push updates (the page polls only when the opt-in Auto-refresh is on), and a phone-tested layout (the narrow layout was checked in a 400 px frame in desktop Chrome only). The page keeps two preferences (theme, auto-refresh) in localStorage, always inside try/catch.
Agent interface (context, describe, error codes)
Section titled “Agent interface (context, describe, error codes)”context -> context.build_context: accounts file + profile (names, persona) + connect_guide.account_rows (offline readiness) + ui_data.open_database (mode=ro; skipped when the file is missing, never created) for pause, approval safeguards (storage.offline_key_mode: the key is never read), status counts, posts waiting for the owner, approval_rebind_required, ui_data.suggestions_view; start.build_guide for next steps; policy.live_opt_in / runtime_environment reduced to booleans; catalog.owner_only_commands; privacy.redact_value lastdescribe -> catalog.describe(make_parser()): flags, choices, defaults, groups, JSON option from argparse itself + catalog.COMMANDS (hand-kept tags: owner_only, network, writes, read_only, interactive; owner-only flags) + everyday: true for the commands in cli_help.EVERYDAY (the short --help view); every command is listedhelp -> cli_help.help_text(make_parser(), TOPIC): everyday view, "all" (grouped index) or one command's own help; -h/--help [TOPIC] on the top-level parser (cli_help.TieredHelpAction) prints the same textmain() -> on an error: "ERROR: ..." on stderr as before; with --json also {"error": {code, message}} on stdout (error_codes.error_code: exception class, refined for common ValidationError messages)publish --json (dry run only) runs the unchanged _run_publish with stdout captured and structures its lines (publish_preview.py); --json with --live is refused before anything runs, so the live path is neither wrapped nor changed. approval-mode --json only displays; combined with a setting change it is refused before any change. tests/test_agent_interface.py checks that every subcommand is classified, that context creates no database on an empty folder, that every new JSON output parses and that text output without --json is unchanged.
Local MCP server (mcp-serve, experimental)
Section titled “Local MCP server (mcp-serve, experimental)”MCP client --stdin line--> mcp_server.serve -> McpServer.handle_line (JSON-RPC 2.0; 2026-07-28 per-request _meta, or legacy initialize 2025-11-25..2024-11-05) tools/list -> Tool per exposed_commands(make_parser()): catalog tag read_only, not interactive/owner_only/network, no owner_only_flags, JSON output, not WITHHELD_COMMANDS (+ draft with --allow-drafts); NEVER_EXPOSED backstop raises at start-up; inputSchema from the argparse actions, WITHHELD_FLAGS dropped tools/call -> Tool.argv: schema check, file arguments confined to --root (resolved) -> [command, --opt=value..., --json] -> cli.main(fixed global options + argv) with stdout/stderr captured, stdin empty, sockets blocked -> privacy.redact_value -> {exit_code, result, messages} (cap: refused, never cut) --stdout line--> clientInvariants: a client never supplies a command name, a global option or raw argv; there is no approve, review, publish, resume, reconcile, reconnect, connect, pause or approval-mode tool; stdout carries only protocol messages (anything else printed goes to stderr); one stderr log line per call without arguments or output. tests/test_mcp_server.py checks the allowlist against the catalog, the backstop, argument validation, path confinement, the output cap, that no read-only tool creates the database, and stdio framing. User guide and threat notes: MCP-SERVER.md.
Share: copy-only packages, per-network fitting, media header checks
Section titled “Share: copy-only packages, per-network fitting, media header checks”share.py turns one caption into one package per chosen network and writes only a NEW private folder; it imports no publisher, storage, credential or network client (SEND_PATH = None) and never opens the database (tests hash an existing database before and after). Since the owner decision of 2026-10-21 the text networks ReachWeaver can also send to (share.api_text_platforms(): policy.SUPPORTED_LIVE entries that the distribution registry marks mock_tested_text and not human-only, today Bluesky, Mastodon, Telegram and LinkedIn) are offered too, through manual_package.build_package(..., copy_handoff=True). That flag exists only for share; manual-export keeps accepting manual_only ids only. Each such package also gets the blocking policy.validate_post problems (the rule draft/check use), the rule’s text limit, an offline richtext.bluesky_preview for Bluesky, and the note “Copy and post it yourself; to send it from ReachWeaver instead, connect the account and use draft → review.” Human-only and moderator-only venues are unchanged: the registry never marks them mock_tested_text, so they still get notes only.
share_tailor.fit(platform, text) is pure and deterministic: when a copy is over the network’s rule (the draft/check rule for the four API networks, manual_export.FACTS for Threads, Instagram, TikTok and YouTube descriptions, the conservative cap for Facebook Pages) it keeps the longest word-boundary prefix that fits (binary search; every rule grows with the prefix) plus “…” and returns shortened, both lengths, the rule and the hashtags cut. Other networks are left as written, with a note when they exceed policy.CONSERVATIVE_TEXT_LIMITS. Hashtags are never moved (no sourced rule exists). --no-shorten keeps the over-long copy, which then blocks.
With --attach and no --media manifest, share.media_findings runs media.check_file_for_targets(path, tables): one header probe per file (probe_file), then the same _check_target rows media-check uses, for the network’s table (MEDIA_TABLES: Bluesky, Mastodon, Instagram, TikTok, LinkedIn, YouTube Shorts, DEV), with no alt text, caption or rights record. Findings become package warnings (MEDIA PROBLEM for an error-severity row, Media advice for a recommendation, plus what was not checked) and never block. Each package records share.route, share.tailoring and share.media_checks in manual-export.json.
account-add
Section titled “account-add”account_add.py appends one public entry to the accounts file (accounts.accounts_path, so --accounts, $REACHWEAVER_ACCOUNTS or the workspace’s config/accounts.json). Per network it knows the required and optional public fields (SCHEMAS); --field refuses accounts.SECRET_FIELD keys and anything outside the schema, and nothing prompts for a secret. The current file must load before anything changes; the candidate file (the old entries byte-for-byte plus the new one) is validated by accounts.load_accounts from a temporary copy, so every loader rule applies unchanged (id and env_prefix shape, per-platform validators, env_prefix_conflicts). On top it refuses example placeholder values, a duplicate id, the same identity twice on one network and a malformed Telegram chat_id (the connect-check rule). An unedited example entry of the same network is filled in place (id and env_prefix kept) unless --id is given. --persona goes through persona_guard.check_account and route (a conflict with the profile is refused) plus a check that the profile does not record the username under another persona. The previous file is kept as accounts.json.bak; both writes are atomic. It prints the credential variable NAMES (account_credential_suffixes) and points to connect --guide. No network, database or send path. At a terminal (stdin and stdout) for a policy.SUPPORTED_LIVE network, run(args, connect_now=...) asks CONNECT_NOW after the write (default No); on y the CLI’s _connect_guide_for parses connect --guide --account ID with the same parser, copies the run’s global paths and calls execute, so the unchanged connect branch (its own terminal check, connect_guide.run_guided, every online check behind the owner’s y) runs in-process. connect’s own first-account flow passes no connect_now, so it never asks twice.
Browser Share composer (share --web)
Section titled “Browser Share composer (share --web)”reachweaver share --web [--open] starts share_browser.py, a small local server separate from the read-only dashboard, for people who prefer a page to the terminal. User guide: BROWSER-SHARE-COMPOSER.md.
browser --GET / --> page (share_browser_ui.HTML, CSP nonce per response) --GET /api/catalog --> share_browser.catalog() (copy-only destinations from the registries; api_capable flag) --POST /api/preview -> _incoming() -> share.make_packages() (validation only, nothing written) --POST /api/prepare -> _incoming() -> make_packages() -> store.write_package(): new share-* folder, or mode "update" + confirm + loaded revision -> staged swap of an unposted package --POST /api/packages -> store.list_packages() (read; per network exported | posted) --POST /api/package/open -> store.editor_from_package() + media_into_store() -> draft = that package --POST /api/package/duplicate -> editor_from_package() -> _incoming(files=package media) -> new package --POST /api/draft/load|save|clear -> state/composer/draft.json (0600, editor fields only; atomic replace) --POST /api/media (octet-stream) -> store.store_media(): state/composer/media/<16 hex>/<safe name> --POST /api/mark-posted -> store.mark_posted() -> storage.record_manual_publish() + <network>/POSTED.json (+ storage.store_metrics(entered-by-hand) when numbers were typed) --POST /api/numbers -> store.record_numbers() -> storage.store_metrics() for that POSTED.json's post only --POST /api/accounts -> store.list_accounts() + configured_networks() (read-only: id, platform, handle) --POST /api/link-account -> store.link_account(): the account a post link names (read-only, posted.py match) --POST /api/idea -> share_browser.idea_text(): one OPEN idea's id + text from ideas.json (read-only)Page and wording: the UI mirrors the dashboard’s tokens (ui_assets.APP_CSS values copied, not imported), with dark mode by prefers-color-scheme, a five-step progress bar computed from the editor state, a role=status announcer and 44 px targets under 640 px. share_browser.plain() maps CLI and field wording in errors and preview warnings to plain words (PLAIN_WORDS; the no-media-manifest warning becomes “store.display_name: caption words cut at a word boundary, control/format/bidi characters removed, plus the save time) in PACKAGE.json; it is re-validated on every read, else derived again, and the folder name pattern is unchanged.
Composer write endpoints and their guards (share_browser.py routes, share_composer_store.py file work; owner-approved 2026-10-21). Every POST first passes Host = 127.0.0.1:<port>, the per-run token (constant-time) and Origin = the server origin, else 403 with nothing read; JSON routes then need application/json, at most 96 KiB within one 10 s deadline, depth ≤ 32 and no lone surrogate. Routes with a write run under one server lock.
| Endpoint | Writes | Guards beyond the common ones |
|---|---|---|
/api/prepare |
new state/shares/share-* or replaces one |
every network rechecked by make_packages; mode new (default; refuses package/revision/confirm) or update (needs confirm: true, a valid existing name and the revision it loaded, else 409; refused with 409 if any network has POSTED.json); staged in .pending-* and renamed, old copy removed only after the swap |
/api/package/open |
the draft; copies package media into the media store | strict name; package media/ entries must be regular files, a symlink refuses the whole open |
/api/package/duplicate |
a new package | as open; rechecked like prepare; no POSTED.json is copied |
/api/draft/save, /clear |
state/composer/draft.json |
allowlisted editor fields only (no token); types and sizes checked; os.replace of a 0600 temp file; a symlinked draft is refused |
/api/media |
state/composer/media/<16 hex>/<name> |
application/octet-stream, Content-Length 1 B..256 MiB checked before reading, one 120 s deadline, extension allowlist share.IMAGE_SUFFIXES plus VIDEO_SUFFIXES, name reduced to [A-Za-z0-9._-], O_CREAT, O_EXCL and O_NOFOLLOW, at most 30 waiting files; a short body keeps nothing |
/api/mark-posted |
one posts row + ledger entry via storage.record_manual_publish, then POSTED.json (O_EXCL) |
network must be in the package, not human-only, measurable (policy.PLATFORMS, dev → devto) and not already marked (409); an optional account_id must match accounts.ACCOUNT_ID, exist in load_accounts() and have the network’s history platform, and must not be contradicted by the link (store.resolve_account: the link names another configured account, or a handle the chosen account does not list), else 422 before the database is opened; with no account_id, the one non-placeholder account whose handle the link names is used (store.account_from_link, the same match as posted.pick_account, built on posted.handle_in_url/account_handles); optional numbers (only likes/replies/reposts/views, each an exact JSON int 0..10^9 or null) is checked before any write and stored with storage.store_metrics (source entered-by-hand, observed now) for the new post only; the store’s refusals (link, duplicate, future time, no material) come back as 422 with plain words |
/api/numbers |
one metric_snapshots row via storage.store_metrics (source entered-by-hand, observed now) |
package, platform, numbers only; numbers as above, at least one; the network must have POSTED.json and its post_id must name a published_manual row with the same history platform and remote_url, else 422 with nothing written; no post changes state |
/api/accounts |
nothing (reads accounts.json) | {} body only; returns id, platform and public handle per account, never env_prefix, and your_networks (composer ids of non-placeholder accounts, file order); a missing or invalid file returns empty lists |
/api/link-account |
nothing (reads accounts.json) | platform, url only; {"account_id": id or null}; anything that is not a known platform and one plausible link gives null |
Every name from the browser matches a fixed pattern (share-YYYYMMDD-HHMMSS-<12 hex>, 16-hex media id, a known network id) and is resolved and confirmed to be a direct child of its folder; no folder component below the workspace may be a symlink, and every file open uses O_NOFOLLOW. There is no send, approve or schedule route; the history database is opened only by /api/mark-posted and /api/numbers, and the record they touch is the terminal published_manual that no send, schedule, claim or reconcile path selects. The read-only dashboard (ui_server.py) still refuses POST, PUT, DELETE and PATCH.
Invariants:
- Same rules as
share. Every request goes throughshare.make_packagesand the existing manual-export preflight. Per-network edits (overrides) are validated one network at a time, may target only a selected network, and are refused for human-only destinations, which receive notes and never post text. - No send path. The modules import no publisher, outbox, credential or network client (
SEND_PATH = None); there is no Publish action. The storage calls arerecord_manual_publish(Mark as posted) andstore_metrics(numbers typed by hand for such a record). The approval, dry-run, emergency-pause, credential and reconciliation rules are untouched. - Loopback and token. It binds
127.0.0.1only. The per-run token travels in the URL fragment (never sent to the server or in a Referer) and then in theX-ReachWeaver-Tokenheader, compared as bytes in constant time. The Host must be exactly127.0.0.1:<port>on every request, and POSTs need the exact Origin andapplication/json. No CORS header is sent; other methods get a JSON 405. - Bounded input. Unknown fields are refused, a body is at most 96 KiB and must arrive within one 10 s deadline (408), and parse errors, including deeply nested JSON, are a 422, never a dropped connection.
- Private output. Preview writes nothing. Saving rechecks every rule, stages the whole handoff and renames it into a fresh 0700 folder with 0600 files; the browser cannot choose the path, and the response reports the absolute folder.
- Untrusted text. User text reaches the page only as text (no
innerHTML); the CSP forbids remote resources and framing. Requests are not logged.
Not implemented: image thumbnails in the page (the CSP keeps img-src 'none'), the global --accounts flag for the composer (it reads $REACHWEAVER_ACCOUNTS or the workspace’s config/accounts.json; ShareServer(accounts=...) and serve(accounts=...) accept a path but share --web does not pass one yet), a cap on concurrent local connections, and keeping per-network edits when the main caption changes (they are discarded). With --open the token URL is handed to the system browser launcher, as ui --open does, so it may be visible in the local process list.
Studio: dashboard and composer in one process (studio)
Section titled “Studio: dashboard and composer in one process (studio)”reachweaver studio (reachweaver/studio.py) starts the read-only dashboard (ui_server.make_server) and the composer (share_browser.ShareServer) in one process, each on its own loopback port, with its OWN per-run token and every guard it has alone. studio.serve_both runs the two serve_forever loops in daemon threads; Ctrl+C (or either loop ending) calls shutdown() and server_close() on both. With --open the browser starts on the dashboard, or on the composer when the database holds no post. When no database exists, studio creates an empty one through storage.connect (the same as init) before the dashboard opens it mode=ro; that is its only own write.
dashboard --GET /<dash token>/api/studio --> {"studio": true, "composer": "http://127.0.0.1:C/#<composer token>"}composer --GET /api/catalog (X-ReachWeaver-Token) --> {..., "dashboard": "http://127.0.0.1:D/<dash token>/#/results"}links: dashboard "Write a new post" / Ctrl K / next step / Write page -> composer URL dashboard Ideas "Write a post from this idea" -> composer URL + "&idea=idea-xxxxxxxx" (fragment only) composer "See how your posts did" (header, footer, after Mark as posted) -> dashboard #/resultsSecurity review points (what keeps the cross-link from widening either page):
- Tokens stay per server and per run. The two tokens differ, are new every run and are never stored or logged. Each server learns the other’s URL only from
studio.link()in the same process; a dashboard started byuior a composer started byshare --webhasNoneand keeps its copyable-command fallback. - A token only appears in a page served by the process that owns both servers, and only inside a token-gated JSON answer (
api/studio,/api/catalog), never in static HTML (tested: neither page body contains either token). Both answers carryCache-Control: no-store. - No referrer. Both servers send
Referrer-Policy: no-referrer(and both pages carry the meta tag); links userel="noopener noreferrer"; the composer token sits in the URL fragment, which is never sent in a request or a Referer. The dashboard token is in its path, so leaving the dashboard must not leak it: the policy above covers that. - No cross-origin access. Both CSPs are unchanged (
default-src 'none',connect-src 'self', no third-party origin), so a page can only navigate to the other, never fetch from it. The dashboard is still GET/HEAD-only (405 for POST/PUT/DELETE/PATCH), and the composer still refuses a POST without its own token and its own Origin (the dashboard’s origin is refused too). - One key in practice. Whoever holds the dashboard URL of a studio run can read the composer link, so the two URLs are one secret; the terminal and docs/DASHBOARD.md say to keep both private.
- Idea prefill is read-only and server-checked. The dashboard puts only an idea id (
idea-+ 8 hex) in the composer link’s fragment; the composer page strips it from the address at once and asks/api/idea(Host, token, Origin, size and allowlist guards like every POST) for that OPEN idea’s text, read fromideas.jsonand never changed. The page fills an empty editor, or, when the editor holds a post, asks before replacing the caption (Undo restores it). This was chosen over seeding the draft on the server from a URL because a link (which a person can be tricked into opening) then never writes anything by itself.share --web --text Tand--idea IDseed the draft from the owner’s own command line throughshare_composer_store.save_draft(allowlisted fields only) and never replace an unsaved draft: the text is offered once by the page instead (ShareServer.offer). - Not exposed to agents.
studioisinteractivein the catalog and inmcp_server.NEVER_EXPOSED.
Tests: tests/test_studio.py (both servers start, cross-link only in studio mode, referrer and CSP headers, dashboard refuses POST/PUT/DELETE/PATCH, composer refuses a wrong Origin or token, Ctrl+C closes both, the idea route and seeding), tests/test_reason_help.py (every health reason code has dashboard help text with the fix).
