Skip to main content

khive_runtime/secret_gate_finalizer/
entity_admission.rs

1//! Runtime-owned entity candidate admission. Production snapshots stay empty.
2
3use std::sync::Arc;
4
5use khive_storage::Entity;
6use serde_json::Value;
7
8use super::manifest::{digest_to_hex, scoped_digest, ManifestSnapshot, RuntimeFieldScope};
9use super::outcome::{FinalizerOutcome, ManifestFault};
10use crate::{EventAttribution, NamespaceToken, RuntimeError, RuntimeResult};
11
12#[cfg(test)]
13pub(crate) mod fixture;
14
15pub(crate) const EXEMPTION_STAMP: &str = "exempted:content-sha256-manifest-v1";
16
17#[derive(Debug, Clone, Copy)]
18pub(crate) enum EntityEntryPoint {
19    Create,
20    Claim,
21    Update,
22    Bulk,
23    CodeIngest,
24    CodeFindingsIngest,
25}
26
27impl EntityEntryPoint {
28    pub(crate) fn family(self, replacing: bool) -> &'static str {
29        match self {
30            Self::Update => "entity.update",
31            Self::Bulk => "entity.bulk",
32            Self::CodeIngest | Self::CodeFindingsIngest if replacing => "entity.update",
33            _ => "entity.create",
34        }
35    }
36
37    pub(crate) fn verb(self) -> &'static str {
38        match self {
39            Self::Update => "update",
40            Self::CodeIngest => "code.ingest",
41            Self::CodeFindingsIngest => "code.findings_ingest",
42            _ => "create",
43        }
44    }
45}
46
47/// Closed direct-ingest origins; callers cannot supply audit verb strings.
48#[derive(Debug, Clone, Copy)]
49pub enum EntityCandidateOrigin {
50    CodeIngest,
51    CodeFindingsIngest,
52}
53
54/// An immutable admission snapshot captured before direct-ingest preflight.
55/// Clones retain that same snapshot through bounded CAS retries.
56#[derive(Debug, Clone)]
57pub struct EntityCandidateContext {
58    namespace: String,
59    snapshot: Arc<ManifestSnapshot>,
60    entry: EntityEntryPoint,
61    record: String,
62}
63
64/// A checked direct-ingest candidate, without a caller-visible runtime stamp.
65#[derive(Debug)]
66pub struct EntityCandidatePrepared {
67    context: EntityCandidateContext,
68    candidate: Entity,
69}
70
71impl EntityCandidatePrepared {
72    pub fn entity(&self) -> &Entity {
73        &self.candidate
74    }
75
76    pub(crate) fn admit(self, token: &NamespaceToken) -> RuntimeResult<EntityAdmission> {
77        if token.namespace().as_str() != self.context.namespace {
78            return Err(RuntimeError::InvalidInput(
79                "direct-ingest candidate namespace differs from its authorization token".into(),
80            ));
81        }
82        admit(self.context, token, self.candidate)
83    }
84}
85
86impl EntityCandidateContext {
87    pub fn new(namespace: &str, origin: EntityCandidateOrigin) -> Self {
88        Self::capture(
89            namespace,
90            match origin {
91                EntityCandidateOrigin::CodeIngest => EntityEntryPoint::CodeIngest,
92                EntityCandidateOrigin::CodeFindingsIngest => EntityEntryPoint::CodeFindingsIngest,
93            },
94        )
95    }
96
97    fn capture(namespace: &str, entry: EntityEntryPoint) -> Self {
98        #[cfg(test)]
99        let snapshot = fixture::snapshot(namespace);
100        #[cfg(not(test))]
101        let snapshot = ManifestSnapshot::empty();
102        Self {
103            namespace: namespace.into(),
104            snapshot,
105            entry,
106            record: "entity".into(),
107        }
108    }
109
110    pub fn check_name_description(&self, value: &str) -> RuntimeResult<()> {
111        self.check_value(RuntimeFieldScope::NameDescription, value)
112    }
113
114    pub fn check_properties(&self, properties: Option<&Value>) -> RuntimeResult<()> {
115        crate::secret_gate::reject_reserved_secret_gate_property(properties)?;
116        if let Some(value) = properties {
117            let mut values = Vec::new();
118            property_strings(value, &mut values);
119            for value in values {
120                self.check_value(RuntimeFieldScope::JsonProperties, value)?;
121            }
122        }
123        Ok(())
124    }
125
126    pub fn check_tags(&self, tags: &[String]) -> RuntimeResult<()> {
127        for tag in tags {
128            self.check_value(RuntimeFieldScope::Tags, tag)?;
129        }
130        Ok(())
131    }
132
133    /// Consume one clone for the exact final candidate; no database is opened.
134    pub fn prepare(self, candidate: Entity) -> RuntimeResult<EntityCandidatePrepared> {
135        if candidate.namespace != self.namespace {
136            return Err(RuntimeError::InvalidInput(
137                "direct-ingest candidate namespace changed after preflight".into(),
138            ));
139        }
140        let matched = scan_candidate(&self, &candidate)?;
141        // This direct lane writes structural rows, not attachment state. A
142        // committed receipt must not echo an unverified attachment projection.
143        if matched.is_some() && candidate.content_ref.is_some() {
144            return Err(RuntimeError::InvalidInput(
145                "manifest direct-ingest candidates must not carry content_ref; use the attachment-aware entity constructor".into(),
146            ));
147        }
148        Ok(EntityCandidatePrepared {
149            context: self,
150            candidate,
151        })
152    }
153
154    fn check_value(&self, scope: RuntimeFieldScope, value: &str) -> RuntimeResult<()> {
155        if self.snapshot.lookup(scope, value).is_some() {
156            Ok(())
157        } else {
158            crate::secret_gate::check(value)
159        }
160    }
161}
162
163pub(crate) enum EntityAdmission {
164    Legacy(Entity),
165    Exempt(PreparedEntityExemption),
166}
167
168/// The ordinary patch route retains its historical patch-only scanner when
169/// no manifest is active, while a matched candidate uses final-object checks.
170pub(crate) struct OrdinaryEntityUpdateContext(EntityCandidateContext);
171
172impl OrdinaryEntityUpdateContext {
173    pub(crate) fn capture(token: &NamespaceToken) -> Self {
174        Self(EntityCandidateContext::capture(
175            token.namespace().as_str(),
176            EntityEntryPoint::Update,
177        ))
178    }
179
180    pub(crate) fn check_patch(
181        &self,
182        name: Option<&str>,
183        description: Option<&str>,
184        properties: Option<&Value>,
185        tags: Option<&[String]>,
186    ) -> RuntimeResult<()> {
187        crate::secret_gate::reject_reserved_secret_gate_property(properties)?;
188        if let Some(name) = name {
189            crate::secret_gate::locate(self.0.check_name_description(name), "entity", "name")?;
190        }
191        if let Some(description) = description {
192            crate::secret_gate::locate(
193                self.0.check_name_description(description),
194                "entity",
195                "description",
196            )?;
197        }
198        if let Some(properties) = properties {
199            crate::secret_gate::locate(
200                self.0.check_properties(Some(properties)),
201                "entity",
202                "properties",
203            )?;
204        }
205        if let Some(tags) = tags {
206            crate::secret_gate::locate(self.0.check_tags(tags), "entity", "tags")?;
207        }
208        Ok(())
209    }
210
211    pub(crate) fn admit(
212        self,
213        token: &NamespaceToken,
214        candidate: Entity,
215    ) -> RuntimeResult<EntityAdmission> {
216        if self.0.snapshot.is_empty() {
217            crate::secret_gate::reject_reserved_secret_gate_property(
218                candidate.properties.as_ref(),
219            )?;
220            reject_injected_manifest_fault(&candidate.namespace)?;
221            Ok(EntityAdmission::Legacy(candidate))
222        } else {
223            admit(self.0, token, candidate)
224        }
225    }
226}
227
228#[derive(Debug, Clone)]
229pub(crate) struct PreparedEntityExemption {
230    pub(super) entity: Entity,
231    pub(super) snapshot: Arc<ManifestSnapshot>,
232    pub(super) entry: EntityEntryPoint,
233    pub(super) attribution: EventAttribution,
234    pub(super) digest_sha256: String,
235    pub(super) field_scope: &'static str,
236    pub(super) overridden_detector: String,
237}
238
239impl PreparedEntityExemption {
240    /// The finalized, runtime-stamped candidate. Do not pass it back through
241    /// caller-input reservation checks; the opaque plan owns its write binding.
242    pub(crate) fn entity(&self) -> &Entity {
243        &self.entity
244    }
245}
246
247pub(crate) fn prepare_entity_admission(
248    token: &NamespaceToken,
249    entry: EntityEntryPoint,
250    candidate: Entity,
251) -> RuntimeResult<EntityAdmission> {
252    prepare_entity_admission_at(token, entry, candidate, "entity")
253}
254
255pub(crate) fn prepare_entity_admission_at(
256    token: &NamespaceToken,
257    entry: EntityEntryPoint,
258    candidate: Entity,
259    record: &str,
260) -> RuntimeResult<EntityAdmission> {
261    let mut context = EntityCandidateContext::capture(&candidate.namespace, entry);
262    context.record = record.into();
263    admit(context, token, candidate)
264}
265
266fn admit(
267    context: EntityCandidateContext,
268    token: &NamespaceToken,
269    mut candidate: Entity,
270) -> RuntimeResult<EntityAdmission> {
271    reject_injected_manifest_fault(&candidate.namespace)?;
272    let matched = scan_candidate(&context, &candidate)?;
273    let Some((scope, digest_sha256, overridden_detector)) = matched else {
274        return Ok(EntityAdmission::Legacy(candidate));
275    };
276    let properties = candidate
277        .properties
278        .get_or_insert_with(|| serde_json::json!({}));
279    let Some(properties) = properties.as_object_mut() else {
280        return Err(RuntimeError::InvalidInput(
281            "manifest-exempt entity properties must be an object".into(),
282        ));
283    };
284    properties.insert(
285        crate::secret_gate::RESERVED_SECRET_GATE_KEY.into(),
286        EXEMPTION_STAMP.into(),
287    );
288    let record_token = token.with_namespace(
289        crate::Namespace::parse(&candidate.namespace)
290            .map_err(|_| RuntimeError::InvalidInput("invalid entity namespace".into()))?,
291    );
292    Ok(EntityAdmission::Exempt(PreparedEntityExemption {
293        entity: candidate,
294        snapshot: context.snapshot,
295        entry: context.entry,
296        attribution: EventAttribution::from_token(&record_token),
297        digest_sha256,
298        field_scope: match scope {
299            RuntimeFieldScope::NameDescription => "name-description",
300            RuntimeFieldScope::JsonProperties => "json-properties",
301            RuntimeFieldScope::Tags => "tags",
302            RuntimeFieldScope::RecordContent => "record-content",
303            RuntimeFieldScope::CodeSource => "code-source",
304        },
305        overridden_detector,
306    }))
307}
308
309fn reject_injected_manifest_fault(namespace: &str) -> RuntimeResult<()> {
310    if let Some(fault) = super::faults::consume_manifest_invalid(namespace) {
311        observe(namespace, FinalizerOutcome::ManifestInvalid(fault));
312        return Err(RuntimeError::InvalidInput(
313            "secret-gate manifest invalid".into(),
314        ));
315    }
316    Ok(())
317}
318
319type CandidateMatch = (RuntimeFieldScope, String, String);
320
321fn scan_candidate(
322    context: &EntityCandidateContext,
323    entity: &Entity,
324) -> RuntimeResult<Option<CandidateMatch>> {
325    crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
326    if context.snapshot.is_empty() {
327        crate::secret_gate::check_at(&entity.name, &context.record, "name")?;
328        if let Some(description) = &entity.description {
329            crate::secret_gate::check_at(description, &context.record, "description")?;
330        }
331        if let Some(properties) = &entity.properties {
332            crate::secret_gate::check_json_at(properties, &context.record, "properties")?;
333        }
334        crate::secret_gate::check_tags_at(&entity.tags, &context.record, "tags")?;
335        return Ok(None);
336    }
337    let mut values = vec![(
338        RuntimeFieldScope::NameDescription,
339        entity.name.as_str(),
340        "name",
341    )];
342    if let Some(description) = &entity.description {
343        values.push((
344            RuntimeFieldScope::NameDescription,
345            description,
346            "description",
347        ));
348    }
349    if let Some(properties) = &entity.properties {
350        let mut leaves = Vec::new();
351        property_strings(properties, &mut leaves);
352        values.extend(
353            leaves
354                .into_iter()
355                .map(|value| (RuntimeFieldScope::JsonProperties, value, "properties")),
356        );
357    }
358    values.extend(
359        entity
360            .tags
361            .iter()
362            .map(|value| (RuntimeFieldScope::Tags, value.as_str(), "tags")),
363    );
364    let mut found: Option<CandidateMatch> = None;
365    for (scope, value, field) in values {
366        if let Some(meta) = context.snapshot.lookup(scope, value) {
367            let digest = digest_to_hex(&scoped_digest(scope, value));
368            if found.as_ref().is_some_and(|(old_scope, old_digest, _)| {
369                *old_scope != scope || *old_digest != digest
370            }) {
371                observe(
372                    &entity.namespace,
373                    FinalizerOutcome::ManifestInvalid(ManifestFault::MultipleMatches),
374                );
375                return Err(RuntimeError::InvalidInput(
376                    "secret-gate manifest has multiple candidate matches".into(),
377                ));
378            }
379            found = Some((scope, digest, meta.overridden_detector.clone()));
380        } else {
381            crate::secret_gate::check_at(value, &context.record, field)?;
382        }
383    }
384    Ok(found)
385}
386
387fn property_strings<'a>(value: &'a Value, values: &mut Vec<&'a str>) {
388    match value {
389        Value::String(value) => values.push(value),
390        Value::Array(array) => array
391            .iter()
392            .for_each(|value| property_strings(value, values)),
393        Value::Object(object) => {
394            for (key, value) in object {
395                values.push(key);
396                property_strings(value, values);
397            }
398        }
399        _ => {}
400    }
401}
402
403pub(super) fn observe(namespace: &str, outcome: FinalizerOutcome) {
404    #[cfg(test)]
405    fixture::record_outcome(namespace, outcome);
406    #[cfg(not(test))]
407    let _ = (namespace, outcome);
408}