Skip to main content

prikk_object/payload/
tag.rs

1//! Tag payload type.
2//!
3//! **RFC 117 T1 (2026-08-22, owner ruling -- "No project has been created in production in the
4//! world yet. Breaking change is accepted."): `patch_set_digest` is amended in at `schema_version`
5//! 1, in place, required.** A tag is therefore a local pointer plus a global identity:
6//! `target_block_id` names a block this repository can resolve locally; `patch_set_digest` is the
7//! digest of that block's own patch closure (`compute_patch_set_digest_from_block`,
8//! `prikk-store/src/patch_set_digest.rs`) and is what travels between repositories -- **two
9//! repositories holding the same patches produce the same `patch_set_digest`, by construction**,
10//! which is the property a tag's portability depends on. There is no schema 2: every Tag object
11//! written before this change stops decoding (`rfc114_vector_11` moved to record this deliberately;
12//! `empty_tag` did not, since it is generated from a literal empty payload independent of this
13//! struct).
14//!
15//! **RFC 117 T7 (2026-08-22, owner ruling "Take it now", after stage 2 measured resolution at
16//! O(N²)): `patch_count` is field 7, also required.** The number of distinct patch ids in the
17//! closure `patch_set_digest` covers -- not new information, since `patch_set_digest_preimage`
18//! already hashes `DOMAIN ‖ count ‖ sorted ids`; field 7 exposes a fact the digest already commits
19//! to, as a cheap integer `resolve_patch_set_digest` (`prikk-store`) can compare before hashing a
20//! candidate, instead of after. **The count is a hint that prunes, never an authority (design §9.4):
21//! a wrong `patch_count` can only cause the right candidate to be skipped or extra candidates to be
22//! hashed -- it can never produce a wrong resolution, because the digest still has to match.** The
23//! same tried-not-trusted shape D6 §11.6 already established for a different object.
24
25use prikk_error::{PrikkError, Result};
26
27use crate::canonical::WireType;
28use crate::payload::common::PatchSetDigest;
29use crate::{CanonicalEncode, CanonicalWriter, ObjectId};
30
31/// Immutable tag payload.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct TagPayload {
34    /// Tag name.
35    pub name: String,
36    /// Target block ID -- the local pointer half of the tag's identity.
37    pub target_block_id: ObjectId,
38    /// Tag message (optional per FDD-03 §9.8).
39    pub message: Option<String>,
40    /// Canonical no-clock sentinel, matching `RefUpdatePayload.created_at` (DC-34 "RefUpdate time
41    /// policy"): zero in every production write, never an authoritative event-time claim. This
42    /// project has no trusted clock; a real timestamp would require a versioned schema and a
43    /// persistence design.
44    pub created_at: u64,
45    /// Author key ID.
46    pub author_key_id: String,
47    /// The digest of `target_block_id`'s own patch closure (RFC 117 T1) -- the global-identity half
48    /// of the tag, portable across repositories that hold the same patches. Required: a tag without
49    /// one would be the old tag with extra steps.
50    pub patch_set_digest: PatchSetDigest,
51    /// The number of distinct patch ids in the closure `patch_set_digest` covers (RFC 117 T7) --
52    /// already part of what the digest hashes, exposed here so resolution can prune by size before
53    /// hashing a candidate. A hint that narrows, never an authority: the digest still decides.
54    pub patch_count: u64,
55}
56
57impl CanonicalEncode for TagPayload {
58    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
59        writer.field_string(1, &self.name)?;
60        writer.field_object_id(2, &self.target_block_id)?;
61        if let Some(message) = &self.message {
62            writer.field_string(3, message)?;
63        }
64        writer.field_u64(4, self.created_at)?;
65        writer.field_string(5, &self.author_key_id)?;
66        writer.field_bytes(6, &self.patch_set_digest.0)?;
67        writer.field_u64(7, self.patch_count)?;
68        Ok(())
69    }
70}
71
72impl TagPayload {
73    /// Decode a Tag payload from Prikk canonical TLV bytes.
74    pub fn decode_canonical(bytes: &[u8]) -> Result<Self> {
75        let mut cursor = TagCursor::new(bytes);
76        let mut name = None;
77        let mut target_block_id = None;
78        let mut message = None;
79        let mut created_at = None;
80        let mut author_key_id = None;
81        let mut patch_set_digest = None;
82        let mut patch_count = None;
83        while let Some(field) = cursor.next_field()? {
84            match field.tag {
85                1 => name = Some(field.read_string()?),
86                2 => target_block_id = Some(field.read_object_id()?),
87                3 => message = Some(field.read_string()?),
88                4 => created_at = Some(field.read_u64()?),
89                5 => author_key_id = Some(field.read_string()?),
90                6 => patch_set_digest = Some(PatchSetDigest(field.read_array::<32>()?)),
91                7 => patch_count = Some(field.read_u64()?),
92                other => {
93                    return Err(PrikkError::MalformedData(format!(
94                        "unknown Tag field tag: {other}"
95                    )));
96                }
97            }
98        }
99        Ok(Self {
100            name: name.ok_or_else(|| PrikkError::MalformedData("Tag missing name".to_string()))?,
101            target_block_id: target_block_id.ok_or_else(|| {
102                PrikkError::MalformedData("Tag missing target_block_id".to_string())
103            })?,
104            message,
105            created_at: created_at
106                .ok_or_else(|| PrikkError::MalformedData("Tag missing created_at".to_string()))?,
107            author_key_id: author_key_id.ok_or_else(|| {
108                PrikkError::MalformedData("Tag missing author_key_id".to_string())
109            })?,
110            patch_set_digest: patch_set_digest.ok_or_else(|| {
111                PrikkError::MalformedData("Tag missing patch_set_digest".to_string())
112            })?,
113            patch_count: patch_count
114                .ok_or_else(|| PrikkError::MalformedData("Tag missing patch_count".to_string()))?,
115        })
116    }
117}
118
119struct TagCursor<'a> {
120    bytes: &'a [u8],
121    pos: usize,
122    last_tag: Option<u16>,
123}
124
125impl<'a> TagCursor<'a> {
126    const fn new(bytes: &'a [u8]) -> Self {
127        Self {
128            bytes,
129            pos: 0,
130            last_tag: None,
131        }
132    }
133
134    fn next_field(&mut self) -> Result<Option<TagField<'a>>> {
135        if self.pos == self.bytes.len() {
136            return Ok(None);
137        }
138        let tag = u16::from_be_bytes(self.read_array::<2>()?);
139        if tag == 0 {
140            return Err(PrikkError::MalformedData(
141                "field tag 0 is reserved".to_string(),
142            ));
143        }
144        if let Some(last) = self.last_tag {
145            if tag < last {
146                return Err(PrikkError::MalformedData(format!(
147                    "field tag order violation: {tag} after {last}"
148                )));
149            }
150        }
151        self.last_tag = Some(tag);
152        let wire_type = self.read_u8()?;
153        let len = usize::try_from(u64::from_be_bytes(self.read_array::<8>()?)).map_err(|_| {
154            PrikkError::MalformedData("canonical field length does not fit usize".to_string())
155        })?;
156        let value = self.read_exact(len)?;
157        Ok(Some(TagField {
158            tag,
159            wire_type,
160            value,
161        }))
162    }
163
164    fn read_u8(&mut self) -> Result<u8> {
165        let value = self.read_exact(1)?;
166        let Some(byte) = value.first() else {
167            return Err(PrikkError::MalformedData(
168                "unexpected empty byte".to_string(),
169            ));
170        };
171        Ok(*byte)
172    }
173
174    fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
175        let bytes = self.read_exact(N)?;
176        let mut out = [0_u8; N];
177        out.copy_from_slice(bytes);
178        Ok(out)
179    }
180
181    fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
182        let end = self
183            .pos
184            .checked_add(len)
185            .ok_or_else(|| PrikkError::MalformedData("canonical range overflow".to_string()))?;
186        let Some(slice) = self.bytes.get(self.pos..end) else {
187            return Err(PrikkError::MalformedData(
188                "unexpected end of canonical payload".to_string(),
189            ));
190        };
191        self.pos = end;
192        Ok(slice)
193    }
194}
195
196struct TagField<'a> {
197    tag: u16,
198    wire_type: u8,
199    value: &'a [u8],
200}
201
202impl<'a> TagField<'a> {
203    fn read_string(&self) -> Result<String> {
204        self.require_wire(WireType::String)?;
205        String::from_utf8(self.value.to_vec())
206            .map_err(|err| PrikkError::MalformedData(format!("invalid UTF-8 string: {err}")))
207    }
208
209    fn read_u64(&self) -> Result<u64> {
210        self.require_wire(WireType::U64)?;
211        Ok(u64::from_be_bytes(self.read_array::<8>()?))
212    }
213
214    fn read_object_id(&self) -> Result<ObjectId> {
215        self.require_wire(WireType::ObjectId)?;
216        Ok(ObjectId::from_bytes(self.read_array::<32>()?))
217    }
218
219    fn require_wire(&self, expected: WireType) -> Result<()> {
220        if self.wire_type == expected as u8 {
221            return Ok(());
222        }
223        Err(PrikkError::MalformedData(format!(
224            "field {} has wrong wire type: expected {}, got {}",
225            self.tag, expected as u8, self.wire_type
226        )))
227    }
228
229    fn read_array<const N: usize>(&self) -> Result<[u8; N]> {
230        if self.value.len() != N {
231            return Err(PrikkError::MalformedData(format!(
232                "field {} expected {N} bytes, got {}",
233                self.tag,
234                self.value.len()
235            )));
236        }
237        let mut out = [0_u8; N];
238        out.copy_from_slice(self.value);
239        Ok(out)
240    }
241}