Skip to content

Commit 8bb6aed

Browse files
authored
[ENH](foundation-api): stamp optional author on page writes (#7515)
## Description of changes Add an optional `author` display label to the upsert-page and apply-patch request bodies, forwarding it through to page and revision metadata. The label is trimmed and omitted when blank so empty values never reach the store. - Validate author length (1 to 256 characters) on both routes - Normalize author by trimming and dropping empty labels in upsert - Thread author into build_metadatas, stamping it per chunk - Forward author from apply-patch through to upsert-page ## Test plan CI ## Migration plan Backwards-compatible. ## Observability plan N/A ## Documentation Changes N/A Co-authored-by: AI
1 parent 894dda5 commit 8bb6aed

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

rust/foundation-api/src/routes/apply_patch.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ pub struct ApplyPatchRequest {
4646
/// `/api/upsert-page` but not persisted on the page.
4747
#[validate(length(max = 350, message = "reason must be at most 350 characters"))]
4848
pub reason: Option<String>,
49+
/// Optional display label for revision history. Forwarded to
50+
/// `/api/upsert-page` and stamped onto page and revision metadata.
51+
#[serde(default)]
52+
#[validate(length(min = 1, max = 256, message = "author must be 1 to 256 characters"))]
53+
pub author: Option<String>,
4954
/// Identifier of the trajectory that produced this patch. Forwarded to
5055
/// `/api/upsert-page` and stamped onto page and revision metadata.
5156
#[validate(length(
@@ -150,6 +155,7 @@ pub(crate) async fn run_apply_patch(
150155
source_ids: patched.source_ids,
151156
categories: patched.categories,
152157
reason: request.reason.clone(),
158+
author: request.author.clone(),
153159
last_written_by: request.last_written_by.clone(),
154160
expected_version: request.expected_version.unwrap_or(patched.base_version),
155161
};
@@ -252,6 +258,7 @@ mod tests {
252258
categories: vec!["product".to_string(), "infra".to_string()],
253259
expected_version: None,
254260
reason: None,
261+
author: Some("Claude Sonnet 4.5".to_string()),
255262
last_written_by: "00000000-0000-0000-0000-000000000001".to_string(),
256263
}
257264
}
@@ -315,4 +322,26 @@ mod tests {
315322
missing_writer.last_written_by.clear();
316323
assert!(missing_writer.validate().is_err());
317324
}
325+
326+
#[test]
327+
fn request_deserialize_defaults_missing_author() {
328+
let req: ApplyPatchRequest = serde_json::from_value(serde_json::json!({
329+
"slug": "alpha",
330+
"old_str": "Old",
331+
"new_str": "New",
332+
"last_written_by": "00000000-0000-0000-0000-000000000001"
333+
}))
334+
.unwrap();
335+
336+
assert_eq!(req.author, None);
337+
req.validate().unwrap();
338+
}
339+
340+
#[test]
341+
fn request_validate_rejects_empty_author() {
342+
let mut req = request("Old", "New");
343+
req.author = Some(String::new());
344+
345+
assert!(req.validate().is_err());
346+
}
318347
}

