diff --git a/Cargo.lock b/Cargo.lock index 78dccfd6..be7e35d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2777,10 +2777,12 @@ dependencies = [ "vibescrobble-index", "vibescrobble-key", "vibescrobble-lexicon", + "vibescrobble-mcp", "vibescrobble-name", "vibescrobble-pds", "vibescrobble-repo", "vibescrobble-serve", + "vibescrobble-stack", ] [[package]] diff --git a/crates/vibescrobble-hook/src/lib.rs b/crates/vibescrobble-hook/src/lib.rs index 85ec3c41..798419c5 100644 --- a/crates/vibescrobble-hook/src/lib.rs +++ b/crates/vibescrobble-hook/src/lib.rs @@ -317,6 +317,45 @@ pub fn stamp_effort( stamp(tool_input, EFFORT_KEY, level.as_str()) } +/// The key a stamped tool call carries the turn's identifier under. +/// +/// # Why a turn needs an identifier of its own +/// +/// A scrobble crosses three processes: this hook provisions and stamps, the +/// scrobble host writes the record, and the personal data server stores it. +/// Each logs what it did, and until now nothing joined the three lines +/// together. A DID identifies the *agent*, which is the wrong grain โ€” an +/// agent that has been working for an hour has hundreds of lines under one +/// DID, and the question a developer actually asks is "what happened to +/// *that* scrobble". +/// +/// # Why it is the harness's identifier rather than a fresh one +/// +/// `tool_use_id` is on the payload already, it is what the harness itself uses +/// to pair a `PreToolUse` with its `PostToolUse`, and it therefore joins these +/// logs to the transcript as well. Minting a second identifier here would +/// produce a number that correlates this project's three processes with each +/// other and with nothing else. +/// +/// It is opaque downstream. Nothing parses it, and nothing depends on its +/// shape: it is a string to group lines by. +pub const TURN_KEY: &str = "_vibescrobbleTurn"; + +/// Rewrites a tool call to carry the identifier of the turn making it. +/// +/// Returns `None` under the same two conditions as [`stamp_identity`]: an +/// input that already carries the key, and an input that is not an object. +/// +/// ``` +/// use vibescrobble_hook::{stamp_turn, TURN_KEY}; +/// let call = serde_json::json!({"text": "reading the MST patch"}); +/// let stamped = stamp_turn(&call, "toolu_01ABC").unwrap(); +/// assert_eq!(stamped[TURN_KEY], "toolu_01ABC"); +/// ``` +pub fn stamp_turn(tool_input: &serde_json::Value, turn: &str) -> Option { + stamp(tool_input, TURN_KEY, turn) +} + /// The key a stamped tool call carries the turn's model identifier under. /// /// A third key rather than a field inside either of the others, for the reason diff --git a/crates/vibescrobble-hookd/README.md b/crates/vibescrobble-hookd/README.md index 534a2b14..acfa29f0 100644 --- a/crates/vibescrobble-hookd/README.md +++ b/crates/vibescrobble-hookd/README.md @@ -8,7 +8,7 @@ hook response as JSON on stdout. |---|---| | `SessionStart` | provision an account for the conversation, remember its DID | | `SubagentStart` | provision an account for the subagent | -| `PreToolUse` on `mcp__vibescrobble__scrobble` | rewrite the call to carry that agent's DID, provisioning one first if the agent has none here | +| `PreToolUse` on `mcp__vibescrobble__scrobble` | rewrite the call to carry that agent's DID and the turn's identifier, provisioning an account first if the agent has none here | | `SubagentStop` | delete the subagent's account | | `SessionEnd` | delete the session's account and any subagent accounts left | @@ -140,6 +140,21 @@ allow it in the same `settings.json`: } ``` +## Following one scrobble across three processes + +The stamp carries the payload's `tool_use_id` as well as the DID. The scrobble +host logs it and forwards it to the personal data server in a `quernstone-turn` +header, and the server puts it on the span every line of that request is +emitted under โ€” so one string joins this hook's line, the host's line, the +server's lines, and the turn in the session transcript. + +The harness's identifier rather than a fresh one, because a new one would +correlate this project's three processes with each other and with nothing else. +It is never written to a record: a repository is public and permanent, and an +operational identifier does not belong in one. + +`vibescrobble-setup debug on` turns the detail up everywhere at once. + ## Configuration A hook is invoked with a command line it does not control, so everything is an diff --git a/crates/vibescrobble-hookd/src/handler.rs b/crates/vibescrobble-hookd/src/handler.rs index 9226a6a7..62671011 100644 --- a/crates/vibescrobble-hookd/src/handler.rs +++ b/crates/vibescrobble-hookd/src/handler.rs @@ -8,7 +8,7 @@ use std::sync::LazyLock; use vibescrobble_hook::{ is_scrobble_tool, lineage, stamp_agent_type, stamp_effort, stamp_identity, stamp_model, - stamp_parent, transcript, AgentKey, CommonInput, HookOutput, HookSpecificOutput, + stamp_parent, stamp_turn, transcript, AgentKey, CommonInput, HookOutput, HookSpecificOutput, PreToolUseInput, HARNESS, SCROBBLE_INSTRUCTIONS, }; @@ -270,6 +270,14 @@ async fn pre_tool_use( stamped = next; } } + // The harness's own pairing identifier for this tool call, passed straight + // through so that this hook's line, the scrobble host's line and the + // server's line can be joined to each other and to the transcript. + if let Some(turn) = input.tool_use_id.as_deref() { + if let Some(next) = stamp_turn(&stamped, turn) { + stamped = next; + } + } HookOutput { hook_specific_output: Some(HookSpecificOutput { diff --git a/crates/vibescrobble-mcp/src/lib.rs b/crates/vibescrobble-mcp/src/lib.rs index d4bdcdef..f0f36e8c 100644 --- a/crates/vibescrobble-mcp/src/lib.rs +++ b/crates/vibescrobble-mcp/src/lib.rs @@ -68,7 +68,7 @@ use serde_json::{json, Map, Value}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use vibescrobble_hook::{ - AGENT_TYPE_KEY, EFFORT_KEY, MODEL_KEY, PARENT_KEY, SCROBBLE_INSTRUCTIONS, STAMP_KEY, + AGENT_TYPE_KEY, EFFORT_KEY, MODEL_KEY, PARENT_KEY, SCROBBLE_INSTRUCTIONS, STAMP_KEY, TURN_KEY, }; use vibescrobble_lexicon::nsid; @@ -126,6 +126,26 @@ pub const AGENT_TYPE_MAX_LENGTH: usize = 256; /// `did:web` over one DNS label and a zone. pub const PARENT_MAX_LENGTH: usize = 512; +/// The longest turn identifier this server will carry. +/// +/// Unlike the four above it, this is not a mirror of a lexicon field, because +/// the turn identifier never reaches a record. It is a harness-internal string +/// useful for joining three logs together, and publishing it into a repository +/// anybody can read would put an operational detail into a permanent public +/// record for the benefit of nobody reading it there. +/// +/// So the bound is a sanity limit rather than an agreement: long enough for +/// any identifier a harness plausibly mints, short enough that a client +/// sending a megabyte cannot make a log line out of it. +pub const TURN_MAX_LENGTH: usize = 256; + +/// The header a scrobble write carries its turn identifier in. +/// +/// A header rather than a field in the request body, for the same reason it is +/// not on the record: the body is the record, and this is about the request +/// rather than about what is being stored. +pub const TURN_HEADER: &str = "quernstone-turn"; + /// How long a client may treat the tool list as fresh, in milliseconds. /// /// Required by the 2026-07-28 revision of the protocol. Deliberately short: @@ -243,6 +263,14 @@ pub struct ScrobbleCall { /// Stamped by the hook from the payload. Absent for the main /// conversation, which has no type. pub agent_type: Option, + /// The harness's identifier for the turn this call was made in. + /// + /// Stamped by the hook from the payload's `tool_use_id`, and opaque here: + /// it is logged and forwarded, never parsed. It is what joins this + /// server's line for a scrobble to the hook's line and to the personal + /// data server's, which is otherwise three logs with no common key + /// finer-grained than the agent. + pub turn: Option, /// The DID of the agent that spawned the acting agent. /// /// Stamped by the hook, which reads it out of a sidecar beside the @@ -439,6 +467,7 @@ pub fn parse_arguments(arguments: Option<&JsonObject>) -> Result) -> Result) -> Option<&str> { + arguments? + .get(TURN_KEY)? + .as_str() + .filter(|value| !value.is_empty() && value.chars().count() <= TURN_MAX_LENGTH) +} + /// Reads one hook-stamped string, dropping anything the record cannot hold. /// /// Shared by every stamp except the DID, because they all follow one rule and @@ -839,12 +881,19 @@ impl PdsClient { did: &str, collection: &str, record: Value, + turn: Option<&str>, ) -> Result { let url = self.create_record_url(); - let response = self + let mut request = self .http .post(&url) - .json(&json!({ "repo": did, "collection": collection, "record": record })) + .json(&json!({ "repo": did, "collection": collection, "record": record })); + // Carried, not stored: the server logs it so its line for this write + // joins the hook's and this server's. See [`TURN_HEADER`]. + if let Some(turn) = turn { + request = request.header(TURN_HEADER, turn); + } + let response = request .send() .await .map_err(|source| PdsError::Unreachable { @@ -1065,13 +1114,22 @@ impl ScrobbleServer { Err(error) => { // A refusal is as much an operator-facing event as a // successful write, so it goes to the same log at WARN. - tracing::warn!(error = %error, "refused a scrobble"); + tracing::warn!( + turn = turn_of(arguments).unwrap_or("-"), + error = %error, + "refused a scrobble" + ); return CallToolResult::error(vec![ContentBlock::text(error.to_string())]); } }; if let Err(error) = self.emoji_memory.check(&call.agent_did, &call.emoji) { - tracing::warn!(did = %call.agent_did, error = %error, "refused a scrobble"); + tracing::warn!( + did = %call.agent_did, + turn = call.turn.as_deref().unwrap_or("-"), + error = %error, + "refused a scrobble" + ); return CallToolResult::error(vec![ContentBlock::text(error.to_string())]); } @@ -1084,7 +1142,12 @@ impl ScrobbleServer { let record = build_record(&call, &created_at); match self .pds - .create_record(&call.agent_did, nsid::SCROBBLE, record) + .create_record( + &call.agent_did, + nsid::SCROBBLE, + record, + call.turn.as_deref(), + ) .await { Ok(rkey) => { @@ -1110,6 +1173,7 @@ impl ScrobbleServer { did = %who, rkey = %rkey, task = call.task.as_deref().unwrap_or("-"), + turn = call.turn.as_deref().unwrap_or("-"), "{}", log_line(&call.emoji, &call.text) ); @@ -1119,7 +1183,12 @@ impl ScrobbleServer { ))]) } Err(error) => { - tracing::warn!(did = %call.agent_did, error = %error, "scrobble write failed"); + tracing::warn!( + did = %call.agent_did, + turn = call.turn.as_deref().unwrap_or("-"), + error = %error, + "scrobble write failed" + ); CallToolResult::error(vec![ContentBlock::text(error.to_string())]) } } diff --git a/crates/vibescrobble-mcp/tests/create_record.rs b/crates/vibescrobble-mcp/tests/create_record.rs index 63fa718d..f383a6c8 100644 --- a/crates/vibescrobble-mcp/tests/create_record.rs +++ b/crates/vibescrobble-mcp/tests/create_record.rs @@ -10,12 +10,16 @@ use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use unicode_segmentation::UnicodeSegmentation; -use vibescrobble_hook::STAMP_KEY; +use vibescrobble_hook::{STAMP_KEY, TURN_KEY}; use vibescrobble_lexicon::nsid; use vibescrobble_mcp::{parse_arguments, PdsClient, PdsError, ScrobbleServer}; -/// What the stub saw: the request line and the body of the one request. -type Seen = Arc>>; +/// What the stub saw: the request line, the whole header block, and the body +/// of the one request. +/// +/// The headers are kept because one of the things being asserted is a header +/// this client sends rather than anything in the body. +type Seen = Arc>>; /// Serves exactly one request with `status` and `body`, then stops. /// @@ -61,7 +65,8 @@ async fn stub(status: u16, body: &'static str) -> (String, Seen) { if received_body.len() >= length { let request_line = headers.lines().next().unwrap_or_default().to_string(); if let Ok(mut slot) = recorder.lock() { - *slot = Some((request_line, received_body.to_string())); + *slot = + Some((request_line, headers.to_string(), received_body.to_string())); } break; } @@ -88,12 +93,17 @@ async fn a_successful_write_posts_to_create_record_and_returns_the_rkey() { let client = PdsClient::new(&base); let record = serde_json::json!({"$type": nsid::SCROBBLE, "text": "hello"}); let rkey = client - .create_record("did:web:localhost%3A3000:agent:a1", nsid::SCROBBLE, record) + .create_record( + "did:web:localhost%3A3000:agent:a1", + nsid::SCROBBLE, + record, + Some("toolu_01ABC"), + ) .await .expect("the stub answers 200"); assert_eq!(rkey, "3lzz9q"); - let (request_line, body) = seen + let (request_line, headers, body) = seen .lock() .expect("stub mutex") .clone() @@ -102,6 +112,12 @@ async fn a_successful_write_posts_to_create_record_and_returns_the_rkey() { request_line, "POST /xrpc/com.atproto.repo.createRecord HTTP/1.1" ); + assert!( + headers + .lines() + .any(|line| line.eq_ignore_ascii_case("quernstone-turn: toolu_01ABC")), + "the turn identifier is sent as a header:\n{headers}" + ); let sent: serde_json::Value = serde_json::from_str(&body).expect("a JSON body"); assert_eq!(sent["repo"], "did:web:localhost%3A3000:agent:a1"); assert_eq!(sent["collection"], nsid::SCROBBLE); @@ -112,7 +128,7 @@ async fn a_successful_write_posts_to_create_record_and_returns_the_rkey() { async fn a_404_is_reported_as_an_unknown_repository() { let (base, _seen) = stub(404, r#"{"error":"RepoNotFound","message":"no such repo"}"#).await; let error = PdsClient::new(&base) - .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({})) + .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({}), None) .await .expect_err("404 is a failure"); assert!(matches!(error, PdsError::UnknownRepo { .. }), "{error}"); @@ -126,7 +142,7 @@ async fn a_400_is_reported_with_what_the_server_objected_to() { ) .await; let error = PdsClient::new(&base) - .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({})) + .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({}), None) .await .expect_err("400 is a failure"); assert!(matches!(error, PdsError::Rejected { .. }), "{error}"); @@ -137,7 +153,7 @@ async fn a_400_is_reported_with_what_the_server_objected_to() { async fn a_200_without_a_uri_is_not_treated_as_success() { let (base, _seen) = stub(200, r#"{"ok":true}"#).await; let error = PdsClient::new(&base) - .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({})) + .create_record("did:web:x", nsid::SCROBBLE, serde_json::json!({}), None) .await .expect_err("a response with no uri is unusable"); assert!(matches!(error, PdsError::MalformedResponse(_)), "{error}"); @@ -187,6 +203,7 @@ async fn a_stamped_call_writes_the_record_the_contract_describes() { "emoji": "๐Ÿงต", "task": "t-7", STAMP_KEY: "did:web:x", + TURN_KEY: "toolu_01XYZ", }); // The parse is exercised here too, so that a change to argument handling // that the unit tests miss still fails a test that writes a real record. @@ -194,11 +211,17 @@ async fn a_stamped_call_writes_the_record_the_contract_describes() { let result = server.scrobble(args.as_object()).await; assert_ne!(result.is_error, Some(true)); - let (_line, body) = seen + let (_line, headers, body) = seen .lock() .expect("stub mutex") .clone() .expect("the stub saw a request"); + assert!( + headers + .lines() + .any(|line| line.eq_ignore_ascii_case("quernstone-turn: toolu_01XYZ")), + "the turn rides the whole path, not only the client:\n{headers}" + ); let sent: serde_json::Value = serde_json::from_str(&body).expect("a JSON body"); assert_eq!(sent["repo"], "did:web:x"); assert_eq!(sent["collection"], nsid::SCROBBLE); @@ -208,6 +231,13 @@ async fn a_stamped_call_writes_the_record_the_contract_describes() { // The emoji rides in its own field, outside the text and outside the // length budget the lexicon puts on the text. assert_eq!(sent["record"]["emoji"], "๐Ÿงต"); + // Carried, never stored. It is a harness-internal identifier, and a + // repository anybody can read is not the place for one. + assert!( + sent["record"].get(TURN_KEY).is_none() && sent["record"].get("turn").is_none(), + "the turn identifier reached the record: {}", + sent["record"] + ); assert_eq!( sent["record"]["emoji"] .as_str() diff --git a/crates/vibescrobble-serve/src/lib.rs b/crates/vibescrobble-serve/src/lib.rs index ee7ff7a7..af5b3e43 100644 --- a/crates/vibescrobble-serve/src/lib.rs +++ b/crates/vibescrobble-serve/src/lib.rs @@ -71,6 +71,7 @@ pub use firehose::{ }; pub use health::{HealthTick, DEFAULT_HEALTH_INTERVAL}; pub use routes::{app, app_with_events, app_with_streams, ATPROTO_METHODS}; +pub use routes::{TURN_HEADER, TURN_HEADER_MAX_LENGTH}; pub use wire::{ record_uri, refuse_skipped_validation, AgentSummary, ApplyWritesRequest, CreateRecordRequest, CreateRecordResponse, DeleteRecordRequest, DescribeRepoQuery, DescribeRepoResponse, DidRequest, diff --git a/crates/vibescrobble-serve/src/routes.rs b/crates/vibescrobble-serve/src/routes.rs index 26bf2dd1..5e7eefde 100644 --- a/crates/vibescrobble-serve/src/routes.rs +++ b/crates/vibescrobble-serve/src/routes.rs @@ -13,7 +13,7 @@ use axum::{Json, Router}; use serde::de::DeserializeOwned; use serde_json::json; use tokio::sync::broadcast; -use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; +use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, MakeSpan, TraceLayer}; use tower_http::LatencyUnit; use tracing::{info, Level}; use vibescrobble_identity::canonical_did; @@ -87,6 +87,61 @@ atproto_methods! { "com.atproto.sync.subscribeRepos" => get(subscribe_repos), } +/// The header a client may name the turn a request belongs to in. +/// +/// A scrobble crosses three processes โ€” the hook that stamps it, the scrobble +/// host that writes it, and this server that stores it โ€” and each logs what it +/// did. Until this existed nothing joined those three lines together at a +/// finer grain than the agent, and an agent that has been working for an hour +/// has hundreds of lines under one DID. +/// +/// It is logged and nothing else. Nothing here parses it, no behaviour depends +/// on it, and it never reaches a record: it is a harness-internal string, and +/// writing it into a repository anybody can read would publish an operational +/// detail permanently for the benefit of nobody reading it there. +/// +/// The producing half is `vibescrobble-mcp`, and the two constants are checked +/// against each other in the facade crate's conformance tests, which is where +/// this project puts an agreement neither crate owns alone. +pub const TURN_HEADER: &str = "quernstone-turn"; + +/// The longest turn identifier this server will put in a log line. +/// +/// A sanity bound rather than an agreement: anything longer is dropped, so a +/// client cannot make an arbitrarily long log line out of a header. +pub const TURN_HEADER_MAX_LENGTH: usize = 256; + +/// The request span, carrying the turn when the client named one. +/// +/// A [`MakeSpan`] rather than a separate middleware because a span is already +/// being made here, and putting the field on it means every line emitted while +/// the request is being served carries it โ€” including the refusals, which are +/// the lines somebody correlating is most often looking for. +#[derive(Debug, Clone, Copy, Default)] +struct TurnSpan; + +impl MakeSpan for TurnSpan { + fn make_span(&mut self, request: &axum::http::Request) -> tracing::Span { + let turn = request + .headers() + .get(TURN_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty() && value.chars().count() <= TURN_HEADER_MAX_LENGTH) + .unwrap_or("-"); + // The same three fields `DefaultMakeSpan` puts on a request span, plus + // the turn. Named "request" rather than "http.request" for the same + // reason the rest of this log is short: it is read by a person + // watching a terminal. + tracing::info_span!( + "request", + method = %request.method(), + uri = %request.uri(), + version = ?request.version(), + turn = %turn, + ) + } +} + /// Builds the router. /// /// The `/events` stream this router serves is connected to a channel nothing @@ -163,7 +218,7 @@ pub fn app_with_streams( // DEBUG for everything else to see them. .layer( TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) + .make_span_with(TurnSpan) .on_request(DefaultOnRequest::new().level(Level::DEBUG)) .on_response( DefaultOnResponse::new() diff --git a/crates/vibescrobble/Cargo.toml b/crates/vibescrobble/Cargo.toml index ab71b2f8..61dabdee 100644 --- a/crates/vibescrobble/Cargo.toml +++ b/crates/vibescrobble/Cargo.toml @@ -32,6 +32,14 @@ vibescrobble-name.workspace = true # needs it here: the Merkle search tree vectors arrive as CAR files, and # reading one is `vibescrobble_repo::car`'s job. vibescrobble-repo.workspace = true +# The scrobble host is a dev-dependency for the same reason again: it is the +# only sender of the turn header, this server is the only reader of it, and +# neither crate owns the agreement alone. +vibescrobble-mcp.workspace = true +# And the stack description, for the same reason once more: it names the log +# filter the development scripts export, and the server is what that filter has +# to mean the same thing to. +vibescrobble-stack.workspace = true # `reqwest` is here so one test can read this server's sync endpoints the way # anything else would: over HTTP, from outside the process. reqwest.workspace = true diff --git a/crates/vibescrobble/tests/conformance/wire.rs b/crates/vibescrobble/tests/conformance/wire.rs index ce0a0279..ff3c778c 100644 --- a/crates/vibescrobble/tests/conformance/wire.rs +++ b/crates/vibescrobble/tests/conformance/wire.rs @@ -821,3 +821,88 @@ async fn a_record_read_back_satisfies_the_lexicon_it_was_written_under() { .validate_record(nsid::AGENT_IDENTITY, &identity["value"]) .expect("the identity record satisfies its own lexicon"); } + +/// The turn header the scrobble host sends is the one this server reads. +/// +/// Two constants rather than one shared crate: the scrobble host deliberately +/// does not depend on the server โ€” that would pull axum into a process whose +/// job is to make HTTP requests โ€” so the agreement lives in two places and is +/// checked here, which is where this project puts a wire detail neither side +/// owns alone. +#[test] +fn the_scrobble_host_and_this_server_agree_on_the_turn_header() { + assert_eq!( + vibescrobble_mcp::TURN_HEADER, + vibescrobble_serve::TURN_HEADER, + "the header the scrobble host sends is not the one this server reads" + ); + assert_eq!( + vibescrobble_mcp::TURN_MAX_LENGTH, + vibescrobble_serve::TURN_HEADER_MAX_LENGTH, + "one side would drop a turn identifier the other side would send" + ); +} + +/// A turn identifier reaches the log and never reaches the repository. +/// +/// The header is an operational detail. Storing it would publish a +/// harness-internal string into a repository anybody can read, permanently, +/// for the benefit of nobody reading it there โ€” so this writes a record with +/// the header set and asserts the record that comes back out carries no trace +/// of it. +#[tokio::test] +async fn the_turn_header_does_not_reach_the_record() { + let (app, did) = server(); + + let request = Request::builder() + .method("POST") + .uri("/xrpc/com.atproto.repo.createRecord") + .header("content-type", "application/json") + .header(vibescrobble::serve::TURN_HEADER, "toolu-01-conformance") + .body(Body::from( + json!({ + "repo": did, + "collection": "zone.quernstone.scrobble", + "record": scrobble("checking that a header stays a header"), + }) + .to_string(), + )) + .expect("request builds"); + let written = call(&app, request).await; + assert_eq!(written.status, StatusCode::OK, "{}", written.body); + + let uri = written.body["uri"].as_str().expect("a uri"); + let rkey = uri.rsplit('/').next().expect("a record key"); + let read = query( + &app, + "com.atproto.repo.getRecord", + &q(&[ + ("repo", did.as_str()), + ("collection", "zone.quernstone.scrobble"), + ("rkey", rkey), + ]), + ) + .await; + assert_eq!(read.status, StatusCode::OK, "{}", read.body); + + let stored = read.body["value"].to_string(); + assert!( + !stored.contains("toolu-01-conformance"), + "the turn identifier was stored: {stored}" + ); +} + +/// The quiet filter the development scripts export is the server's own default. +/// +/// Two constants again, and for the same reason: `vibescrobble-stack` is read +/// by the hook, a process that makes two HTTP calls and exits, and depending +/// on the server crate to learn a string would pull axum into it. If these +/// drift, turning debugging off leaves the stack running under a filter +/// nothing chose. +#[test] +fn the_quiet_log_filter_is_the_servers_own_default() { + assert_eq!( + vibescrobble_stack::QUIET_LOG_FILTER, + vibescrobble::serve::DEFAULT_LOG_FILTER + ); +}