From acb33a67679a8f1a1a962eb60e06634e8bda3545 Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sat, 1 Aug 2026 12:26:24 -0400 Subject: [PATCH] feat(cli): add publisher publication lifecycle --- Cargo.lock | 1 + README.md | 10 +- crates/frameshift-cli/Cargo.toml | 2 + crates/frameshift-cli/src/cmd/publication.rs | 313 ++++++++++++- crates/frameshift-cli/src/main.rs | 63 ++- crates/frameshift-client/src/error.rs | 7 + crates/frameshift-client/src/publication.rs | 438 ++++++++++++++++++- docs/wiki/CLI-Reference.md | 22 +- docs/wiki/Publishing-and-Moderation.md | 25 +- 9 files changed, 865 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0bf1f9a..8ca0219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2073,6 +2073,7 @@ dependencies = [ name = "frameshift-cli" version = "0.10.0" dependencies = [ + "chrono", "clap", "ed25519-dalek", "frameshift-catalog", diff --git a/README.md b/README.md index e18018a..4b74cb9 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ On registry install, the client verifies the pack signature against the exact ke ### Admin -The registry server exposes account-authenticated lifecycle controls. Publisher owners can withdraw eligible non-public submissions with `POST /v1/publication-submissions/{id}/withdraw`. Active administrators can suspend a publisher with `POST /v1/admin/publishers/{publisher_id}/suspend` or tombstone an active release with `POST /v1/admin/packs/{name}/{version}/tombstone`. Each accepted transition and its reason are committed atomically to immutable audit evidence. Publisher owners can read their scoped evidence at `GET /v1/publishers/{handle}/publication-decisions`; administrators can read the global stream at `GET /v1/admin/publication-decisions`. +The registry server exposes account-authenticated lifecycle controls. Publisher owners can withdraw eligible non-public submissions with `frameshift publication withdraw` and read their scoped immutable evidence with `frameshift publication decisions`. Active administrators can suspend a publisher with `POST /v1/admin/publishers/{publisher_id}/suspend` or tombstone an active release with `POST /v1/admin/packs/{name}/{version}/tombstone`. Each accepted transition and its reason are committed atomically to immutable audit evidence. Administrators can read the global stream at `GET /v1/admin/publication-decisions`. Active moderators and administrators can inspect a known quarantined submission with `frameshift moderation show`, download its exact archive with @@ -241,7 +241,7 @@ submission with `frameshift moderation show`, download its exact archive with `decide`, and publish an approved submission with `promote`. The server retains the role, lifecycle, and independent-review checks for every operation. -Publisher owners may file one appeal within 30 days of a `request_changes` or `reject` moderation decision using `POST /v1/publishers/{handle}/publication-decisions/{decision_id}/appeal`. Active administrators resolve appeals at `POST /v1/admin/publication-appeals/{appeal_id}/resolution`; an `overturn` approves the exact unchanged submission, while `uphold` preserves its adverse state. The original reviewer cannot resolve the appeal when another active administrator is available. A sole administrator must record a bounded separation exception. Filing and resolution require a caller-supplied UUID `x-request-id`, reject substituted retries, and retain immutable evidence. Owners read their private cases at `GET /v1/publishers/{handle}/publication-appeals`; administrators use `GET /v1/admin/publication-appeals`. +Publisher owners may file one appeal within 30 days of a `request_changes` or `reject` moderation decision with `frameshift publication appeal` and read private cases with `frameshift publication appeals`. Active administrators resolve appeals at `POST /v1/admin/publication-appeals/{appeal_id}/resolution`; an `overturn` approves the exact unchanged submission, while `uphold` preserves its adverse state. The original reviewer cannot resolve the appeal when another active administrator is available. A sole administrator must record a bounded separation exception. Filing and resolution require a caller-supplied UUID `x-request-id`, reject substituted retries, and retain immutable evidence. ### Registry safety controls @@ -438,6 +438,12 @@ frameshift publish --persona [--out ] Build frameshift publication review --draft --server --publisher Review an exact Creator Studio snapshot frameshift publication submit --draft --server --publisher Submit the confirmed signed snapshot frameshift publication status --server --submission-id Inspect an account-backed submission +frameshift publication withdraw --server --submission-id Withdraw an eligible non-public submission + --reason-code +frameshift publication decisions --server --publisher List immutable owner lifecycle evidence +frameshift publication appeal --server --publisher Appeal an adverse moderation decision + --decision-id --statement +frameshift publication appeals --server --publisher List private appeal cases frameshift moderation show --server --submission-id Inspect a quarantined submission frameshift moderation artifact --server --submission-id --out Download its exact archive without overwriting frameshift moderation decide --server --submission-id Record approve, request-changes, or reject diff --git a/crates/frameshift-cli/Cargo.toml b/crates/frameshift-cli/Cargo.toml index 02ae47b..dca0c9d 100644 --- a/crates/frameshift-cli/Cargo.toml +++ b/crates/frameshift-cli/Cargo.toml @@ -27,6 +27,8 @@ frameshift-embed-candle = { path = "../frameshift-embed-candle", optional = true frameshift-vault = { path = "../frameshift-vault" } frameshift-vault-local = { path = "../frameshift-vault-local" } clap = { workspace = true } +# Parses RFC 3339 keyset cursors for publication lifecycle history. +chrono.workspace = true # Names the selected signing key while preserving the client key-store boundary. ed25519-dalek = { workspace = true } # Wraps the vault passphrase/value input so it is never accidentally logged. diff --git a/crates/frameshift-cli/src/cmd/publication.rs b/crates/frameshift-cli/src/cmd/publication.rs index 9be74e0..e975d38 100644 --- a/crates/frameshift-cli/src/cmd/publication.rs +++ b/crates/frameshift-cli/src/cmd/publication.rs @@ -1,16 +1,21 @@ //! Human-reviewed Creator Studio publication commands. //! -//! Review prepares and displays one exact artifact without persisting approval. -//! Submit requires the displayed artifact, publisher, and key identifiers before -//! it records local approval and crosses the authenticated quarantine boundary. +//! Review and submission bind one exact artifact before quarantine admission. +//! Owner lifecycle commands then expose withdrawal, immutable decision evidence, +//! and appeals without weakening the authenticated server boundary. +use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; use ed25519_dalek::SigningKey; -use frameshift_catalog::{MembershipState, ObjectHash, PublisherRole}; +use frameshift_catalog::{ + MembershipState, ObjectHash, PublicationAppealCursor, PublicationLifecycleCursor, PublisherRole, +}; use frameshift_client::account::{self, AccountView}; use frameshift_client::identity::public_key_b64; use frameshift_client::publication::{ - create_publication_intent, get_publication_submission, prepare_publication, submit_publication, + create_publication_intent, file_publication_appeal, get_publication_submission, + list_publication_appeals, list_publication_decisions, prepare_publication, submit_publication, + withdraw_publication_submission, }; use frameshift_client::{ Client, EnrolledPublisherKey, EnrolledPublisherKeyState, PublicationReviewBinding, @@ -81,6 +86,81 @@ pub enum PublicationCommand { #[arg(long)] submission_id: Uuid, }, + /// Withdraw one eligible account-owned submission before publication. + Withdraw { + /// Registry base URL. + #[arg(long)] + server: String, + /// Stable submission UUID returned by the submission command. + #[arg(long)] + submission_id: Uuid, + /// Stable 1-64 character private reason code. + #[arg(long)] + reason_code: String, + /// Stable decision UUID to reuse after an ambiguous network failure. + #[arg(long)] + decision_id: Option, + /// Stable request UUID to reuse after an ambiguous network failure. + #[arg(long)] + request_id: Option, + }, + /// List immutable lifecycle decisions for one owned publisher profile. + Decisions { + /// Registry base URL. + #[arg(long)] + server: String, + /// Account-owned publisher handle. + #[arg(long)] + publisher: String, + /// RFC 3339 timestamp from the final record of the preceding page. + #[arg(long, requires = "before_id")] + before_created_at: Option>, + /// UUID from the final record of the preceding page. + #[arg(long, requires = "before_created_at")] + before_id: Option, + /// Number of newest-first records to return. + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, + /// File one appeal against an adverse moderation decision. + Appeal { + /// Registry base URL. + #[arg(long)] + server: String, + /// Account-owned publisher handle. + #[arg(long)] + publisher: String, + /// Adverse moderation decision UUID. + #[arg(long)] + decision_id: Uuid, + /// Private appeal statement of at most 4000 characters. + #[arg(long)] + statement: String, + /// Stable appeal UUID to reuse after an ambiguous network failure. + #[arg(long)] + appeal_id: Option, + /// Stable request UUID to reuse after an ambiguous network failure. + #[arg(long)] + request_id: Option, + }, + /// List private appeal cases for one owned publisher profile. + Appeals { + /// Registry base URL. + #[arg(long)] + server: String, + /// Account-owned publisher handle. + #[arg(long)] + publisher: String, + /// RFC 3339 timestamp from the final record of the preceding page. + #[arg(long, requires = "before_id")] + before_created_at: Option>, + /// UUID from the final record of the preceding page. + #[arg(long, requires = "before_created_at")] + before_id: Option, + /// Number of newest-first appeal cases to return. + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, } /// One exact review report paired with the key that produced its artifact. @@ -122,6 +202,58 @@ pub fn run_publication(args: PublicationArgs) -> Result<(), CliError> { server, submission_id, } => status(&server, submission_id), + PublicationCommand::Withdraw { + server, + submission_id, + reason_code, + decision_id, + request_id, + } => withdraw( + &server, + submission_id, + &reason_code, + decision_id, + request_id, + ), + PublicationCommand::Decisions { + server, + publisher, + before_created_at, + before_id, + limit, + } => decisions( + &server, + &publisher, + publication_lifecycle_cursor(before_created_at, before_id)?, + limit, + ), + PublicationCommand::Appeal { + server, + publisher, + decision_id, + statement, + appeal_id, + request_id, + } => appeal( + &server, + &publisher, + decision_id, + &statement, + appeal_id, + request_id, + ), + PublicationCommand::Appeals { + server, + publisher, + before_created_at, + before_id, + limit, + } => appeals( + &server, + &publisher, + publication_appeal_cursor(before_created_at, before_id)?, + limit, + ), } } @@ -211,6 +343,132 @@ fn status(server: &str, submission_id: Uuid) -> Result<(), CliError> { Ok(()) } +/// Withdraw one eligible non-public submission while preserving retry identifiers. +fn withdraw( + server: &str, + submission_id: Uuid, + reason_code: &str, + decision_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let decision_id = decision_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let decision = withdraw_publication_submission( + server, + &token, + submission_id, + decision_id, + request_id, + reason_code, + ) + .map_err(|error| { + retryable_owner_mutation_error( + "publication withdrawal", + error, + "decision-id", + decision_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&decision)?); + Ok(()) +} + +/// Print one bounded newest-first page of publisher lifecycle decisions. +fn decisions( + server: &str, + publisher: &str, + before: Option, + limit: u32, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let records = list_publication_decisions(server, &token, publisher, before, limit) + .map_err(|error| publication_transport_error("publication decision listing", error))?; + println!("{}", serde_json::to_string_pretty(&records)?); + Ok(()) +} + +/// File one appeal while preserving both retry identifiers in any failure. +fn appeal( + server: &str, + publisher: &str, + decision_id: Uuid, + statement: &str, + appeal_id: Option, + request_id: Option, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let appeal_id = appeal_id.unwrap_or_else(Uuid::new_v4); + let request_id = request_id.unwrap_or_else(Uuid::new_v4); + let record = file_publication_appeal( + server, + &token, + publisher, + decision_id, + appeal_id, + request_id, + statement, + ) + .map_err(|error| { + retryable_owner_mutation_error( + "publication appeal", + error, + "appeal-id", + appeal_id, + request_id, + ) + })?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) +} + +/// Print one bounded newest-first page of publisher appeal cases. +fn appeals( + server: &str, + publisher: &str, + before: Option, + limit: u32, +) -> Result<(), CliError> { + validate_server_url(server)?; + let token = resolve_access_token(server)?; + let records = list_publication_appeals(server, &token, publisher, before, limit) + .map_err(|error| publication_transport_error("publication appeal listing", error))?; + println!("{}", serde_json::to_string_pretty(&records)?); + Ok(()) +} + +/// Construct a lifecycle cursor after Clap has enforced the paired flags. +fn publication_lifecycle_cursor( + created_at: Option>, + id: Option, +) -> Result, CliError> { + match (created_at, id) { + (None, None) => Ok(None), + (Some(created_at), Some(id)) => Ok(Some(PublicationLifecycleCursor { created_at, id })), + _ => Err(CliError::Publish( + "--before-created-at and --before-id must be supplied together".to_string(), + )), + } +} + +/// Construct an appeal cursor after Clap has enforced the paired flags. +fn publication_appeal_cursor( + created_at: Option>, + id: Option, +) -> Result, CliError> { + match (created_at, id) { + (None, None) => Ok(None), + (Some(created_at), Some(id)) => Ok(Some(PublicationAppealCursor { created_at, id })), + _ => Err(CliError::Publish( + "--before-created-at and --before-id must be supplied together".to_string(), + )), + } +} + /// Open the canonical Creator Studio draft store below the managed data root. fn open_studio(client: &Client) -> Result { Studio::open(client.data_root().join("studio").join("drafts")).map_err(publication_draft_error) @@ -363,6 +621,24 @@ fn retryable_transport_error( )) } +/// Preserve owner-mutation idempotency identifiers in an actionable retry command. +fn retryable_owner_mutation_error( + stage: &str, + error: frameshift_client::ClientError, + operation_id_flag: &str, + operation_id: Uuid, + request_id: Uuid, +) -> CliError { + CliError::Publish(format!( + "{stage} failed: {error}; retry with --{operation_id_flag} {operation_id} --request-id {request_id}" + )) +} + +/// Wrap one read-only publication transport failure with its failed operation. +fn publication_transport_error(stage: &str, error: frameshift_client::ClientError) -> CliError { + CliError::Publish(format!("{stage} failed: {error}")) +} + /// Map Creator Studio failures into the publication command's public error surface. fn publication_draft_error(error: frameshift_studio::StudioError) -> CliError { CliError::Publish(format!("publication draft error: {error}")) @@ -499,4 +775,31 @@ mod tests { assert!(require_manifest_publisher("alice", "alice").is_ok()); assert!(require_manifest_publisher("mallory", "alice").is_err()); } + + /// Owner mutation failures retain both UUID flags required for an exact retry. + #[test] + fn owner_mutation_error_preserves_retry_ids() { + let operation_id = Uuid::from_u128(20); + let request_id = Uuid::from_u128(21); + let error = retryable_owner_mutation_error( + "publication appeal", + frameshift_client::ClientError::RegistryHttp { + url: "https://registry.example".to_string(), + detail: "connection closed".to_string(), + }, + "appeal-id", + operation_id, + request_id, + ); + let message = error.to_string(); + assert!(message.contains(&format!("--appeal-id {operation_id}"))); + assert!(message.contains(&format!("--request-id {request_id}"))); + } + + /// Programmatic callers cannot construct a partial keyset cursor. + #[test] + fn cursor_construction_rejects_partial_components() { + assert!(publication_lifecycle_cursor(None, Some(Uuid::from_u128(1))).is_err()); + assert!(publication_appeal_cursor(None, Some(Uuid::from_u128(1))).is_err()); + } } diff --git a/crates/frameshift-cli/src/main.rs b/crates/frameshift-cli/src/main.rs index bcd6516..15b09be 100644 --- a/crates/frameshift-cli/src/main.rs +++ b/crates/frameshift-cli/src/main.rs @@ -124,7 +124,7 @@ enum Command { /// Publish a persona pack to a directory or registry. Publish(PublishArgs), - /// Review and submit Creator Studio drafts through moderated publication. + /// Manage Creator Studio submissions, owner lifecycle decisions, and appeals. Publication(PublicationArgs), /// Inspect, decide, and promote quarantined publication submissions. @@ -637,6 +637,67 @@ mod publication_cli_tests { ]); assert!(result.is_err()); } + + /// Withdrawal parsing accepts explicit retry identifiers for safe replay. + #[test] + fn parses_publication_withdrawal_retry() { + let cli = Cli::try_parse_from([ + "frameshift", + "publication", + "withdraw", + "--server", + "https://registry.example", + "--submission-id", + "00000000-0000-0000-0000-000000000001", + "--reason-code", + "author_request", + "--decision-id", + "00000000-0000-0000-0000-000000000002", + "--request-id", + "00000000-0000-0000-0000-000000000003", + ]) + .expect("withdrawal arguments should parse"); + assert!(matches!( + cli.command, + Command::Publication(PublicationArgs { + command: PublicationCommand::Withdraw { .. } + }) + )); + } + + /// Decision pagination requires both keyset cursor components. + #[test] + fn publication_decisions_rejects_partial_cursor() { + let result = Cli::try_parse_from([ + "frameshift", + "publication", + "decisions", + "--server", + "https://registry.example", + "--publisher", + "alice", + "--before-id", + "00000000-0000-0000-0000-000000000001", + ]); + assert!(result.is_err()); + } + + /// Appeal history parsing enforces the server's page-size bound locally. + #[test] + fn publication_appeals_rejects_oversized_page() { + let result = Cli::try_parse_from([ + "frameshift", + "publication", + "appeals", + "--server", + "https://registry.example", + "--publisher", + "alice", + "--limit", + "101", + ]); + assert!(result.is_err()); + } } /// CLI parsing regressions for the role-gated moderation surface. diff --git a/crates/frameshift-client/src/error.rs b/crates/frameshift-client/src/error.rs index c17f5ca..892ea06 100644 --- a/crates/frameshift-client/src/error.rs +++ b/crates/frameshift-client/src/error.rs @@ -286,6 +286,13 @@ pub enum ClientError { #[error("publication review binding does not match the prepared artifact")] PublicationReviewBindingMismatch, + /// A publication lifecycle argument violates a stable client-visible server bound. + #[error("invalid publication lifecycle input: {detail}")] + InvalidPublicationLifecycleInput { + /// Actionable description of the rejected argument and its accepted shape. + detail: String, + }, + #[error("author_pubkey is not a supported ed25519 public key encoding: {0}")] InvalidAuthorPublicKey(String), diff --git a/crates/frameshift-client/src/publication.rs b/crates/frameshift-client/src/publication.rs index 946447d..dc4ab57 100644 --- a/crates/frameshift-client/src/publication.rs +++ b/crates/frameshift-client/src/publication.rs @@ -9,7 +9,11 @@ use std::fs; use ed25519_dalek::{Signer as _, SigningKey}; use flate2::{Compression, GzBuilder}; -use frameshift_catalog::{PublicationIntentRecord, PublicationSubmissionRecord}; +use frameshift_catalog::{ + PublicationAppealCaseRecord, PublicationAppealCursor, PublicationAppealRecord, + PublicationIntentRecord, PublicationLifecycleCursor, PublicationLifecycleDecisionRecord, + PublicationSubmissionRecord, +}; use frameshift_pack::{ObjectHash, Pack}; use frameshift_studio::DraftSnapshot; pub use frameshift_studio::{PublicationBinding, PublicationReviewBinding}; @@ -46,6 +50,24 @@ struct CreatePublicationIntentRequest { scan_schema_version: u32, } +/// JSON body accepted by one owner submission withdrawal. +#[derive(Serialize)] +struct WithdrawPublicationSubmissionRequest<'a> { + /// Stable lifecycle decision identifier and primary idempotency key. + id: Uuid, + /// Stable bounded private reason code. + reason_code: &'a str, +} + +/// JSON body accepted by one publisher-owner appeal filing. +#[derive(Serialize)] +struct FilePublicationAppealRequest<'a> { + /// Stable appeal identifier and primary idempotency key. + id: Uuid, + /// Bounded private statement explaining the appeal. + statement: &'a str, +} + /// Read-only accessors for a prepared publication. impl PreparedPublication { /// Return the public exact-artifact binding for final review and intent creation. @@ -198,6 +220,181 @@ pub fn get_publication_submission( crate::publisher::send_and_decode(request.call(), url.as_str()) } +/// Withdraw one eligible account-owned non-public submission idempotently. +pub fn withdraw_publication_submission( + server_url: &str, + access_token: &SecretString, + submission_id: Uuid, + decision_id: Uuid, + request_id: Uuid, + reason_code: &str, +) -> Result { + validate_lifecycle_reason_code(reason_code)?; + let id = submission_id.to_string(); + let url = crate::publisher::registry_endpoint_url( + server_url, + &["v1", "publication-submissions", &id, "withdraw"], + )?; + let body = WithdrawPublicationSubmissionRequest { + id: decision_id, + reason_code, + }; + post_publication_json(&url, access_token, request_id, &body) +} + +/// List immutable lifecycle decisions scoped to one owned publisher profile. +pub fn list_publication_decisions( + server_url: &str, + access_token: &SecretString, + publisher_handle: &str, + before: Option, + limit: u32, +) -> Result, ClientError> { + let mut url = crate::publisher::registry_endpoint_url( + server_url, + &[ + "v1", + "publishers", + publisher_handle, + "publication-decisions", + ], + )?; + append_publication_page_query( + &mut url, + before.map(|cursor| (cursor.created_at, cursor.id)), + limit, + )?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent().get(url.as_str()), + access_token, + ); + crate::publisher::send_and_decode(request.call(), url.as_str()) +} + +/// File one idempotent appeal against an adverse publisher moderation decision. +pub fn file_publication_appeal( + server_url: &str, + access_token: &SecretString, + publisher_handle: &str, + decision_id: Uuid, + appeal_id: Uuid, + request_id: Uuid, + statement: &str, +) -> Result { + validate_appeal_statement(statement)?; + let decision = decision_id.to_string(); + let url = crate::publisher::registry_endpoint_url( + server_url, + &[ + "v1", + "publishers", + publisher_handle, + "publication-decisions", + &decision, + "appeal", + ], + )?; + let body = FilePublicationAppealRequest { + id: appeal_id, + statement, + }; + post_publication_json(&url, access_token, request_id, &body) +} + +/// List private appeal cases scoped to one owned publisher profile. +pub fn list_publication_appeals( + server_url: &str, + access_token: &SecretString, + publisher_handle: &str, + before: Option, + limit: u32, +) -> Result, ClientError> { + let mut url = crate::publisher::registry_endpoint_url( + server_url, + &["v1", "publishers", publisher_handle, "publication-appeals"], + )?; + append_publication_page_query( + &mut url, + before.map(|cursor| (cursor.created_at, cursor.id)), + limit, + )?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent().get(url.as_str()), + access_token, + ); + crate::publisher::send_and_decode(request.call(), url.as_str()) +} + +/// Send one bearer-authenticated idempotent publication JSON mutation. +fn post_publication_json( + url: &url::Url, + access_token: &SecretString, + request_id: Uuid, + body: &T, +) -> Result { + let bytes = + serde_json::to_vec(body).map_err(|error| ClientError::JsonSerialize(error.to_string()))?; + let request = crate::publisher::with_bearer( + crate::registry::http_agent() + .post(url.as_str()) + .set("Content-Type", "application/json") + .set("x-request-id", &request_id.to_string()), + access_token, + ); + crate::publisher::send_and_decode(request.send_bytes(&bytes), url.as_str()) +} + +/// Append a validated newest-first publication page query to one endpoint. +fn append_publication_page_query( + url: &mut url::Url, + before: Option<(chrono::DateTime, Uuid)>, + limit: u32, +) -> Result<(), ClientError> { + if !(1..=100).contains(&limit) { + return Err(ClientError::InvalidPublicationLifecycleInput { + detail: "--limit must be between 1 and 100".to_string(), + }); + } + let mut query = url.query_pairs_mut(); + if let Some((created_at, id)) = before { + query.append_pair("before_created_at", &created_at.to_rfc3339()); + query.append_pair("before_id", &id.to_string()); + } + query.append_pair("limit", &limit.to_string()); + drop(query); + Ok(()) +} + +/// Validate the server's stable lifecycle reason-code grammar before transport. +fn validate_lifecycle_reason_code(reason_code: &str) -> Result<(), ClientError> { + let reason = reason_code.as_bytes(); + let valid_head = !reason.is_empty() + && reason.len() <= 64 + && reason + .first() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + let valid_tail = reason.iter().skip(1).all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-') + }); + if valid_head && valid_tail { + return Ok(()); + } + Err(ClientError::InvalidPublicationLifecycleInput { + detail: "--reason-code must use 1-64 lowercase ASCII letters, digits, '.', '_', or '-'" + .to_string(), + }) +} + +/// Validate the server's stable private appeal statement bound before transport. +fn validate_appeal_statement(statement: &str) -> Result<(), ClientError> { + if !statement.trim().is_empty() && statement.chars().count() <= 4_000 { + return Ok(()); + } + Err(ClientError::InvalidPublicationLifecycleInput { + detail: "--statement must be non-blank and at most 4000 characters".to_string(), + }) +} + /// Build a reproducible gzip-tar from sorted snapshot files plus its signature. fn deterministic_archive( snapshot: &DraftSnapshot, @@ -274,6 +471,12 @@ fn append_text_part(body: &mut Vec, boundary: &str, name: &str, value: &str) #[cfg(test)] /// Tests for deterministic preparation and exact public request bindings. mod tests { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + use std::thread; + + use chrono::{TimeZone as _, Utc}; + use frameshift_catalog::{PublicationAppealCursor, PublicationLifecycleCursor}; use frameshift_studio::Studio; use super::*; @@ -305,6 +508,75 @@ mod tests { .unwrap() } + /// Read one complete HTTP request from the blocking client under test. + fn read_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).expect("read request"); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + let Some(headers_end) = request.windows(4).position(|window| window == b"\r\n\r\n") + else { + continue; + }; + let headers_end = headers_end + 4; + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + }) + .unwrap_or(0); + if request.len() >= headers_end + content_length { + break; + } + } + String::from_utf8(request).expect("request UTF-8") + } + + /// Serve one fixed JSON response and return the captured request. + fn serve_json_response(body: Vec) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let request = read_request(&mut stream); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(headers.as_bytes()).expect("write headers"); + stream.write_all(&body).expect("write response"); + request + }); + (format!("http://{address}/registry"), handle) + } + + /// Return one complete owner lifecycle decision wire fixture. + fn lifecycle_decision_json(decision_id: Uuid, request_id: Uuid) -> serde_json::Value { + serde_json::json!({ + "id": decision_id, + "action": "withdraw_submission", + "actor_account_id": Uuid::from_u128(2), + "publisher_id": Uuid::from_u128(3), + "submission_id": Uuid::from_u128(1), + "pack_name": null, + "version": null, + "from_state": "quarantined", + "to_state": "withdrawn", + "reason_code": "author_request", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + }) + } + /// Repeated preparation produces byte-identical archives and valid pack signatures. #[test] fn preparation_is_reproducible_and_signed() { @@ -444,4 +716,168 @@ mod tests { assert!(text.contains("name=\"archive\"")); assert!(text.contains(&format!("--{boundary}--"))); } + + /// Withdrawal transport binds the submission path and both retry identifiers. + #[test] + fn sends_idempotent_owner_withdrawal() { + let decision_id = Uuid::from_u128(6); + let request_id = Uuid::from_u128(7); + let response = serde_json::to_vec(&lifecycle_decision_json(decision_id, request_id)) + .expect("serialize decision fixture"); + let (server, handle) = serve_json_response(response); + let token = SecretString::new("owner-token".to_string()); + + let decision = withdraw_publication_submission( + &server, + &token, + Uuid::from_u128(1), + decision_id, + request_id, + "author_request", + ) + .expect("withdrawal response"); + + assert_eq!(decision.id, decision_id); + let request = handle.join().expect("test server thread"); + let lowercase_request = request.to_ascii_lowercase(); + assert!(request.starts_with( + "POST /registry/v1/publication-submissions/00000000-0000-0000-0000-000000000001/withdraw HTTP/1.1\r\n" + )); + assert!(lowercase_request.contains(&format!("\r\nx-request-id: {request_id}\r\n"))); + assert!(request.contains(&format!("\"id\":\"{decision_id}\""))); + assert!(request.contains("\"reason_code\":\"author_request\"")); + assert!(request.contains("\r\nAuthorization: Bearer owner-token\r\n")); + } + + /// Lifecycle decision reads encode both keyset cursor components and the page bound. + #[test] + fn lists_owner_decisions_with_keyset_cursor() { + let response = serde_json::to_vec(&vec![lifecycle_decision_json( + Uuid::from_u128(6), + Uuid::from_u128(7), + )]) + .expect("serialize decision page"); + let (server, handle) = serve_json_response(response); + let token = SecretString::new("owner-token".to_string()); + let cursor = PublicationLifecycleCursor { + created_at: Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(), + id: Uuid::from_u128(9), + }; + + let decisions = + list_publication_decisions(&server, &token, "alice/admin", Some(cursor), 25) + .expect("decision page"); + + assert_eq!(decisions.len(), 1); + let request = handle.join().expect("test server thread"); + assert!( + request.starts_with("GET /registry/v1/publishers/alice%2Fadmin/publication-decisions?") + ); + assert!(request.contains("before_created_at=2026-01-02T03%3A04%3A05%2B00%3A00")); + assert!(request.contains("before_id=00000000-0000-0000-0000-000000000009")); + assert!(request.contains("limit=25")); + } + + /// Appeal filing binds the publisher path, adverse decision, and stable retry IDs. + #[test] + fn files_idempotent_owner_appeal() { + let appeal_id = Uuid::from_u128(10); + let decision_id = Uuid::from_u128(11); + let request_id = Uuid::from_u128(12); + let response = serde_json::to_vec(&serde_json::json!({ + "id": appeal_id, + "decision_id": decision_id, + "submission_id": Uuid::from_u128(1), + "publisher_id": Uuid::from_u128(3), + "actor_account_id": Uuid::from_u128(2), + "statement": "The unchanged artifact meets policy.", + "request_id": request_id, + "created_at": "2026-01-01T00:00:00Z" + })) + .expect("serialize appeal fixture"); + let (server, handle) = serve_json_response(response); + let token = SecretString::new("owner-token".to_string()); + + let appeal = file_publication_appeal( + &server, + &token, + "alice", + decision_id, + appeal_id, + request_id, + "The unchanged artifact meets policy.", + ) + .expect("appeal response"); + + assert_eq!(appeal.id, appeal_id); + let request = handle.join().expect("test server thread"); + let lowercase_request = request.to_ascii_lowercase(); + assert!(request.starts_with(&format!( + "POST /registry/v1/publishers/alice/publication-decisions/{decision_id}/appeal HTTP/1.1\r\n" + ))); + assert!(lowercase_request.contains(&format!("\r\nx-request-id: {request_id}\r\n"))); + assert!(request.contains(&format!("\"id\":\"{appeal_id}\""))); + assert!(request.contains("\"statement\":\"The unchanged artifact meets policy.\"")); + } + + /// Appeal case reads preserve the publisher path and bounded page query. + #[test] + fn lists_owner_appeals_with_keyset_cursor() { + let response = + serde_json::to_vec(&serde_json::json!([])).expect("serialize empty appeal page"); + let (server, handle) = serve_json_response(response); + let token = SecretString::new("owner-token".to_string()); + let cursor = PublicationAppealCursor { + created_at: Utc.with_ymd_and_hms(2026, 2, 3, 4, 5, 6).unwrap(), + id: Uuid::from_u128(13), + }; + + let appeals = list_publication_appeals(&server, &token, "alice", Some(cursor), 100) + .expect("appeal page"); + + assert!(appeals.is_empty()); + let request = handle.join().expect("test server thread"); + assert!(request.starts_with("GET /registry/v1/publishers/alice/publication-appeals?")); + assert!(request.contains("before_created_at=2026-02-03T04%3A05%3A06%2B00%3A00")); + assert!(request.contains("before_id=00000000-0000-0000-0000-00000000000d")); + assert!(request.contains("limit=100")); + } + + /// Invalid lifecycle mutation fields fail before opening an HTTP connection. + #[test] + fn rejects_invalid_owner_mutation_fields_locally() { + let token = SecretString::new("owner-token".to_string()); + let reason_error = withdraw_publication_submission( + "https://registry.example", + &token, + Uuid::from_u128(1), + Uuid::from_u128(2), + Uuid::from_u128(3), + "Invalid Reason", + ) + .expect_err("invalid reason code should fail"); + assert!(reason_error.to_string().contains("--reason-code")); + + let statement_error = file_publication_appeal( + "https://registry.example", + &token, + "alice", + Uuid::from_u128(4), + Uuid::from_u128(5), + Uuid::from_u128(6), + " ", + ) + .expect_err("blank statement should fail"); + assert!(statement_error.to_string().contains("--statement")); + } + + /// Invalid page bounds fail locally with the accepted range. + #[test] + fn rejects_invalid_owner_page_limit_locally() { + let token = SecretString::new("owner-token".to_string()); + let error = + list_publication_decisions("https://registry.example", &token, "alice", None, 101) + .expect_err("oversized page should fail"); + assert!(error.to_string().contains("between 1 and 100")); + } } diff --git a/docs/wiki/CLI-Reference.md b/docs/wiki/CLI-Reference.md index 73f38c8..61ffbd7 100644 --- a/docs/wiki/CLI-Reference.md +++ b/docs/wiki/CLI-Reference.md @@ -35,7 +35,7 @@ The binary is named `frameshift`. | `grow` | Append, inspect, or summarize local growth | | `verify` | Run persona conformance checks | | `publish` | Package a persona locally or publish it to a registry | -| `publication` | Review, submit, and inspect account-backed publications | +| `publication` | Review, submit, inspect, withdraw, and appeal account-backed publications | | `moderation` | Inspect, decide, and promote quarantined submissions | | `register` | Register this machine's author key under a registry handle | | `keys` | Manage local and account-enrolled publisher keys | @@ -206,9 +206,23 @@ under `--handle `. Use `review` to bind human confirmation to an exact Creator Studio snapshot, `submit` to create the authenticated publication intent and upload that signed -snapshot, and `status` to inspect the resulting submission. The submit command -requires separate confirmations for the archive hash, publisher, signer key, -and submission intent. +snapshot, and `status` to inspect the resulting submission. Use `withdraw` for +an eligible non-public submission, `decisions` for immutable publisher-scoped +lifecycle evidence, `appeal` for one adverse moderation decision, and `appeals` +for private appeal history. The submit command requires separate confirmations +for the archive hash, publisher, signer key, and submission intent. + +```bash +frameshift publication withdraw --server https://registry.example --submission-id --reason-code author_request +frameshift publication decisions --server https://registry.example --publisher alice +frameshift publication appeal --server https://registry.example --publisher alice --decision-id --statement "The unchanged artifact meets policy." +frameshift publication appeals --server https://registry.example --publisher alice +``` + +Withdrawal and appeal failures print the generated operation and request UUID +flags so an ambiguous request can be retried exactly. Decision and appeal +history use newest-first keyset pagination with a bounded `--limit`; supply +`--before-created-at` and `--before-id` together to request the next page. ### `frameshift moderation ` diff --git a/docs/wiki/Publishing-and-Moderation.md b/docs/wiki/Publishing-and-Moderation.md index 8170e2c..24d54ab 100644 --- a/docs/wiki/Publishing-and-Moderation.md +++ b/docs/wiki/Publishing-and-Moderation.md @@ -21,9 +21,10 @@ token and an Ed25519-signed request. A local manifest without a valid signature cannot enter the account-backed publication pipeline, and the selected signer must match the manifest's `author_pubkey`. -The `frameshift publication review`, `submit`, and `status` commands expose the -account-backed workflow through the CLI. Final submission is not exposed -through the MCP server. +The `frameshift publication review`, `submit`, `status`, `withdraw`, +`decisions`, `appeal`, and `appeals` commands expose the account-backed +workflow through the CLI. Final submission is not exposed through the MCP +server. ## Quarantine and review @@ -70,6 +71,15 @@ Accepted lifecycle transitions and their reasons are recorded as immutable decision evidence. Publisher owners can read the decision stream scoped to their publisher profile. +```bash +frameshift publication withdraw --server https://registry.example --submission-id --reason-code author_request +frameshift publication decisions --server https://registry.example --publisher alice +``` + +Withdrawal failures include the decision and request UUID flags required for +an exact retry. Decision history is newest-first and accepts a bounded page +size plus paired timestamp and UUID cursor flags. + ## Appeals A publisher owner may file one appeal within 30 days of a `request_changes` or @@ -83,6 +93,15 @@ administrator is available. A sole administrator must record a bounded separation exception. Appeal filing and resolution use caller-generated request IDs so retries cannot silently become different actions. +```bash +frameshift publication appeal --server https://registry.example --publisher alice --decision-id --statement "The unchanged artifact meets policy." +frameshift publication appeals --server https://registry.example --publisher alice +``` + +Appeal failures include the appeal and request UUID flags required for an exact +retry. Appeal history uses the same bounded newest-first pagination contract as +decision history. + ## Suspension and tombstones Publisher suspension blocks publisher authority without rewriting historical