macroonz_compiler/plan/anchor.rs
1//! What a plan hangs off, what its account therefore watches, and where that account's content stands in the origin graph.
2//!
3//! Three readings of one value, so a plan's anchor, its triggers, and its origin edges cannot disagree about what the request walked in with.
4//!
5//! # Bounds
6//!
7//! An anchor names ONE address, and that is a spelling rule: what the content stands on is written into the material the identity is derived over, so two plans over different dependency sets reach different identities whatever they anchor at.
8//! A watch naming one address out of several would be a claim about the others, which is why the watch reading covers the whole account and the narrow one-trigger reading refuses instead of electing.
9
10use super::{Account, InvalidationTrigger, PlanError, PlanIssue};
11use crate::identity::{self, Anchoring, Identity, Transcript};
12use crate::kind::Kind;
13use crate::origin::{OriginEdge, OriginRelation};
14
15/// The stable material one captured dependency stands at before a kind-specific content binding names it more narrowly.
16const CAPTURED_CONTENT_NODE: &[u8] = b"content";
17
18/// The origin node one captured dependency stands at.
19fn captured_content_node(
20 commitment: Identity<identity::CapturedDeclaration>,
21) -> Identity<identity::OriginNode> {
22 Identity::derived(Transcript::under_projection(
23 identity::Role::OriginNode,
24 &commitment,
25 CAPTURED_CONTENT_NODE,
26 0,
27 ))
28}
29
30/// The stable material one kind-specific content binding stands at.
31const BOUND_CONTENT_NODE: &[u8] = b"content";
32
33/// The origin node one content binding stands at.
34fn content_node(content: Identity<identity::ProjectionContent>) -> Identity<identity::OriginNode> {
35 Identity::derived(Transcript::under_projection(
36 identity::Role::OriginNode,
37 &content,
38 BOUND_CONTENT_NODE,
39 0,
40 ))
41}
42
43impl<K: Kind> Account<K> {
44 /// What a transcript derived over this account hangs off.
45 ///
46 /// Read off the account's own commitment, so a plan's anchor and a plan's stated cause are one fact rather than two that could disagree.
47 #[must_use]
48 pub fn anchoring(&self) -> Anchoring {
49 Anchoring::UnderProjection(*self.commitment().as_bytes())
50 }
51
52 /// The origin node this account's content stands at.
53 ///
54 /// Derived under the content commitment, so changing its kind, captured declaration, or canonical content moves the node through the binding's stronger identity rather than a second restatement of those facts.
55 #[must_use]
56 pub fn origin_node(&self) -> Identity<identity::OriginNode> {
57 content_node(self.content_commitment())
58 }
59
60 /// The origin edges this account contributes: one per declared dependency, each running from what the content stands on to the content itself.
61 ///
62 /// The relation is [`OriginRelation::ExplicitLink`] because that is what happened — an author supplied this dependency set at the door.
63 /// It is no semantic derivation: nothing here derived meaning from a dependency.
64 ///
65 /// # Ordering
66 ///
67 /// The edges are a FAN-IN and not a walk: every one of them ends at [`Account::origin_node`], so consecutive edges do not join and the set is not a trail.
68 /// A caller draws trails through these edges one at a time rather than handing the set to a trail constructor, which would refuse the discontinuity — correctly.
69 #[must_use]
70 pub fn dependency_edges(&self) -> Vec<OriginEdge> {
71 let to = self.origin_node();
72 self.dependencies()
73 .iter()
74 .map(|dependency| OriginEdge {
75 from: captured_content_node(*dependency),
76 relation: OriginRelation::ExplicitLink,
77 to,
78 })
79 .collect()
80 }
81
82 /// The triggers that watch this account's commitment and every dependency it declares.
83 #[must_use]
84 pub fn cause_triggers(&self) -> Vec<InvalidationTrigger> {
85 let (first, rest) = self.caused_by();
86 core::iter::once(first).chain(rest).collect()
87 }
88
89 /// The single trigger that watches this account's own content.
90 ///
91 /// The deliberately narrow reading, for a caller that can carry one trigger and no more.
92 ///
93 /// # Errors
94 ///
95 /// Returns [`PlanIssue::CauseSetUnwatchable`] where the account also names dependencies: one trigger cannot state that cause set, and a watch covering the commitment alone would read exactly like a complete one.
96 pub fn cause_trigger(&self) -> Result<InvalidationTrigger, PlanError> {
97 let (first, rest) = self.caused_by();
98 if rest.is_empty() {
99 return Ok(first);
100 }
101 Err(PlanError::of(PlanIssue::CauseSetUnwatchable {
102 named: u32::try_from(rest.len().saturating_add(1)).unwrap_or(u32::MAX),
103 watchable: 1,
104 }))
105 }
106
107 /// The content commitment's trigger and one per declared dependency beside it.
108 ///
109 /// The content commitment is already derived under the exact captured declaration, so a second trigger over that declaration would be a weaker restatement rather than another cause.
110 /// This is the one spelling both readings above take, and the one the shared watch derivation opens with.
111 pub(super) fn caused_by(&self) -> (InvalidationTrigger, Vec<InvalidationTrigger>) {
112 (
113 InvalidationTrigger::ProjectionContent {
114 watched: self.content_commitment(),
115 },
116 self.dependencies()
117 .iter()
118 .map(|watched| InvalidationTrigger::CapturedDeclaration { watched: *watched })
119 .collect(),
120 )
121 }
122}