rust/foundation-api/src/routes/upsert_page.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ pub struct UpsertPageRequest {
8383
/// context.
8484
#[validate(length(max = 350, message = "reason must be at most 350 characters"))]
8585
pub reason: Option<String>,
86+
/// Optional display label for revision history. Stamped on every chunk as
87+
/// `author` when non-empty after trimming and copied into revision history.
88+
#[serde(default)]
89+
#[validate(length(min = 1, max = 256, message = "author must be 1 to 256 characters"))]
90+
pub author: Option<String>,
8691
/// Identifier of the trajectory that produced this page write. Stamped on
8792
/// every chunk as `last_written_by` and copied into revision history.
8893
#[validate(length(
@@ -209,6 +214,7 @@ pub(crate) async fn run_upsert_page(
209214
categories: &[String],
210215
) -> Result<UpsertPageResponse, UpsertPageError> {
211216
let slug = request.slug.as_str();
217+
let author = normalize_author(request.author.as_deref());
212218
let wiki_client = server
213219
.foundation_chroma_client
214220
.as_ref()
@@ -366,6 +372,7 @@ pub(crate) async fn run_upsert_page(
366372
i64::from(version),
367373
categories,
368374
&request.source_ids,
375+
author,
369376
&request.last_written_by,
370377
);
371378

@@ -578,6 +585,10 @@ pub(crate) fn normalize_categories(categories: &[String]) -> Vec<String> {
578585
.collect()
579586
}
580587

588+
fn normalize_author(author: Option<&str>) -> Option<&str> {
589+
author.map(str::trim).filter(|author| !author.is_empty())
590+
}
591+
581592
#[cfg(test)]
582593
mod tests {
583594
use super::*;
@@ -603,6 +614,7 @@ mod tests {
603614
source_ids: source_ids.iter().map(|s| s.to_string()).collect(),
604615
categories: categories.iter().map(|s| s.to_string()).collect(),
605616
reason: None,
617+
author: Some("Claude Sonnet 4.5".to_string()),
606618
last_written_by: "00000000-0000-0000-0000-000000000001".to_string(),
607619
expected_version: 0,
608620
}
@@ -731,6 +743,16 @@ mod tests {
731743
assert_eq!(normalize_categories(&[]), Vec::<String>::new());
732744
}
733745

746+
#[test]
747+
fn normalize_author_trims_and_omits_blank_labels() {
748+
assert_eq!(
749+
normalize_author(Some(" Claude Sonnet 4.5 ")),
750+
Some("Claude Sonnet 4.5")
751+
);
752+
assert_eq!(normalize_author(Some(" ")), None);
753+
assert_eq!(normalize_author(None), None);
754+
}
755+
734756
#[test]
735757
fn page_chunk_read_limit_stays_within_chroma_get_limit() {
736758
assert_eq!(PAGE_CHUNK_READ_LIMIT as usize, MAX_TRANSACTION_WRITES);
@@ -953,6 +975,22 @@ mod tests {
953975
.unwrap();
954976
}
955977

978+
#[test]
979+
fn request_deserialize_defaults_missing_author() {
980+
let req: UpsertPageRequest = serde_json::from_value(json!({
981+
"slug": "foo",
982+
"content": "# Title\n\nBody",
983+
"source_ids": ["slack_master:abc"],
984+
"categories": ["z", "a"],
985+
"last_written_by": "00000000-0000-0000-0000-000000000001",
986+
"expected_version": 0
987+
}))
988+
.unwrap();
989+
990+
assert_eq!(req.author, None);
991+
req.validate().unwrap();
992+
}
993+
956994
#[test]
957995
fn request_validate_bounds_reason_length() {
958996
let mut req = request("foo", "body", &[], &[]);
@@ -963,6 +1001,19 @@ mod tests {
9631001
assert!(req.validate().is_err());
9641002
}
9651003

1004+
#[test]
1005+
fn request_validate_bounds_author_length() {
1006+
let mut req = request("foo", "body", &[], &[]);
1007+
req.author = Some("a".repeat(256));
1008+
req.validate().unwrap();
1009+
1010+
req.author = Some("a".repeat(257));
1011+
assert!(req.validate().is_err());
1012+
1013+
req.author = Some(String::new());
1014+
assert!(req.validate().is_err());
1015+
}
1016+
9661017
#[test]
9671018
fn request_validate_requires_last_written_by() {
9681019
let mut req = request("foo", "body", &[], &[]);

rust/foundation-api/src/wiki/page.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub(crate) fn build_metadatas(
3232
version: i64,
3333
categories: &[String],
3434
source_ids: &[String],
35+
author: Option<&str>,
3536
last_written_by: &str,
3637
) -> Vec<Metadata> {
3738
chunks
@@ -73,6 +74,9 @@ pub(crate) fn build_metadatas(
7374
MetadataValue::StringArray(source_ids.to_vec()),
7475
);
7576
}
77+
if let Some(author) = author {
78+
meta.insert("author".to_string(), MetadataValue::Str(author.to_string()));
79+
}
7680
meta
7781
})
7882
.collect()
@@ -147,6 +151,7 @@ mod tests {
147151
3,
148152
&["a".to_string()],
149153
&["slack_master:abc".to_string()],
154+
Some("Claude Sonnet 4.5"),
150155
"00000000-0000-0000-0000-000000000001",
151156
);
152157

@@ -169,6 +174,10 @@ mod tests {
169174
"00000000-0000-0000-0000-000000000001".to_string()
170175
))
171176
);
177+
assert_eq!(
178+
first.get("author"),
179+
Some(&MetadataValue::Str("Claude Sonnet 4.5".to_string()))
180+
);
172181
assert_eq!(
173182
first.get("categories"),
174183
Some(&MetadataValue::StringArray(vec!["a".to_string()]))
@@ -199,11 +208,13 @@ mod tests {
199208
1,
200209
&[],
201210
&[],
211+
None,
202212
"00000000-0000-0000-0000-000000000001",
203213
);
204214

205215
assert!(!metas[0].contains_key("categories"));
206216
assert!(!metas[0].contains_key("source_ids"));
217+
assert!(!metas[0].contains_key("author"));
207218
assert!(metas[0].contains_key(SPARSE_KEY));
208219
}
209220
}

0 commit comments

Comments
 (0)