saya_cli/commands/contracts.rs
1//! The headless `saya contracts` adapter: resolves arguments, calls the
2//! `crate::contracts` operations, maps results to render DTOs, and emits them.
3//!
4//! No policy lives here. What may be recalled, what conflicts, what is stale,
5//! and whether a claim may be stored confirmed are all decided in
6//! `crate::contracts` and the store — this module only resolves a profile, builds
7//! the typed request, and renders the typed result. 2b-4's slash/agent adapters
8//! call the same operations and must not need to duplicate anything here.
9//!
10//! Dispatch and shared helpers live here; profile resolution is in
11//! `contracts_profile.rs`, read commands in `contracts_read.rs`, write commands
12//! in `contracts_write.rs`, and view→DTO mapping in `contracts_map.rs`.
13
14mod contracts_decide;
15mod contracts_map;
16mod contracts_profile;
17mod contracts_read;
18mod contracts_remember_schema;
19mod contracts_write;
20
21// The identity-dropping `RetrievedContract → ContractView` mapping, re-exported
22// `pub(crate)` so the agent contract tools (2b-3a) reuse it instead of carrying
23// a second mapping that could leak the opaque profile identity.
24pub(crate) use contracts_map::contract_view;
25pub(crate) use contracts_map::queue_item_view;
26// The identity-dropping profile resolution, re-exported `pub(crate)` so the
27// preferences adapter (5c-2) reuses it rather than carrying a second one.
28pub(crate) use contracts_profile::resolve_profile;
29
30use super::output::failure_message;
31use crate::cli::ContractsCommand;
32use crate::config::runtime::RuntimeConfig;
33use crate::contracts::ContractOpError;
34use crate::contracts::SchemaAvailability;
35use crate::render::RenderFormat;
36use saya_store::{SchemaStore, SqliteStateStore};
37use saya_types::{ClaimId, FINGERPRINT_VERSION, ProfileIdentity, SchemaFingerprint, SchemaTree};
38
39/// Exit code for any typed contract-command failure (usage error, op error, or a
40/// write against an unavailable store). Matches the user-error code `config`
41/// uses; the scheme here is ad-hoc per command like the rest of the crate.
42pub(super) const EXIT_CONTRACT_ERROR: i32 = 2;
43
44pub async fn run_contracts(
45 command: ContractsCommand,
46 runtime: &RuntimeConfig,
47 format: RenderFormat,
48 store: &SqliteStateStore,
49) -> Result<i32, Box<dyn std::error::Error>> {
50 match command {
51 ContractsCommand::List { profile } => {
52 contracts_read::list(store, runtime, format, profile.as_deref()).await
53 }
54 ContractsCommand::Show { table, profile } => {
55 contracts_read::show(store, runtime, format, &table, profile.as_deref()).await
56 }
57 ContractsCommand::Queue { profile, limit } => {
58 contracts_read::queue(store, runtime, format, profile.as_deref(), limit).await
59 }
60 ContractsCommand::Remember {
61 table,
62 kind,
63 value,
64 column,
65 reason,
66 profile,
67 } => match resolve_profile(runtime, profile.as_deref()) {
68 Ok((name, identity)) => {
69 contracts_write::remember(
70 contracts_write::RememberRequest {
71 table: &table,
72 kind,
73 value: &value,
74 column: column.as_deref(),
75 reason: reason.as_deref(),
76 },
77 contracts_write::RememberContext {
78 store,
79 format,
80 profile_name: &name,
81 identity: &identity,
82 },
83 )
84 .await
85 }
86 Err((code, message)) => failure_message(code, message, format),
87 },
88 ContractsCommand::Review {
89 claim_id,
90 confirm,
91 reject,
92 } => contracts_write::review(store, format, &claim_id, confirm, reject).await,
93 ContractsCommand::Decide {
94 prefix,
95 decision,
96 profile,
97 } => match resolve_profile(runtime, profile.as_deref()) {
98 Ok((name, identity)) => {
99 contracts_decide::decide(store, format, &prefix, decision, &name, &identity).await
100 }
101 Err((code, message)) => failure_message(code, message, format),
102 },
103 ContractsCommand::Forget { claim_id, reason } => {
104 contracts_write::forget_claim(store, format, &claim_id, reason).await
105 }
106 }
107}
108
109/// Emits a typed contract-operation error and returns the contract error exit
110/// code. `ContractOpError` is payload-free, so no identity or value can leak.
111pub(super) fn op_failure(
112 error: ContractOpError,
113 format: RenderFormat,
114) -> Result<i32, Box<dyn std::error::Error>> {
115 failure_message(EXIT_CONTRACT_ERROR, error.to_string(), format)
116}
117
118/// Emits a payload-free argument-error message and returns the contract error
119/// exit code: malformed table, bad value, ambiguous review flags.
120pub(super) fn arg_failure(
121 message: ArgMessage,
122 format: RenderFormat,
123) -> Result<i32, Box<dyn std::error::Error>> {
124 failure_message(EXIT_CONTRACT_ERROR, message.to_string(), format)
125}
126
127/// The "no schema observed" fingerprint: current format, all-zero digest. The
128/// headless adapter has no live schema to fingerprint, so it stores a digest
129/// guaranteed never to equal a real schema's — a later live schema reads the
130/// claim as `needs_review` (or `stale` if a referenced column is gone), never as
131/// `current`. Fabricating a real-looking digest would risk a false match.
132///
133/// `pub(crate)` so the agent contract tools (2b-3a) reuse this same sentinel
134/// instead of inventing a second all-zero digest convention.
135pub(crate) fn unobserved_fingerprint() -> SchemaFingerprint {
136 SchemaFingerprint::from_parts(FINGERPRINT_VERSION, &"0".repeat(64))
137 .expect("current format with a 64-hex-zero digest is a valid fingerprint")
138}
139
140/// The cached schema for `identity`, or `None` when nothing is cached. Mirrors
141/// the agent recall path's `SchemaStore::get_schema` lookup, but without the
142/// `.unwrap_or_default()` that collapses "no cache" onto an empty tree: a
143/// missing cache stays `None` so validity reads the honest
144/// `live_schema_unavailable`, while a cached-but-empty tree reads `stale`. A
145/// store read failure degrades to `None` — not a crash on a read path.
146///
147/// Shared by the read commands (`list`/`show`), the write command (`remember`),
148/// and the agent contract tools (`contract_search`), so every adapter that
149/// classifies a claim against the cached schema does it the same way — never a
150/// second lookup convention.
151pub(crate) async fn cached_schema(
152 store: &SqliteStateStore,
153 identity: &ProfileIdentity,
154) -> Option<SchemaTree> {
155 store
156 .get_schema(identity.as_str())
157 .await
158 .ok()
159 .flatten()
160 .map(|cached| cached.schema)
161}
162
163/// The schema known for a profile as a three-state [`SchemaAvailability`]: a
164/// real cache (`Available`, carrying when it was observed), no cache entry yet
165/// (`Missing`), or a store that could not be read (`Unavailable`). The read
166/// commands and the agent contract tools use this — *not* [`cached_schema`] —
167/// so a store error or an undiscovered profile classifies `LiveSchemaUnavailable`
168/// instead of collapsing to an empty tree that would read `Stale` (the P1 bug).
169/// The write path (`remember`/`import`) keeps [`cached_schema`]: it resolves a
170/// fingerprint against whatever the cache holds, which is not a classification.
171pub(crate) async fn cached_schema_availability(
172 store: &SqliteStateStore,
173 identity: &ProfileIdentity,
174) -> SchemaAvailability {
175 match store.get_schema(identity.as_str()).await {
176 Ok(Some(cached)) => SchemaAvailability::available(cached.schema, cached.updated_unix_ms),
177 Ok(None) => SchemaAvailability::Missing,
178 Err(_) => SchemaAvailability::Unavailable,
179 }
180}
181
182/// Parses a claim id, mapping a malformed one to a payload-free typed message
183/// so an untrusted id never reaches the terminal.
184pub(super) fn parse_claim_id(input: &str) -> Result<ClaimId, String> {
185 ClaimId::parse(input).map_err(|_| ArgMessage::MalformedClaimId.to_string())
186}
187
188/// Payload-free argument-error messages, so untrusted input (a bad table name,
189/// a bad claim id, a value the store refused) never reaches the terminal.
190#[derive(Debug, Clone, Copy)]
191pub(super) enum ArgMessage {
192 MalformedTable,
193 MalformedClaimId,
194 BadValue,
195 AmbiguousReview,
196 /// A short-reference prefix matched more than one claim (spec D). The typed
197 /// prefixes never reach the message — the user must type more characters.
198 AmbiguousPrefix,
199 /// A short-reference prefix matched no claim (spec D). Payload-free: the
200 /// prefix the user typed is untrusted and never echoed.
201 PrefixNotFound,
202}
203
204impl std::fmt::Display for ArgMessage {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 match self {
207 Self::MalformedTable => write!(
208 f,
209 "qualified name must be exactly three dot-separated parts: catalog.schema.object"
210 ),
211 Self::MalformedClaimId => write!(f, "claim id must be alphanumeric, '-', or '_'"),
212 Self::BadValue => write!(f, "claim value is invalid"),
213 Self::AmbiguousReview => write!(f, "choose exactly one of --confirm or --reject"),
214 Self::AmbiguousPrefix => write!(
215 f,
216 "that claim reference matches more than one claim; type more characters"
217 ),
218 Self::PrefixNotFound => write!(
219 f,
220 "no claim matches that reference; it may have been forgotten, or the prefix is too short"
221 ),
222 }
223 }
224}