Canonical schemas (schema_version 1.2)
reachweaver/schemas.py defines versioned, machine-validated contracts for content, accounts, publish jobs and analytics, plus a pure publish state machine. It uses only the standard library: a small declarative validator covers field type, required/optional, enum, regex, numeric/length ranges, nested objects and lists. No jsonschema dependency, no network, no SQLite writes.
The contracts are the target shape for issue #4. The existing SQLite outbox (storage.py) is unchanged. It still enforces approval, the emergency stop, account budgets and ambiguous-send handling for every live send. The schemas do not authorize or perform any publishing.
Validate a document
Section titled “Validate a document”reachweaver schema-validate --type PublishJob examples/schemas/text-post-job.jsonreachweaver schema-validate --type PlatformVariant examples/schemas/image-variant.jsonreachweaver schema-validate --type PlatformVariant examples/schemas/video-variant.jsonreachweaver schema-validate --type Approval examples/schemas/text-post-approval.jsonreachweaver schema-validate --type AnalyticsSnapshot examples/schemas/analytics-snapshot-window.jsonreachweaver schema-validate --type AnalyticsSnapshot examples/schemas/analytics-snapshot-status.jsonPrints OK: TYPE VERSION (the document’s own schema_version: 1.0, 1.1 or 1.2) and exits 0, or prints INVALID with one ERROR: line per problem and exits 1. Unreadable JSON exits 2. Everything runs offline.
From Python: validate(kind, doc) -> list[str] (an empty list means valid) and require_valid(kind, doc), which raises ValidationError.
Every top-level document carries a supported "schema_version" ("1.0", "1.1" or "1.2") and "type": "<TypeName>". Embedded documents (a PlatformVariant inside PublishJob.payload, a MediaAsset inside PlatformVariant.media) may leave out both fields. If they include them, the type must match and the version must be supported and no newer than the container’s. Unknown fields are rejected everywhere, and so is a field newer than the document’s declared version (a 1.0 snapshot cannot carry window_start).
| Type | Purpose | Key fields |
|---|---|---|
SourceSignal |
An owned achievement worth telling (release, paper, demo) | brand_id, kind, url and/or doi, observed_at |
Evidence |
One claim tied to a traceable source | claim, url/doi, verification (primary_source_checked/owner_attested), checked_by, checked_at |
ContentBrief |
What to say, to whom, why | brand_id, campaign_id, audience_id, objective, locale, evidence_ids, optional utm |
Draft |
Platform-neutral copy | brief_id, body, evidence_ids, authorship (human/ai_assisted/ai_generated) |
PlatformVariant |
One platform/account rendering, i.e. the approved payload | platform, account_id, format, text, media[], link_url, utm, platform_options |
MediaAsset |
Image/video/gif/document with integrity and rights | mime_type, sha256, bytes, alt_text (image/gif), duration_seconds + has_captions (video), license, people_depicted, consent[] |
Account |
Public account metadata only | id, platform, brand_id, env_prefix, audience_ids, owner consent[] |
Approval |
Human authorization bound to an exact payload | variant_id, account_id, platform, approved_payload_sha256, decision, reviewer_kind: "human", approved_at, expires_at |
PublishJob |
Outbox unit and lifecycle state | state, idempotency_key, payload, payload_sha256, approval_id, scheduled_at, attempts, remote_post_id, error_code, cost[] |
PublishReceipt |
First-party outcome of one attempt | job_id, idempotency_key, outcome (published/failed/unknown), remote_post_id, error_code, evidence |
AnalyticsSnapshot |
One native metrics observation | remote_post_id, observed_at, source, metrics (missing = null/absent, never zero); 1.1 adds optional window_start/window_end; 1.2 adds optional metric_status |
Experiment |
A pre-declared comparison | hypothesis, primary_metric, denominator, guardrail_metric, variant_ids, age_hours, min_pairs, declared_at |
Shared helper objects: UTM (source, medium, campaign, optional term/content), Cost (amount ≥ 0, ISO-4217-style currency, kind), Consent (subject, obtained, recorded_at, optional reference), Metrics (the eight storage.METRIC_FIELDS, each a non-negative integer or null), and, from 1.2, MetricStatuses (the same eight keys, each an optional MetricStatus: status in suppressed, not_available, delayed, estimated, plus a non-negative integer value only for estimated).
Format rules
Section titled “Format rules”- Timestamps: RFC 3339 in UTC only (
2026-10-15T07:00:00Zor+00:00). Local offsets are rejected so that comparisons and hashes are unambiguous. - Locale: a BCP-47 subset,
language[-Script][-REGION|-UN M.49], e.g.en-US,pt-BR,zh-Hans-CN. - URLs: public
httpsonly. Credentials, IP hosts, localhost, fragments and secret-looking query keys (token,code,sig, …) are rejected, reusingcampaigns.public_url. DOIs match10.NNNN/.... - IDs: brand, campaign, audience and source IDs are lowercase slugs. Account IDs use the
accounts.pyrule. Idempotency keys are 16–128 characters of[A-Za-z0-9._:-]. Error codes aresnake_case. Remote post IDs may contain URIs (e.g.at://…) but no whitespace or control characters. - Secrets: any key anywhere in any document that matches
accounts.SECRET_FIELD(token,password,secret,cookie,session,api_key,private_key) is rejected. Credentials stay in environment variables named byAccount.env_prefix. Token values are never part of a document, so they cannot be logged from one. - Text:
PlatformVariant.textruns throughpolicy.validate_post, which applies the conservative per-platform limit, placeholder detection and control-character checks. It also refuses Unicode bidi embeddings, overrides and isolates (U+202A-U+202E, U+2066-U+2069; Trojan Source) and invisible format characters such as U+2060 and U+FEFF. ZWNJ, ZWJ, ZWSP and the LRM/RLM/ALM marks that multilingual and emoji text needs still pass. A Bluesky job with 290 characters fails even though the same text would be fine on LinkedIn. - Media and format:
text,articleandlinkcarry no media (linkneedslink_url).imageneeds image/gif assets.videoneeds exactly one video.carouselneeds ≥ 2 image/video assets. Images needalt_text. Videos needduration_seconds,has_captions, and atranscript_uriif not captioned.people_depicted: trueneeds aConsentwithsubject: people_depicted, obtained: true. A remote media URI must be public https; otherwise use a relative path. - Accounts: need an
account_ownerconsent withobtained: true. Mastodonservergoes throughrequire_https_public_server. Bluesky needshandle; Telegram needschat_id. - Hacker News is not a
PLATFORMSvalue, so it can never be aPlatformVariant. That keeps it human-only. - Snapshot window (1.1):
window_startandwindow_endgive the period the counts cover. They are RFC 3339 UTC, both or neither, andwindow_start ≤ window_end ≤ observed_at(equal bounds are allowed). Leaving both out means the window is unknown: the counts may be lifetime cumulative or cover a provider-defined period. Never fill in a guessed window. The rule mirrorsstorage.observation_window, whichmetrics --window-start/--window-endandimport-metricsapply before a row is stored. Sample:examples/schemas/analytics-snapshot-window.json(illustrative values, not a real account’s metrics). - Metric status (1.2, issue #15):
metric_statussays why a metric has no exact count.{"views": {"status": "suppressed"}, "impressions": {"status": "estimated", "value": 1200}}.suppressedmeans the platform withheld the value (for example below a privacy threshold),not_availablemeans the platform does not expose it,delayedmeans it is not final yet, andestimatedcarries the platform’s estimate invalue. A metric is either an exact count inmetricsor a status here, never both. A metric in neither is unknown. An estimate is never an exact metric, so it can never be summed with one. A snapshot needs at least one exact metric or one status. The rule mirrors themetric_snapshots.metric_statuscolumn, whichmetricsandimport-metricsfill from value tokens (storage.parse_metric_value) (ATTRIBUTION.md). A1.0or1.1document cannot carry the field (added in schema 1.2). Sample:examples/schemas/analytics-snapshot-status.json(illustrative values, not a real account’s metrics).
Immutable approved payload hash
Section titled “Immutable approved payload hash”payload_sha256(variant) is the SHA-256 of canonical_json(variant): UTF-8 JSON with sorted keys, (",", ":") separators, ensure_ascii=False, and NaN/Infinity rejected. Key order does not matter, but every byte of content does. The payload itself contains platform, account_id, text, media hashes, links, UTM values and platform_options, so the hash binds all of them.
PublishJob.payload_sha256must equal the hash ofPublishJob.payload. This is a self-consistency check.Approval.approved_payload_sha256is set by the human reviewer at approval time. Before a job can enterapproved,scheduledorpublishing, it must equal the hash of the current payload. Editing the payload and recomputing the job’s own hash still fails: the change needs a new review and a new approval.- Avoid non-integer floats in payloads you intend to hash on other runtimes. Python’s float formatting is deterministic, but other JSON encoders can differ.
Publish state machine
Section titled “Publish state machine”draft -> reviewed -> approved -> scheduled -> publishing -> published ^ | | ^ | |-----> failed -> draft (re-review + re-approve) | | | +---------+ +-----> unknown -> published | failed (receipt only) +---------+-----------+------------+draft/reviewed/approved/scheduled -> cancelled published, cancelled: terminaltransition_job(job, target, *, approval=None, receipt=None, account=None, prior_receipts=(), paused=False, now=None) validates the job and returns a new job dict. It never mutates its input, sends, or persists anything. It rejects:
- Unapproved publication:
draft/reviewedcannot jump topublishing. Enteringapproved,scheduledorpublishingrequires a validApprovalwithdecision: approved,reviewer_kind: human, a matchingapproval_id/variant_id, and an unexpiredexpires_at. - Wrong account:
Approval.account_iddiffers from the job’s, or anAccountdocument whoseid/platformdiffers from the job’s. - Cross-platform mismatch:
Approval.platformdiffers from the job’s, or the job’splatform/account_iddiffers from its payload’s (caught by schema validation). - Hash change: the payload hash differs from
approved_payload_sha256. - Replay after success: any
published -> publishing|publishedstep. Enteringpublishingis also refused whenprior_receiptsholds apublishedorunknownreceipt with the sameidempotency_key, so a copied job cannot re-send. - Blind retry:
unknowncannot return topublishing. Only aPublishReceipt(API response or owner-verified native account check) can move it topublishedorfailed.failedreturns todraftand dropsapproval_id. - Emergency stop and due time:
paused=Trueblockspublishing, and a scheduled job is blocked beforescheduled_at. - Receipts:
published,failedandunknownneed a receipt with a matchingjob_id/account_id/platform/idempotency_keyand outcome. A published receipt needs aremote_post_idand first-partyevidenceother thannone.
Mapping to the current SQLite outbox (documentation only)
Section titled “Mapping to the current SQLite outbox (documentation only)”STORAGE_STATUS_MAP records how storage.posts.status values correspond to canonical states. Storage behaviour is unchanged.
| storage status | canonical state | note |
|---|---|---|
draft |
draft |
check is a lint, not a recorded review, so storage has no reviewed |
approved |
approved |
storage approval is the approved_at timestamp, not a hash-bound Approval |
scheduled |
scheduled |
|
sending |
publishing |
durable claim under BEGIN IMMEDIATE |
published |
published |
remote_id ≈ remote_post_id |
needs_review |
unknown |
last_error ≈ error_code; never auto-retried |
cancelled |
cancelled |
|
published_manual |
none (unmapped) | posted by hand and recorded with mark-shared --url; never approved, queued or sent, so schema-export lists it as unmapped and never invents a PublishJob or Approval |
| — | failed |
reconcile --not-published goes straight from needs_review to draft, collapsing unknown -> failed -> draft |
Migration rules
Section titled “Migration rules”schema_versionis a"MAJOR.MINOR"string. The version is global, not per type: oneSCHEMA_VERSION(currently"1.2") covers the whole contract set, andMIGRATIONSis keyed by version alone. A bump that touches one type moves every type to the new number; types that did not change are identical in both versions.SUPPORTED_VERSIONSlists what this build reads, currently("1.0", "1.1", "1.2").- Minor bumps (1.0 → 1.1) may only add optional fields or new enum values. Older documents stay valid after a version-only step. Each added field records the version that introduced it (
F(since="1.1")). - Major bumps rename, remove, retype or tighten fields. They need an explicit step in
MIGRATIONS = {from_version: (to_version, pure_fn)}, a fixture, and a test. migrate(doc)returns a deep copy upgraded toSCHEMA_VERSION. The current version is the identity path, and1.0 → 1.1and1.1 → 1.2are each the identity plus a top-levelschema_versionbump. Embedded documents keep their declared version, so an approved payload is not rewritten (see rule 6). An unknown or cyclic version raisesValidationError("Unsupported schema_version …")instead of guessing.- Validation accepts any version in
SUPPORTED_VERSIONSand checks each document against the fields its declared version defines. A field newer than that version is rejected withadded in schema 1.1. An unsupported version, such as a future major, is rejected with a hint to runmigrate()first. Because rule 2 allows only optional additions, a supported older minor document needs no migration to stay valid. - Approvals do not survive migration. If a migration changes an approved
PlatformVariantin any way, its hash no longer matchesapproved_payload_sha256andtransition_jobrefuses it. A human must re-review and re-approve. Never rewrite an approval hash in a migration. The1.0 → 1.1step changes only the top-levelschema_version, so a migratedPublishJobkeeps its byte-identical1.0payload, and the existing approval still binds it. - Migrations are pure and offline. They never contact a platform or change SQLite rows.
| Version | Change | Migration from previous |
|---|---|---|
1.0 |
Initial contracts for the 12 types | — |
1.1 |
AnalyticsSnapshot gains optional window_start/window_end (minor) |
identity + schema_version bump |
1.2 |
AnalyticsSnapshot gains optional metric_status (minor, issue #15) |
identity + schema_version bump |
Replaceable connectors
Section titled “Replaceable connectors”Everything upstream of a connector (signals, evidence, briefs, drafts, approvals, jobs, receipts, snapshots, experiments) is vendor-neutral. Platform names are plain enum values from policy.PLATFORMS. Provider-specific knobs live only in PlatformVariant.platform_options, a free-form JSON object capped at 20 kB and checked for secret-like keys. Examples: Bluesky langs, LinkedIn visibility, YouTube privacy_status/made_for_kids. Unknown top-level fields are rejected, so a vendor field cannot leak into the shared contract by accident.
A connector reads a validated PlatformVariant, interprets its own platform_options, and returns a vendor-neutral PublishReceipt (remote_post_id, remote_url, error_code, cost). Swapping a connector, such as a direct API versus a native scheduler handoff, changes neither the upstream types nor the approval hash rules. Sample payloads live in examples/schemas/.
Exporting the legacy outbox (schema-export)
Section titled “Exporting the legacy outbox (schema-export)”reachweaver schema-export [--out FILE] [--type T] maps local SQLite rows to canonical documents at the current version, 1.2 (reachweaver/schema_export.py). It is read-only: the connection runs in PRAGMA query_only mode during the export, so no row, setting or event is written. It never sends, schedules, approves or reconciles anything. The output is one canonical-JSON document per line, written to stdout or to a new owner-only (0600) file. An existing path or symlink is refused, never overwritten. A summary goes to stderr, or to stdout when --out is set: counts per type, unmapped (each entry has type, id, source row and reason) and a note. --type limits both lists to one of Account, Evidence, PlatformVariant, PublishJob, PublishReceipt, AnalyticsSnapshot, Draft or Approval.
reachweaver schema-export --out state/canonical-export.jsonl # new 0600 file; summary on stdoutreachweaver schema-export --type Approval # no documents; unmapped reasons on stderrEvery emitted document passes validate. When storage lacks a required field, the exporter fills in an explicit unknown value only for a descriptive field whose domain has one: locale: "und" (the BCP 47 “undetermined” tag), and checked_by / display_name: "unknown". It never invents an identifier or join key (brand_id, campaign_id), an enum member, a consent record, a reviewer or a hash. Such documents go to unmapped instead.
| Legacy source | Exported as | Rule |
|---|---|---|
posts row staged by campaign-stage |
PlatformVariant + PublishJob (ids legacy:<post>:variant/:job) |
format: text; state from STORAGE_STATUS_MAP; idempotency_key = post id (the adapters’ real dedupe key); attempts = surviving send_claimed events; updated_at = last event; error_code = last_error for unknown |
posts row created with draft |
unmapped | no brand_id recorded |
any posts row |
Draft unmapped |
authorship and brief are not recorded; no supported version (1.0, 1.1, 1.2) has an unknown authorship value |
| rows needing an approval | Approval unmapped |
see below |
published row + published event |
PublishReceipt, evidence: api_response |
observed_at = event time |
published row + reconciled_published event |
PublishReceipt, evidence: owner_verified_native_account |
no surviving event: unmapped |
metric_snapshots of a published post with remote_id |
AnalyticsSnapshot |
absent metrics are null; source must be a slug; window_start/window_end are emitted (normalized to UTC Z) only when the row has them, a NULL window is left out as unknown, and a row with only one bound is unmapped; metric_status (1.2) is emitted only when the row records statuses, and a malformed status column makes the row unmapped |
campaign_variants.evidence_json items |
Evidence (<campaign>:<evidence id>) |
offsets normalized to UTC; conflicting duplicates unmapped |
accounts.json entry with brand_id and consent |
Account |
public fields only: id, platform, env_prefix, handle, server, chat_id, display_name, audience_ids, locale, consent |
What links a snapshot to its post (pinned by tests/test_analytics_linkage.py). Each exported AnalyticsSnapshot carries the account_id, platform and remote_post_id of the published post its row belongs to, the snapshot’s own source and observed_at, the observation window when stored, and job_id, which names the PublishJob (idempotency_key = the post id) and so the PublishReceipt with the same remote_post_id. campaign_id is the staged campaign, or the post’s campaign label when that is a valid slug; otherwise it is left out, never rewritten. A snapshot whose post has no recorded remote post id is reported as unmapped, never exported, and a metric nobody imported is null, never 0.
Approvals are deliberately not exported, and no version bump relaxes that. The 1.1 and 1.2 bumps only added optional snapshot fields (the window, then the per-metric status); Approval is unchanged. Legacy approval is only the approved_at timestamp written by approve. There is no reviewer identity and no approved_payload_sha256. Hashing the exported payload would fabricate a human approval of a canonical document that nobody reviewed. A 1.1 bump that made the hash optional would loosen a required field, which migration rule 2 does not allow, and it would weaken the hash gate for every future approval. Exported jobs in gated states carry approval_id: "legacy:<post>:approval", and that id is listed in unmapped with the reason. No Approval document exists for it, so transition_job refuses to advance an exported job. A canonical approval needs a fresh human review of the canonical payload.
A document whose text looks secret (the privacy.redact patterns, or the value of any secret-named REACHWEAVER_* environment variable) is unmapped rather than redacted, because redacting would change the payload. Use data-export for a redacted copy of the whole database. The decision record is ADR-0003.
