diff --git a/apps/decodex/src/radar.rs b/apps/decodex/src/radar.rs index 0755226f0..ab02238b0 100644 --- a/apps/decodex/src/radar.rs +++ b/apps/decodex/src/radar.rs @@ -42,6 +42,7 @@ const SCHEMA_VERSION: i64 = 3; const SIGNAL_SCHEMA: &str = "signal_entry/v1"; const SOCIAL_CANDIDATE_SCHEMA: &str = "social_candidate/v1"; const SOCIAL_POST_SCHEMA: &str = "social_post/v1"; +const SOCIAL_PUBLISH_RESERVATION_SCHEMA: &str = "social_publish_reservation/v1"; const UPSTREAM_IMPACT_SCHEMA: &str = "upstream_impact/v1"; const UPSTREAM_REVIEW_QUEUE_SCHEMA: &str = "upstream_review_queue/v1"; const UPSTREAM_REVIEW_SCHEMA: &str = "upstream_review/v1"; @@ -81,6 +82,7 @@ const SOCIAL_POST_LIFECYCLE_STATES: &[&str] = &[ "superseded_published", "superseded_text_only", ]; +const SOCIAL_PUBLISH_RESERVATION_STATUSES: &[&str] = &["active", "canceled", "consumed", "expired"]; const SOURCE_ITEM_KINDS: &[&str] = &["commit", "pull_request"]; const UPSTREAM_IMPACT_KINDS: &[&str] = &["browser_observation", "changelog", "commit", "pull_request", "release", "signal"]; @@ -465,11 +467,17 @@ struct ArtifactValidation { #[derive(Debug)] struct ValidationState { + active_social_publish_reservation_idempotency_keys: BTreeMap, + seen_terminal_social_post_idempotency_keys: BTreeMap, seen_signal_slugs: BTreeMap, } impl ValidationState { fn new() -> Self { - Self { seen_signal_slugs: BTreeMap::new() } + Self { + active_social_publish_reservation_idempotency_keys: BTreeMap::new(), + seen_terminal_social_post_idempotency_keys: BTreeMap::new(), + seen_signal_slugs: BTreeMap::new(), + } } } @@ -1011,6 +1019,22 @@ pub(crate) fn validate( if validation.schema.as_deref() == Some(SIGNAL_SCHEMA) { validate_signal_slug_uniqueness(path, &payload, &mut state, &mut errors); } + if validation.schema.as_deref() == Some(SOCIAL_POST_SCHEMA) { + validate_terminal_social_post_idempotency_key_uniqueness( + path, + &payload, + &mut state, + &mut errors, + ); + } + if validation.schema.as_deref() == Some(SOCIAL_PUBLISH_RESERVATION_SCHEMA) { + validate_active_social_publish_reservation_uniqueness( + path, + &payload, + &mut state, + &mut errors, + ); + } for error in validation.errors { errors.push(format!("{}: {error}", path.display())); @@ -4235,6 +4259,8 @@ fn validate_artifact(payload: &Value) -> ArtifactValidation { Some(SIGNAL_SCHEMA) => validate_signal(entry, &mut errors), Some(SOCIAL_CANDIDATE_SCHEMA) => validate_social_candidate(entry, &mut errors), Some(SOCIAL_POST_SCHEMA) => validate_social_post(entry, &mut errors), + Some(SOCIAL_PUBLISH_RESERVATION_SCHEMA) => + validate_social_publish_reservation(entry, &mut errors), Some(UPSTREAM_IMPACT_SCHEMA) => validate_upstream_impact(entry, &mut errors), Some(UPSTREAM_REVIEW_QUEUE_SCHEMA) => validate_upstream_review_queue(entry, &mut errors), Some(UPSTREAM_REVIEW_SCHEMA) => validate_upstream_review(entry, &mut errors), @@ -5223,6 +5249,105 @@ fn validate_social_candidate_decision(decision: Option<&Value>, errors: &mut Vec } } +fn validate_social_publish_reservation(entry: &Map, errors: &mut Vec) { + for field in ["slug", "idempotency_key", "reserved_at", "expires_at", "day", "timezone"] { + if !is_non_empty_string(entry.get(field)) { + errors.push(format!("{field} must be a non-empty string")); + } + } + + validate_social_publish_reservation_constants(entry, errors); + validate_social_publish_reservation_refs(entry.get("candidate_refs"), errors); + validate_non_empty_string_list(entry.get("duplicate_keys"), "duplicate_keys", errors); + validate_optional_string_list(entry.get("evidence_notes"), "evidence_notes", errors); + validate_social_publish_reservation_owner(entry.get("owner"), errors); + validate_rfc3339_field(entry, "reserved_at", errors); + validate_rfc3339_field(entry, "expires_at", errors); + validate_social_publish_reservation_status_payload(entry, errors); +} + +fn validate_social_publish_reservation_constants( + entry: &Map, + errors: &mut Vec, +) { + if string_field(entry, "channel") != Some("x") { + errors.push("channel must be x".into()); + } + if string_field(entry, "target_account") != Some("decodexspace") { + errors.push("target_account must be decodexspace".into()); + } + if string_field(entry, "controller_account") != Some("hackink") { + errors.push("controller_account must be hackink".into()); + } + if !matches_one_of(entry.get("mode"), SOCIAL_POST_MODES) { + errors.push(format!("mode must be one of {}", choices(SOCIAL_POST_MODES))); + } + if !matches_one_of(entry.get("status"), SOCIAL_PUBLISH_RESERVATION_STATUSES) { + errors.push(format!( + "status must be one of {}", + choices(SOCIAL_PUBLISH_RESERVATION_STATUSES) + )); + } +} + +fn validate_social_publish_reservation_refs(refs: Option<&Value>, errors: &mut Vec) { + let Some(refs) = refs.and_then(Value::as_object) else { + errors.push("candidate_refs must be an object".into()); + + return; + }; + let has_refs = ["social_candidates", "urls"] + .iter() + .any(|field| non_empty_array(refs.get(*field)).is_some()); + + if !has_refs { + errors.push("candidate_refs must include social_candidates or urls".into()); + } + if refs.get("urls").is_some_and(|urls| !is_https_string_array(urls)) { + errors.push("candidate_refs.urls must be a list of https URLs".into()); + } + + validate_optional_string_list( + refs.get("social_candidates"), + "candidate_refs.social_candidates", + errors, + ); +} + +fn validate_social_publish_reservation_owner(owner: Option<&Value>, errors: &mut Vec) { + let Some(owner) = owner else { + return; + }; + let Some(owner) = owner.as_object() else { + errors.push("owner must be an object when present".into()); + + return; + }; + + for field in ["automation_id", "branch", "pr_url", "run_id"] { + if owner.get(field).is_some_and(|value| !is_non_empty_string(Some(value))) { + errors.push(format!("owner.{field} must be non-empty when present")); + } + } + + if owner.get("pr_url").is_some_and(|value| !is_https_string(Some(value))) { + errors.push("owner.pr_url must be an https URL when present".into()); + } +} + +fn validate_social_publish_reservation_status_payload( + entry: &Map, + errors: &mut Vec, +) { + match string_field(entry, "status") { + Some("consumed") if !is_non_empty_string(entry.get("consumed_by_social_post")) => + errors.push("consumed_by_social_post is required when status is consumed".into()), + Some("canceled" | "expired") if !is_non_empty_string(entry.get("release_reason")) => + errors.push("release_reason is required when status is canceled or expired".into()), + _ => {}, + } +} + fn validate_social_post(entry: &Map, errors: &mut Vec) { for field in ["slug", "audience"] { if !is_non_empty_string(entry.get(field)) { @@ -5285,18 +5410,32 @@ fn validate_social_post_source_refs(refs: Option<&Value>, errors: &mut Vec, errors: &mut Vec) { @@ -5518,6 +5657,78 @@ fn validate_signal_slug_uniqueness( } } +fn validate_terminal_social_post_idempotency_key_uniqueness( + path: &Path, + payload: &Value, + state: &mut ValidationState, + errors: &mut Vec, +) { + let status = payload.get("status").and_then(Value::as_str); + + if !matches!(status, Some("published" | "blocked")) { + return; + } + + let Some(key) = payload + .get("decision") + .and_then(Value::as_object) + .and_then(|decision| decision.get("idempotency_key")) + .and_then(Value::as_str) + else { + return; + }; + + if let Some(existing) = + state.seen_terminal_social_post_idempotency_keys.insert(key.to_owned(), path.to_path_buf()) + { + errors.push(format!( + "{}: duplicate terminal social_post idempotency_key {key:?} also used by {}", + path.display(), + existing.display() + )); + } + if let Some(existing) = state.active_social_publish_reservation_idempotency_keys.get(key) { + errors.push(format!( + "{}: terminal social_post idempotency_key {key:?} conflicts with active reservation {}", + path.display(), + existing.display() + )); + } +} + +fn validate_active_social_publish_reservation_uniqueness( + path: &Path, + payload: &Value, + state: &mut ValidationState, + errors: &mut Vec, +) { + if payload.get("status").and_then(Value::as_str) != Some("active") { + return; + } + + let Some(key) = payload.get("idempotency_key").and_then(Value::as_str) else { + return; + }; + + if let Some(existing) = state.seen_terminal_social_post_idempotency_keys.get(key) { + errors.push(format!( + "{}: active social_publish_reservation idempotency_key {key:?} conflicts with terminal social_post {}", + path.display(), + existing.display() + )); + } + if let Some(existing) = state + .active_social_publish_reservation_idempotency_keys + .insert(key.to_owned(), path.to_path_buf()) + { + errors.push(format!( + "{}: duplicate active social_publish_reservation idempotency_key {key:?} also used by {}", + path.display(), + existing.display() + )); + } +} + fn validate_non_empty_string_list(value: Option<&Value>, label: &str, errors: &mut Vec) { let valid = non_empty_array(value).is_some_and(|values| { values.iter().all(|item| item.as_str().is_some_and(|item| !item.is_empty())) @@ -5553,6 +5764,17 @@ fn validate_optional_string_list(value: Option<&Value>, label: &str, errors: &mu } } +fn validate_rfc3339_field(entry: &Map, field: &str, errors: &mut Vec) { + let Some(value) = entry.get(field).and_then(Value::as_str).filter(|value| !value.is_empty()) + else { + return; + }; + + if OffsetDateTime::parse(value, &Rfc3339).is_err() { + errors.push(format!("{field} must be an RFC3339 timestamp")); + } +} + fn validate_optional_positive_integer_list( value: Option<&Value>, label: &str, @@ -5639,6 +5861,7 @@ fn known_schemas() -> String { SIGNAL_SCHEMA, SOCIAL_CANDIDATE_SCHEMA, SOCIAL_POST_SCHEMA, + SOCIAL_PUBLISH_RESERVATION_SCHEMA, UPSTREAM_IMPACT_SCHEMA, UPSTREAM_REVIEW_QUEUE_SCHEMA, UPSTREAM_REVIEW_SCHEMA, @@ -5843,6 +6066,133 @@ mod tests { assert!(errors[0].contains("duplicate slug")); } + #[test] + fn rejects_duplicate_terminal_social_post_idempotency_keys_across_files() { + let social_post = valid_social_post(); + let mut state = crate::radar::ValidationState::new(); + let mut errors = Vec::new(); + + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/one.json"), + &social_post, + &mut state, + &mut errors, + ); + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/two.json"), + &social_post, + &mut state, + &mut errors, + ); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("duplicate terminal social_post idempotency_key")); + } + + #[test] + fn permits_failed_social_post_idempotency_key_retry() { + let mut failed_post = valid_social_post(); + + failed_post["status"] = serde_json::json!("failed"); + + let published_post = valid_social_post(); + let mut state = crate::radar::ValidationState::new(); + let mut errors = Vec::new(); + + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/failed.json"), + &failed_post, + &mut state, + &mut errors, + ); + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/published.json"), + &published_post, + &mut state, + &mut errors, + ); + + assert!(errors.is_empty()); + } + + #[test] + fn accepts_valid_social_publish_reservation() { + let reservation = valid_social_publish_reservation(); + + assert_errors(&reservation, []); + } + + #[test] + fn rejects_duplicate_active_social_publish_reservation_idempotency_keys() { + let reservation = valid_social_publish_reservation(); + let mut state = crate::radar::ValidationState::new(); + let mut errors = Vec::new(); + + radar::validate_active_social_publish_reservation_uniqueness( + &PathBuf::from("artifacts/social/x/reservations/one.json"), + &reservation, + &mut state, + &mut errors, + ); + radar::validate_active_social_publish_reservation_uniqueness( + &PathBuf::from("artifacts/social/x/reservations/two.json"), + &reservation, + &mut state, + &mut errors, + ); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("duplicate active social_publish_reservation")); + } + + #[test] + fn rejects_active_reservation_for_terminal_social_post_idempotency_key() { + let social_post = valid_social_post(); + let reservation = valid_social_publish_reservation(); + let mut state = crate::radar::ValidationState::new(); + let mut errors = Vec::new(); + + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/published.json"), + &social_post, + &mut state, + &mut errors, + ); + radar::validate_active_social_publish_reservation_uniqueness( + &PathBuf::from("artifacts/social/x/reservations/active.json"), + &reservation, + &mut state, + &mut errors, + ); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("conflicts with terminal social_post")); + } + + #[test] + fn rejects_terminal_social_post_for_active_reservation_idempotency_key() { + let social_post = valid_social_post(); + let reservation = valid_social_publish_reservation(); + let mut state = crate::radar::ValidationState::new(); + let mut errors = Vec::new(); + + radar::validate_active_social_publish_reservation_uniqueness( + &PathBuf::from("artifacts/social/x/reservations/active.json"), + &reservation, + &mut state, + &mut errors, + ); + radar::validate_terminal_social_post_idempotency_key_uniqueness( + &PathBuf::from("artifacts/social/x/posts/published.json"), + &social_post, + &mut state, + &mut errors, + ); + + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("conflicts with active reservation")); + } + #[test] fn accepts_valid_release_delta_and_rejects_missing_default_pair() { let mut release_delta = valid_release_delta(); @@ -6760,4 +7110,40 @@ mod tests { "media_refs": ["https://x.com/decodexspace/status/1/photo/1"] }) } + + fn valid_social_publish_reservation() -> Value { + serde_json::json!({ + "schema": "social_publish_reservation/v1", + "slug": "openai-codex-pr-22414", + "channel": "x", + "target_account": "decodexspace", + "controller_account": "hackink", + "mode": "operator_impact", + "status": "active", + "idempotency_key": "x:decodexspace:operator_impact:openai-codex-pr-22414", + "reserved_at": "2026-06-02T02:50:00Z", + "expires_at": "2026-06-02T03:50:00Z", + "day": "2026-06-02", + "timezone": "Asia/Shanghai", + "candidate_refs": { + "social_candidates": [ + "artifacts/github/social-candidates/openai-codex-pr-22414.json" + ], + "urls": ["https://github.com/openai/codex/pull/22414"] + }, + "duplicate_keys": [ + "Remote Codex can now use Unix socket endpoints.", + "https://github.com/openai/codex/pull/22414" + ], + "owner": { + "automation_id": "decodex-x-publisher", + "branch": "automation/decodex-x-publisher-2026-06-02-pr-22414", + "pr_url": "https://github.com/hack-ink/decodex/pull/1", + "run_id": "2026-06-02T02:50:00Z" + }, + "evidence_notes": [ + "Created before compose after checked-in records and live profile readback were clear." + ] + }) + } } diff --git a/artifacts/social/x/posts/2026-06-06/openai-codex-app-26-602.json b/artifacts/social/x/posts/2026-06-06/openai-codex-app-26-602.json new file mode 100644 index 000000000..389449b4c --- /dev/null +++ b/artifacts/social/x/posts/2026-06-06/openai-codex-app-26-602.json @@ -0,0 +1,97 @@ +{ + "schema": "social_post/v1", + "slug": "openai-codex-app-26-602", + "channel": "x", + "target_account": "decodexspace", + "controller_account": "hackink", + "mode": "release_pulse", + "status": "published", + "audience": "Codex app users and Decodex operators", + "text": [ + "Codex app 26.602 is out.\n\nWhat changed:\n- Profile activity insights and share cards\n- Better Computer Use startup and appshot errors\n- Browser/review UI fixes plus expanded onboarding\n\nChangelog: https://developers.openai.com/codex/changelog" + ], + "source_refs": { + "social_candidates": [ + "artifacts/github/social-candidates/openai-codex-app-26-602.json" + ], + "urls": [ + "https://developers.openai.com/codex/changelog", + "https://x.com/decodexspace/status/2063109099135574379", + "https://x.com/decodexspace/status/2063109099135574379/photo/1", + "https://x.com/decodexspace/status/2063198209271554481" + ] + }, + "evidence_notes": [ + "The social_candidate/v1 artifact has decision.worthiness = publish and mode release_pulse.", + "The official Codex changelog lists Codex app updates 26.602 on 2026-06-04.", + "The changelog says activity insights and share cards were added to the Profile section, with sharing available on consumer ChatGPT plans.", + "The changelog says Computer Use startup readiness and appshot error reporting improved.", + "The changelog says browser and review UI issues were fixed and onboarding was expanded with more role choices.", + "A post-publication profile timeline audit found an earlier matching @decodexspace post at https://x.com/decodexspace/status/2063109099135574379 with /photo/1 media; this later post is a duplicate publication.", + "Asia/Shanghai cap accounting initially found zero checked-in @decodexspace published records for 2026-06-06, but live profile readback proves the correct pre-publish count was at least one.", + "X duplicate detection for from:decodexspace with the exact Codex app 26.602 phrase returned no results before composing.", + "A post-publication X search repeated the exact phrase and still returned no results even while the profile timeline exposed both matching posts, so X search no-results is not reliable duplicate evidence.", + "Chrome account readback showed Decodex @decodexspace before composing.", + "The final permalink readback confirmed @decodexspace, @hackink automation attribution, the expected post text, and the official changelog link card." + ], + "claims": [ + { + "text": "Codex app 26.602 includes Profile activity insights and share cards.", + "evidence": "https://developers.openai.com/codex/changelog", + "confidence": "confirmed" + }, + { + "text": "Codex app 26.602 improves Computer Use startup readiness and appshot error reporting.", + "evidence": "https://developers.openai.com/codex/changelog", + "confidence": "confirmed" + }, + { + "text": "Codex app 26.602 includes browser and review UI fixes plus expanded onboarding role choices.", + "evidence": "https://developers.openai.com/codex/changelog", + "confidence": "confirmed" + }, + { + "text": "The published X post is live on @decodexspace and includes the official changelog link card.", + "evidence": "https://x.com/decodexspace/status/2063198209271554481", + "confidence": "confirmed" + }, + { + "text": "The published X post duplicates an earlier live @decodexspace Codex app 26.602 post.", + "evidence": "https://x.com/decodexspace/status/2063109099135574379", + "confidence": "confirmed" + } + ], + "decision": { + "worthiness": "publish", + "priority": "high", + "idempotency_key": "x:decodexspace:codex-app-26-602:release_pulse", + "reason": "The official changelog has concrete user-visible app changes and enough reader value for a source-led release pulse.", + "daily_limit": 8, + "daily_count_before": 1, + "daily_count_after": 2, + "day": "2026-06-06", + "timezone": "Asia/Shanghai" + }, + "publication": { + "posted_at": "2026-06-06T09:56:00Z", + "published_urls": [ + "https://x.com/decodexspace/status/2063198209271554481" + ], + "publisher": "chrome", + "account_verified": true, + "made_with_ai": false + }, + "caveats": [ + "This post is a duplicate of the earlier media-attached live post at https://x.com/decodexspace/status/2063109099135574379.", + "Do not treat X search no-results as sufficient duplicate-detection evidence for future publication decisions.", + "The candidate's declared generated-media path /Users/x/.codex/decodex/social-media/codex-app-26-602.png was missing at publication time.", + "The post was intentionally published text-only because the official changelog link card and compact release bullets carried the reader value without a generic replacement image.", + "Sharing for profile cards is described as available on consumer ChatGPT plans." + ], + "post_lifecycle": { + "current_state": "superseded_published", + "quote_eligible": false, + "superseded_by_candidate": "https://x.com/decodexspace/status/2063109099135574379", + "reason": "Post-publication profile readback found an earlier matching live Codex app 26.602 post; this later text-only post is a duplicate and must not be reused as future publication evidence." + } +} diff --git a/dev/skills/references/social-release-publisher-gates.md b/dev/skills/references/social-release-publisher-gates.md index 318fd0f65..b964eef37 100644 --- a/dev/skills/references/social-release-publisher-gates.md +++ b/dev/skills/references/social-release-publisher-gates.md @@ -65,6 +65,14 @@ For publishable prerelease candidates, evidence must name: depends on AI-rendered readable text. - Generated images live in `$CODEX_HOME/decodex/social-media/` or temporary storage by default, not Git. +- Before composing any release, app, or prerelease post, check durable records, pending + publication PRs when available, active `social_publish_reservation/v1` records, and + the live `@decodexspace` profile/timeline for the exact lead text, release tag, + source URL, and prior status URLs. X search `No results` is not a duplicate-clear + signal by itself. +- Do not open X compose until an active `social_publish_reservation/v1` for the same + idempotency key and duplicate keys is committed and PR-visible. Repeat live + profile/timeline duplicate readback immediately before clicking Post. - If account verification, duplicate detection, media upload, or final readback is unreliable, fail closed. Do not downgrade to text-only unless the operator explicitly approves that fallback for the current candidate. diff --git a/dev/skills/x-post-publisher/SKILL.md b/dev/skills/x-post-publisher/SKILL.md index c5b797cd1..b977a1b24 100644 --- a/dev/skills/x-post-publisher/SKILL.md +++ b/dev/skills/x-post-publisher/SKILL.md @@ -41,11 +41,26 @@ Publish only when all are true: - prerelease channel lineage and previous-post quote state were checked through `post_lifecycle.quote_eligible` - idempotency key has not already been published or blocked for the same source +- checked-in records, active `social_publish_reservation/v1` records, open + publication PRs when available, and live `@decodexspace` profile/timeline readback + show no matching post for the candidate's exact lead text, idempotency subject, + release tag, or source URL +- an active `social_publish_reservation/v1` with the same idempotency key and + duplicate keys has been committed and pushed so it is PR-visible before X compose - daily cap of 8 posts for `@decodexspace` in `Asia/Shanghai` is not reached For release/prerelease/app candidates, apply `../references/social-release-publisher-gates.md` before composing. +Do not treat X search `No results` as sufficient duplicate evidence. Use search only +as a supporting signal; if profile/timeline readback is unavailable, stale, +loading-only, or contradicts search, fail closed before composing. + +Do not compose from a local-only, uncommitted, or unpushed reservation. After the +reservation is PR-visible and immediately before clicking Post, repeat live +profile/timeline duplicate readback. If a duplicate appears, cancel or expire the +reservation and do not publish. + ## Chrome And Media Use `@Chrome` only inside this low-frequency Publisher workflow. Before composing, @@ -76,6 +91,12 @@ Write `artifacts/social/x/posts//.json` with: details as applicable - X status/media URL or media caveat when media was used or skipped - `post_lifecycle` when the record can affect future prerelease quote chains +- the consumed reservation path under `source_refs.reservations` when the compose gate + was reached + +Update `artifacts/social/x/reservations//.json` from `active` to +`consumed`, `canceled`, or `expired` before the run ends. Do not leave an active +reservation behind unless the thread is explicitly handed off to a human operator. Run: diff --git a/dev/skills/x-post-quality-system/SKILL.md b/dev/skills/x-post-quality-system/SKILL.md index 65904998a..c57c6d940 100644 --- a/dev/skills/x-post-quality-system/SKILL.md +++ b/dev/skills/x-post-quality-system/SKILL.md @@ -64,8 +64,13 @@ Required shared elements: source-agnostic placeholders Before upload, verify the image is specific, visually consistent, readable as an X -preview, and not generic or off-brand. Record prompt/media path/quality outcome in -`social_post/v1` evidence notes or caveats when useful. +preview, and not generic or off-brand. Also verify the candidate is not already live +on the `@decodexspace` profile/timeline and does not conflict with any active +`social_publish_reservation/v1` in checked records or open publication PRs. X search +`No results` is not enough. +Record prompt/media path/quality outcome in `social_post/v1` evidence notes or caveats +when useful. Fail closed when media is generic, reused, unavailable but required, or when duplicate -detection, account verification, upload, or final readback is unreliable. +detection, reservation visibility, account verification, upload, or final readback is +unreliable. diff --git a/docs/runbook/social-publishing-workflow.md b/docs/runbook/social-publishing-workflow.md index 28084efa7..1595092a2 100644 --- a/docs/runbook/social-publishing-workflow.md +++ b/docs/runbook/social-publishing-workflow.md @@ -33,6 +33,8 @@ Outputs: - An optional `upstream_impact/v1` artifact under `artifacts/github/impact/`. - A `social_candidate/v1` record under `artifacts/github/social-candidates/` when analysis needs a durable Publisher handoff or pre-publication decision. +- A `social_publish_reservation/v1` record under + `artifacts/social/x/reservations//` before any X compose step. - A `social_post/v1` record under `artifacts/social/x/posts//`. - Optional local generated media under `$CODEX_HOME/decodex/social-media/`; generated media files are not committed by default. @@ -143,10 +145,30 @@ one exact compare URL intentionally carries the detailed PR list. 5. Check idempotency and daily cap. - Build a stable idempotency key from account, source, mode, and release checkpoint when applicable. - - Count already-published `@decodexspace` records for the cap day. + - Count already-published `@decodexspace` records for the cap day from checked-in + `social_post/v1` records, and inspect open PRs that add `social_post/v1` records + when the current worktree may not include every pending publication record. + - Check active `social_publish_reservation/v1` records in the current tree and open + publication PRs. If another active reservation has the same idempotency key, + source URL, exact lead text, release tag, or candidate slug, fail closed instead + of composing. + - Run live duplicate detection against the `@decodexspace` profile/timeline before + composing. Match the candidate's exact lead text, idempotency subject, release + tag, source URL, and known prior status URLs. + - X search can be an additional signal, but `No results` is not sufficient proof + that no duplicate exists. If X search and profile/timeline readback disagree, or + either surface is loading-only or unreadable, fail closed. - The default cap day uses `Asia/Shanghai`. - If the candidate would exceed 8 posts, do not post. Write `status = "blocked"` with `block.reason = "daily_cap_exceeded"`. + - Before opening the X composer, create an active + `social_publish_reservation/v1` record with the idempotency key, duplicate keys, + owner/run metadata, `reserved_at`, and `expires_at`. + - Commit and push the reservation, or update the publication PR so the reservation + is PR-visible. A local-only reservation does not authorize publication. + - After the reservation is visible and immediately before clicking Post, repeat the + live profile/timeline duplicate readback. If a duplicate appears, cancel or expire + the reservation and do not publish. 6. Prepare media only when useful. - Use the `decodex_signal_card` image template in @@ -172,11 +194,17 @@ one exact compare URL intentionally carries the detailed PR list. - Use `schema = "social_post/v1"`. - Use `target_account = "decodexspace"` and `controller_account = "hackink"`. - Set `status = "published"`, `blocked`, `failed`, or `skipped`. + - Include the consumed reservation under `source_refs.reservations` when the run + reached the compose gate. - Preserve source refs, evidence notes, claims, decision data, and publication URLs when available. - For media, preserve the X status URL and any `/photo/N` readback URL. Do not add a generated image file to Git unless the operator explicitly asks for a permanent sample. + - Update the reservation to `consumed` with `consumed_by_social_post` after a + published, blocked, or otherwise terminal audited result. Use `canceled` or + `expired` with `release_reason` when publication stops before a durable terminal + post record is useful. 9. Validate. - Run: diff --git a/docs/spec/social-publishing.md b/docs/spec/social-publishing.md index 44f03a2cf..963c956c2 100644 --- a/docs/spec/social-publishing.md +++ b/docs/spec/social-publishing.md @@ -19,6 +19,7 @@ Not this document: Defines: - The `social_post/v1` artifact shape. +- The `social_publish_reservation/v1` pre-compose reservation shape. - Allowed post modes for Decodex Publisher. - The automated Chrome publishing boundary. - The daily cap and blocked-publication ledger rule. @@ -29,15 +30,21 @@ Defines: The canonical schema identifier is: - `social_post/v1` +- `social_publish_reservation/v1` Recommended checked-in locations: - `artifacts/social/x/posts//.json` +- `artifacts/social/x/reservations//.json` `social_post/v1` is a publication record, not a review-only draft or pre-publication candidate. Use `social_candidate/v1` for handoff decisions before Publisher evaluates account state, idempotency, daily cap, media, and final publication. +`social_publish_reservation/v1` is a pre-compose lease. Publisher automation must +create a checked, PR-visible active reservation before opening X compose. A local-only, +uncommitted, or unpushed reservation does not authorize publication. + Generated media files are not default Git artifacts. Store successful publication facts in Git as small JSON records. Store generated image files in a local persistent media cache, or discard them after upload, unless an operator explicitly asks to commit @@ -56,7 +63,7 @@ an exact sample. | `status` | string | `published`, `blocked`, `failed`, or `skipped`. | | `audience` | string | Primary reader group. | | `text` | array | One or more English post bodies, one array item per thread post. | -| `source_refs` | object | Links to signal, upstream-impact, upstream-review, release, PR, or changelog evidence. | +| `source_refs` | object | Links to reservation, candidate, signal, upstream-impact, upstream-review, release, PR, or changelog evidence. | | `evidence_notes` | array | Non-empty list of evidence-backed notes that justify the post or skip decision. | | `claims` | array | Non-empty list of user-facing claims with evidence references. | | `decision` | object | AI worthiness, priority, idempotency key, daily counter, and cap decision. | @@ -153,6 +160,44 @@ The daily cap is hard. Automation must not publish the ninth `@decodexspace` pos the same cap day. Instead it must write a `status = "blocked"` record with `block.reason = "daily_cap_exceeded"`. +## Publication Reservation Object + +`social_publish_reservation/v1` prevents two Publisher runs from composing the same +post when durable `social_post/v1` records have not been merged yet. + +Required fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `schema` | string | Must be `social_publish_reservation/v1`. | +| `slug` | string | Stable URL-safe identifier for the candidate. | +| `channel` | string | Must be `x`. | +| `target_account` | string | Must be `decodexspace`. | +| `controller_account` | string | Must be `hackink`. | +| `mode` | string | One value from the post-mode table. | +| `status` | string | `active`, `consumed`, `canceled`, or `expired`. | +| `idempotency_key` | string | Same stable key that the terminal `social_post/v1` record will use. | +| `reserved_at` | string | UTC RFC3339 timestamp. | +| `expires_at` | string | UTC RFC3339 timestamp for stale-reservation cleanup. | +| `day` | string | Calendar day used for cap accounting, formatted `YYYY-MM-DD`. | +| `timezone` | string | Default is `Asia/Shanghai`. | +| `candidate_refs` | object | Links to `social_candidate/v1` artifacts or source URLs that authorize the reservation. | +| `duplicate_keys` | array | Non-empty strings to check in durable records, open PRs, and live profile readback. | + +Optional fields: + +- `owner`: automation id, run id, branch, and PR URL that own the active reservation. +- `evidence_notes`: notes about duplicate checks and account/profile readback at + reservation time. +- `consumed_by_social_post`: required when `status = "consumed"`. +- `release_reason`: required when `status = "canceled"` or `status = "expired"`. + +Validation rule: `decodex radar validate` rejects duplicate active reservation +idempotency keys and rejects an active reservation whose key already has a terminal +`published` or `blocked` `social_post/v1` record. Failed or skipped publication +attempts do not reserve the key permanently; a retry must create a fresh active +reservation and consume, cancel, or expire it at the end of the run. + ## Blocked Cap Records When a candidate is blocked by the daily cap, the record must preserve the review @@ -187,6 +232,20 @@ described here. It must use the logged-in `@decodexspace` account, verify the ac before composing, and fail closed when Chrome, login state, X page structure, duplicate detection, or media upload is unreliable. +Duplicate detection must not rely on X search alone. Before composing, Publisher +automation must check durable `social_post/v1` records, active +`social_publish_reservation/v1` records in the checked tree and open publication PRs +when available, and a live `@decodexspace` profile/timeline readback for the +candidate's exact lead text, idempotency subject, release tag, or source URL. Treat an +X search `No results` state as weak evidence only; if profile or permalink readback is +unavailable, stale, loading-only, or contradicts search, fail closed instead of +publishing. + +After the active reservation is PR-visible and immediately before clicking Post, +Publisher automation must repeat live profile/timeline readback. If a duplicate appears +at that final gate, cancel or expire the reservation and write a non-published +`social_post/v1` record only when it adds audit or idempotency value. + Chrome tabs are temporary execution resources. Publisher automation must close or release research, compose, upload, and readback tabs after the `social_post/v1` record captures the result. A tab may stay open only as an explicit human handoff, such as diff --git a/scripts/github/README.md b/scripts/github/README.md index fe35755e1..c6e5b9f18 100644 --- a/scripts/github/README.md +++ b/scripts/github/README.md @@ -47,6 +47,8 @@ Current checked contracts: - `release_delta/v1` artifacts are validated by `decodex radar validate`. - `upstream_impact/v1` artifacts are validated by `decodex radar validate`. - `social_candidate/v1` artifacts are validated by `decodex radar validate`. +- `social_publish_reservation/v1` artifacts are validated by + `decodex radar validate`. - `social_post/v1` artifacts are validated by `decodex radar validate`. Contract ownership: diff --git a/scripts/github/contracts.py b/scripts/github/contracts.py index a55a8a304..9a177bb89 100644 --- a/scripts/github/contracts.py +++ b/scripts/github/contracts.py @@ -17,6 +17,7 @@ UPSTREAM_REVIEW_SCHEMA = "upstream_review/v1" SOCIAL_CANDIDATE_SCHEMA = "social_candidate/v1" SOCIAL_POST_SCHEMA = "social_post/v1" +SOCIAL_PUBLISH_RESERVATION_SCHEMA = "social_publish_reservation/v1" ANALYSIS_MODES = {"pr_first", "commit_only"} SIGNAL_KINDS = {"capability", "behavior_change", "try_now"} SIGNAL_CONFIDENCE = {"confirmed", "likely", "weak"} @@ -51,6 +52,7 @@ "superseded_published", "superseded_text_only", } +SOCIAL_PUBLISH_RESERVATION_STATUSES = {"active", "canceled", "consumed", "expired"} SOCIAL_BLOCK_REASONS = { "daily_cap_exceeded", "duplicate", @@ -687,7 +689,6 @@ def validate_social_candidate(entry: dict[str, Any]) -> ValidationResult: or not all(isinstance(url, str) and url.startswith("https://") for url in urls) ): errors.append("source_refs.urls must be a list of https URLs") - for list_field in ("evidence_notes", "claims"): values = entry.get(list_field) if not isinstance(values, list) or not values: @@ -730,6 +731,100 @@ def validate_social_candidate(entry: dict[str, Any]) -> ValidationResult: return ValidationResult(ok=not errors, errors=errors) +def validate_social_publish_reservation(entry: dict[str, Any]) -> ValidationResult: + errors: list[str] = [] + + if entry.get("schema") != SOCIAL_PUBLISH_RESERVATION_SCHEMA: + errors.append(f"schema must be {SOCIAL_PUBLISH_RESERVATION_SCHEMA}") + + for field in ("slug", "idempotency_key", "reserved_at", "expires_at", "day", "timezone"): + if not isinstance(entry.get(field), str) or not entry[field]: + errors.append(f"{field} must be a non-empty string") + + if entry.get("channel") != "x": + errors.append("channel must be x") + if entry.get("target_account") != "decodexspace": + errors.append("target_account must be decodexspace") + if entry.get("controller_account") != "hackink": + errors.append("controller_account must be hackink") + if entry.get("mode") not in SOCIAL_POST_MODES: + errors.append(f"mode must be one of {sorted(SOCIAL_POST_MODES)}") + if entry.get("status") not in SOCIAL_PUBLISH_RESERVATION_STATUSES: + errors.append(f"status must be one of {sorted(SOCIAL_PUBLISH_RESERVATION_STATUSES)}") + + refs = entry.get("candidate_refs") + if not isinstance(refs, dict): + errors.append("candidate_refs must be an object") + else: + present = [ + name + for name in ("social_candidates", "urls") + if isinstance(refs.get(name), list) and refs[name] + ] + if not present: + errors.append("candidate_refs must include social_candidates or urls") + social_candidates = refs.get("social_candidates", []) + if social_candidates and ( + not isinstance(social_candidates, list) + or not all(isinstance(item, str) and item for item in social_candidates) + ): + errors.append("candidate_refs.social_candidates must be a list of non-empty strings") + urls = refs.get("urls", []) + if urls and ( + not isinstance(urls, list) + or not all(isinstance(url, str) and url.startswith("https://") for url in urls) + ): + errors.append("candidate_refs.urls must be a list of https URLs") + + duplicate_keys = entry.get("duplicate_keys") + if ( + not isinstance(duplicate_keys, list) + or not duplicate_keys + or not all(isinstance(item, str) and item for item in duplicate_keys) + ): + errors.append("duplicate_keys must be a non-empty list of strings") + + for field in ("reserved_at", "expires_at"): + value = entry.get(field) + if isinstance(value, str) and value: + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + errors.append(f"{field} must be an RFC3339 timestamp") + + owner = entry.get("owner") + if owner is not None: + if not isinstance(owner, dict): + errors.append("owner must be an object when present") + else: + for field in ("automation_id", "branch", "pr_url", "run_id"): + value = owner.get(field) + if value is not None and (not isinstance(value, str) or not value): + errors.append(f"owner.{field} must be non-empty when present") + pr_url = owner.get("pr_url") + if pr_url is not None and (not isinstance(pr_url, str) or not pr_url.startswith("https://")): + errors.append("owner.pr_url must be an https URL when present") + + evidence_notes = entry.get("evidence_notes", []) + if evidence_notes is not None and ( + not isinstance(evidence_notes, list) + or not all(isinstance(item, str) and item for item in evidence_notes) + ): + errors.append("evidence_notes must be a list of non-empty strings when present") + + status = entry.get("status") + if status == "consumed" and ( + not isinstance(entry.get("consumed_by_social_post"), str) or not entry["consumed_by_social_post"] + ): + errors.append("consumed_by_social_post is required when status is consumed") + if status in {"canceled", "expired"} and ( + not isinstance(entry.get("release_reason"), str) or not entry["release_reason"] + ): + errors.append("release_reason is required when status is canceled or expired") + + return ValidationResult(ok=not errors, errors=errors) + + def validate_social_post(entry: dict[str, Any]) -> ValidationResult: errors: list[str] = [] @@ -765,17 +860,40 @@ def validate_social_post(entry: dict[str, Any]) -> ValidationResult: else: present = [ name - for name in ("signals", "upstream_impacts", "upstream_reviews", "urls") + for name in ( + "reservations", + "signals", + "social_candidates", + "upstream_impacts", + "upstream_reviews", + "urls", + ) if isinstance(refs.get(name), list) and refs[name] ] if not present: - errors.append("source_refs must include signals, upstream_impacts, upstream_reviews, or urls") + errors.append( + "source_refs must include reservations, signals, social_candidates, " + "upstream_impacts, upstream_reviews, or urls" + ) urls = refs.get("urls", []) if urls and ( not isinstance(urls, list) or not all(isinstance(url, str) and url.startswith("https://") for url in urls) ): errors.append("source_refs.urls must be a list of https URLs") + for field in ( + "reservations", + "signals", + "social_candidates", + "upstream_impacts", + "upstream_reviews", + ): + values = refs.get(field, []) + if values and ( + not isinstance(values, list) + or not all(isinstance(item, str) and item for item in values) + ): + errors.append(f"source_refs.{field} must be a list of non-empty strings") for list_field in ("evidence_notes", "claims"): values = entry.get(list_field) diff --git a/scripts/github/social_post.schema.json b/scripts/github/social_post.schema.json index 3195cbc5b..c0ff79295 100644 --- a/scripts/github/social_post.schema.json +++ b/scripts/github/social_post.schema.json @@ -67,6 +67,12 @@ "type": "object", "additionalProperties": false, "anyOf": [ + { + "required": ["reservations"] + }, + { + "required": ["social_candidates"] + }, { "required": ["signals"] }, @@ -81,6 +87,22 @@ } ], "properties": { + "reservations": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "social_candidates": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, "signals": { "type": "array", "minItems": 1, diff --git a/scripts/github/social_publish_reservation.schema.json b/scripts/github/social_publish_reservation.schema.json new file mode 100644 index 000000000..7b1603546 --- /dev/null +++ b/scripts/github/social_publish_reservation.schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Decodex social publish reservation", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "slug", + "channel", + "target_account", + "controller_account", + "mode", + "status", + "idempotency_key", + "reserved_at", + "expires_at", + "day", + "timezone", + "candidate_refs", + "duplicate_keys" + ], + "properties": { + "schema": { + "const": "social_publish_reservation/v1" + }, + "slug": { + "type": "string", + "minLength": 1 + }, + "channel": { + "const": "x" + }, + "target_account": { + "const": "decodexspace" + }, + "controller_account": { + "const": "hackink" + }, + "mode": { + "type": "string", + "enum": [ + "release_pulse", + "release_rollup", + "practical_explainer", + "operator_impact", + "thread", + "watch_note" + ] + }, + "status": { + "type": "string", + "enum": ["active", "canceled", "consumed", "expired"] + }, + "idempotency_key": { + "type": "string", + "minLength": 1 + }, + "reserved_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "day": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "timezone": { + "type": "string", + "minLength": 1 + }, + "candidate_refs": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": ["social_candidates"] + }, + { + "required": ["urls"] + } + ], + "properties": { + "social_candidates": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "urls": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^https://" + } + } + } + }, + "duplicate_keys": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "owner": { + "type": "object", + "additionalProperties": false, + "properties": { + "automation_id": { + "type": "string", + "minLength": 1 + }, + "run_id": { + "type": "string", + "minLength": 1 + }, + "branch": { + "type": "string", + "minLength": 1 + }, + "pr_url": { + "type": "string", + "pattern": "^https://" + } + } + }, + "evidence_notes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "consumed_by_social_post": { + "type": "string", + "minLength": 1 + }, + "release_reason": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "consumed" + } + } + }, + "then": { + "required": ["consumed_by_social_post"] + } + }, + { + "if": { + "properties": { + "status": { + "enum": ["canceled", "expired"] + } + } + }, + "then": { + "required": ["release_reason"] + } + } + ] +}