1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// SPDX-License-Identifier: Apache-2.0
//! Wire types for compiled knowledge pages.
use serde::{Deserialize, Serialize};
pub const PAGE_PENDING_REBUILD_MESSAGE: &str = "evidence updated, prose rebuild pending";
/// A compiled knowledge page — structured, cross-referenced, backed by source memories.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page {
pub id: String,
pub title: String,
pub summary: Option<String>,
pub content: String,
pub entity_id: Option<String>,
#[serde(default, alias = "domain")]
pub space: Option<String>,
/// Kept for dual-write transition; prefer concept_sources join table for new reads.
pub source_memory_ids: Vec<String>,
pub version: i64,
pub status: String,
pub created_at: String,
pub last_compiled: String,
pub last_modified: String,
/// How many source memories were updated since last distillation.
pub sources_updated_count: i64,
/// Why this page is stale: "source_updated" | "source_conflict" | None.
pub stale_reason: Option<String>,
/// Human-facing compile state for stale pages whose evidence has already
/// changed but whose prose is waiting for the next compile lane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_rebuild: Option<String>,
/// True if a human has edited this page's content directly.
pub user_edited: bool,
/// Relevance score from search (0.0-1.0). Only populated by `search_pages`;
/// zero for persisted/non-search contexts.
#[serde(default, skip_serializing_if = "is_zero_f32")]
pub relevance_score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_edited_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_edited_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_delta_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changelog: Option<String>,
/// Scope axis (P3). Distinct from `space` (category column). Set at creation
/// time from `CreateConceptRequest.workspace` or the `X-Origin-Space` header.
/// NULL = no workspace constraint. Enforced only by the scoped-recall page gate
/// on the cross-rerank search path; direct page lookups (search_pages MCP tool,
/// exports) do not filter by workspace.
#[serde(default)]
pub workspace: Option<String>,
/// Routing metadata: which mechanism created this page.
/// One of: "distilled" | "authored" | "research" | "imported".
/// NOT a trust signal (see `review_status` for that).
#[serde(default = "default_creation_kind")]
pub creation_kind: String,
/// Trust boundary: whether this page has been confirmed as accurate.
/// One of: "unconfirmed" | "confirmed".
/// Every newly-created page is born "unconfirmed" regardless of
/// `creation_kind` (spec 3.1/5.2). `default_review_status()` below is a
/// wire/deserialization fallback for rows predating this column (backfilled
/// "confirmed" by migration 62), not the birth value for new pages.
#[serde(default = "default_review_status")]
pub review_status: String,
/// Per-occurrence [N] citation records for this page's body (spec §3).
/// Empty for pages never citation-distilled or citation-backfilled.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub citations: Vec<PageCitation>,
}
fn default_creation_kind() -> String {
"distilled".to_string()
}
fn default_review_status() -> String {
"confirmed".to_string()
}
fn is_zero_f32(v: &f32) -> bool {
*v == 0.0
}
pub fn pending_rebuild_for_stale_reason(stale_reason: Option<&str>) -> Option<String> {
match stale_reason {
Some("source_updated") => Some(PAGE_PENDING_REBUILD_MESSAGE.to_string()),
_ => None,
}
}
/// Typed provenance link for a page (P2 successor to `PageSource`).
/// Backed by the `page_evidence` SQL table (migration 60).
/// Additive — `PageSource` / `page_sources` are NOT removed.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PageEvidence {
pub page_id: String,
pub source_kind: String, // memory | external_url | external_file | authored
pub locator: Option<String>,
pub title: Option<String>,
pub linked_at: i64,
pub link_reason: Option<String>,
}
/// One [N] marker occurrence in a page body, in body order (per-occurrence,
/// NOT per marker number: the same [1] reused in two sentences gets two
/// records with independent statuses). Renderer join rule: the k-th [N]
/// instance scanning the body left-to-right joins the record with
/// occurrence == k.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageCitation {
pub occurrence: u32,
pub marker: u32,
pub source_kind: String,
pub locator: String,
/// Claim-vs-this-source-alone content-token overlap (0..=1) at the
/// recorded `scope` granularity, audit only.
pub score: f64,
/// "verified" | "unverified" — decided claim-level vs the UNION of the
/// claim's cited sources (spec §5).
pub status: String,
/// Granularity that decided the status: "sentence" (the marker's own
/// sentence cleared the floor) or "paragraph" (sentence failed; the
/// enclosing paragraph cleared it — small on-device models cite at
/// paragraph ends, so elaboration sentences would otherwise badge true
/// claims). The recorded scope keeps the weaker guarantee visible to
/// renderers. Unverified records carry "paragraph" (both tiers tried).
#[serde(default = "default_citation_scope")]
pub scope: String,
}
fn default_citation_scope() -> String {
"sentence".to_string()
}
#[cfg(test)]
mod citation_tests {
use super::*;
#[test]
fn old_page_json_deserializes_with_empty_citations() {
let json = r#"{"id":"p1","title":"T","content":"body","source_memory_ids":[],
"version":1,"status":"active","created_at":"x","last_compiled":"x",
"last_modified":"x","sources_updated_count":0,"user_edited":false}"#;
let p: Page = serde_json::from_str(json).unwrap();
assert!(p.citations.is_empty());
}
#[test]
fn citation_roundtrip() {
let c = PageCitation {
occurrence: 2,
marker: 1,
source_kind: "memory".into(),
locator: "mem_a".into(),
score: 0.31,
status: "unverified".into(),
scope: "paragraph".into(),
};
let s = serde_json::to_string(&vec![c]).unwrap();
let back: Vec<PageCitation> = serde_json::from_str(&s).unwrap();
assert_eq!(back[0].occurrence, 2);
assert_eq!(back[0].status, "unverified");
assert_eq!(back[0].scope, "paragraph");
// pre-scope records (old rows) default to "sentence"
let legacy: PageCitation = serde_json::from_str(
r#"{"occurrence":1,"marker":1,"source_kind":"memory","locator":"m","score":1.0,"status":"verified"}"#,
)
.unwrap();
assert_eq!(legacy.scope, "sentence");
}
}