craft-ai-core: rename from craft-ai-model; lapse each kind of consent on its own; keep models found at a new address

Consent expiry is now per kind of data: renewing text no longer renews an expired image permission. A connection edit keeps models found by a test at the new address, and drops only the ones saved for the old one.
This commit is contained in:
Jeremy
2026-10-15 20:06:57 +01:00
parent cb7f55bae4
commit bd175be54f
46 changed files with 127 additions and 44 deletions
@@ -4,15 +4,15 @@ on:
workflow_dispatch:
push:
branches: [main]
paths: ['crates/ai-model/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-model.yml']
paths: ['crates/ai-core/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-core.yml']
pull_request:
paths: ['crates/ai-model/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-model.yml']
paths: ['crates/ai-core/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ai-core.yml']
permissions:
contents: read
jobs:
ai-model:
ai-core:
timeout-minutes: 15
strategy:
fail-fast: false
@@ -26,7 +26,7 @@ jobs:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- run: cargo fmt --check
- run: cargo clippy -p craft-ai-model --all-targets --locked -- -D warnings
- run: cargo test -p craft-ai-model --locked
- run: cargo clippy -p craft-ai-core --all-targets --locked -- -D warnings
- run: cargo test -p craft-ai-core --locked
# The model promises to compile for the web.
- run: cargo check -p craft-ai-model --target wasm32-unknown-unknown --locked
- run: cargo check -p craft-ai-core --target wasm32-unknown-unknown --locked
Generated
+1 -1
View File
@@ -515,7 +515,7 @@ dependencies = [
]
[[package]]
name = "craft-ai-model"
name = "craft-ai-core"
version = "0.1.0"
dependencies = [
"serde",
+1 -1
View File
@@ -11,7 +11,7 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-ai-model = { path = "crates/ai-model" }
craft-ai-core = { path = "crates/ai-core" }
craft-atomic = { path = "crates/atomic" }
craft-cms = { path = "crates/cms" }
craft-print = { path = "crates/print" }
+1 -1
View File
@@ -23,7 +23,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-ai-model`](crates/ai-model/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite.
- [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing
and writing, transforms, rendering intents, black point compensation, soft proofing, gamut
@@ -1,5 +1,5 @@
[package]
name = "craft-ai-model"
name = "craft-ai-core"
description = "Shared data model for AI connections, roles, requests and jobs across the Craft apps"
version.workspace = true
edition.workspace = true
@@ -1,6 +1,6 @@
# Craft AI model
`craft-ai-model` is the data model and contract for AI in the Craft Apps: connections, models,
`craft-ai-core` is the data model and contract for AI in the Craft Apps: connections, models,
capabilities, roles (tools), requests, jobs, consent, request history, the shared store's file
format, and the declarative route spec. It has no I/O, no async runtime and compiles for
`wasm32-unknown-unknown`. It depends only on `serde` and `serde_json`.
@@ -5,7 +5,8 @@
//! recorded in a [`ConsentLedger`] per app, connection, destination and capability, for the kinds
//! of data it named, and optionally until a time. A configured connection never authorises an app
//! by itself: pointing a connection at a different host, asking for a different capability or
//! letting a timed agreement lapse all ask again. Records saved before capabilities were scoped
//! letting a timed agreement lapse all ask again. Each kind of data lapses on its own: renewing
//! text does not renew an image permission that has run out. Records saved before capabilities were scoped
//! cover nothing, so they ask once more. The clock is the caller's (unix seconds). Local
//! connections never need consent. The disclosure names exactly what this app sends, which is why
//! consent stays in the app even when the key came from the launcher.
@@ -75,7 +76,9 @@ impl Disclosure {
}
}
/// One saved agreement.
/// One saved agreement: the kinds of data it covers, all lapsing together. Agreeing to a kind
/// again moves it out of any record with a different expiry, so one kind's renewal never extends
/// another's.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentRecord {
pub app: AppId,
@@ -117,8 +120,7 @@ impl ConsentLedger {
if d.locality == Locality::Local {
return false;
}
let kinds = d.kinds();
!self.records.iter().any(|r| r.covers(d) && r.expires_at.is_none_or(|t| now < t) && kinds.iter().all(|k| r.kinds.contains(k)))
d.kinds().iter().any(|k| !self.records.iter().any(|r| r.covers(d) && r.expires_at.is_none_or(|t| now < t) && r.kinds.contains(k)))
}
/// Records agreement that holds until revoked.
@@ -131,18 +133,22 @@ impl ConsentLedger {
self.record(d, Some(expires_at));
}
/// Merges the kinds in `d` into the record for the same app, connection, destination and
/// capability. The latest agreement sets that record's expiry; other capabilities keep theirs.
/// Records the kinds in `d` with the given expiry for the same app, connection, destination
/// and capability. The latest agreement sets the expiry of the kinds it names; other kinds
/// and other capabilities keep theirs.
fn record(&mut self, d: &Disclosure, expires_at: Option<u64>) {
let kinds = d.kinds();
if let Some(r) = self.records.iter_mut().find(|r| r.covers(d)) {
for r in self.records.iter_mut().filter(|r| r.covers(d) && r.expires_at != expires_at) {
r.kinds.retain(|k| !kinds.contains(k));
}
self.records.retain(|r| !r.kinds.is_empty());
if let Some(r) = self.records.iter_mut().find(|r| r.covers(d) && r.expires_at == expires_at) {
for k in kinds {
if !r.kinds.contains(&k) {
r.kinds.push(k);
}
}
r.kinds.sort();
r.expires_at = expires_at;
} else {
self.records.push(ConsentRecord {
app: d.app.clone(),
@@ -312,6 +318,32 @@ mod tests {
assert!(ledger.needs_consent(&disclosure_for(&upscale_req()), 5_000));
}
fn text_only_req() -> Request {
Request::new(CapabilityId::parse("image.inpaint").unwrap()).with_input(InputRole::Prompt, Payload::text("a cat"))
}
#[test]
fn renewing_text_does_not_renew_an_expired_image_permission() {
let mut ledger = ConsentLedger::default();
ledger.grant_until(&disclosure_for(&req()), 1_000);
let text = disclosure_for(&text_only_req());
assert!(ledger.needs_consent(&text, 2_000));
ledger.grant_until(&text, 5_000);
assert!(!ledger.needs_consent(&text, 2_000));
assert!(ledger.needs_consent(&disclosure_for(&req()), 2_000), "image consent lapsed at 1000 and was not renewed");
}
#[test]
fn renewing_one_kind_leaves_the_others_on_their_own_expiry() {
let mut ledger = ConsentLedger::default();
ledger.grant_until(&disclosure_for(&req()), 1_000);
ledger.grant(&disclosure_for(&text_only_req()));
let both = disclosure_for(&req());
assert!(!ledger.needs_consent(&both, 999));
assert!(ledger.needs_consent(&both, 1_000));
assert!(!ledger.needs_consent(&disclosure_for(&text_only_req()), u64::MAX));
}
#[test]
fn a_record_from_before_scoping_loads_but_covers_nothing() {
let old = r#"{"records":[{"app":"photocraft","connection":"a","destination":"https://api.example.com","kinds":["text","image"]}]}"#;
@@ -9,8 +9,8 @@
//! - **A key is checked before it is kept.** One line of visible ASCII, no spaces. A pasted
//! newline would otherwise reach an HTTP header.
//! - **Editing keeps what was not touched.** Leaving the key box empty keeps the saved key; the
//! models found on a server are dropped when the address changes, because they belong to the
//! old one.
//! models saved with a connection are dropped when the address changes, because they belong to
//! the old one. Models found by a test at the new address are kept.
//! - **Messages are for people.** Every [`DraftError`] names the field and says what to do, and
//! never contains the key.
//!
@@ -249,6 +249,11 @@ pub struct ConnectionDraft {
/// Editing: the saved key's locator, and whether a key was saved at all.
secret: Option<SecretRef>,
original_endpoint: Option<String>,
/// Editing: the models the connection had when the form opened, which belong to the original
/// address.
loaded_models: Vec<ModelInfo>,
/// The address `models` were last set for through [`set_models`](Self::set_models).
models_for: Option<String>,
}
impl Default for ConnectionDraft {
@@ -266,6 +271,8 @@ impl Default for ConnectionDraft {
models: Vec::new(),
secret: None,
original_endpoint: None,
loaded_models: Vec::new(),
models_for: None,
}
}
}
@@ -313,9 +320,25 @@ impl ConnectionDraft {
models: c.models.clone(),
secret: c.auth.secret_ref().cloned(),
original_endpoint: c.endpoint.clone(),
loaded_models: c.models.clone(),
models_for: None,
}
}
/// Replaces `models` with what a test found at the address now in the form. They are kept on
/// save unless the address changes again afterwards.
pub fn set_models(&mut self, models: Vec<ModelInfo>) {
self.models = models;
self.models_for = Some(self.endpoint.trim().to_owned());
}
/// False when the address moved since the form opened and `models` are still the old
/// address's: neither found by a test at this address nor different from what was loaded.
fn models_fit(&self, address: &str) -> bool {
let moved = self.original_endpoint.as_deref().is_some_and(|o| o != address);
!moved || self.models_for.as_deref() == Some(address) || (self.models_for.is_none() && self.models != self.loaded_models)
}
/// Where this would send data, as far as the address can tell. `None` while it is not a usable
/// address, so a form can show the badge live.
pub fn locality(&self) -> Option<Locality> {
@@ -360,7 +383,6 @@ impl ConnectionDraft {
None => new_id(label, existing)?,
};
let (auth, key) = self.auth_and_key(typed, &id, existing)?;
let moved = self.original_endpoint.as_deref().is_some_and(|o| o != address);
let connection = Connection {
id,
label: label.to_owned(),
@@ -369,7 +391,7 @@ impl ConnectionDraft {
locality,
auth,
billing: self.billing,
models: if moved { Vec::new() } else { self.models.clone() },
models: if self.models_fit(address) { self.models.clone() } else { Vec::new() },
enabled: self.enabled,
key_header,
};
@@ -174,6 +174,35 @@ fn models_follow_the_address() {
assert!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models.is_empty());
}
#[test]
fn models_found_at_the_new_address_are_kept() {
let saved = saved_openai();
let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")];
let mut moved = ConnectionDraft::from_connection(&saved);
moved.endpoint = "https://other.example.com/v1".into();
moved.models = found.clone();
assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, found);
}
#[test]
fn a_retest_that_finds_the_same_models_keeps_them() {
let saved = saved_openai();
let mut moved = ConnectionDraft::from_connection(&saved);
moved.endpoint = "https://other.example.com/v1".into();
moved.set_models(saved.models.clone());
assert_eq!(moved.prepare(std::slice::from_ref(&saved)).unwrap().connection.models, saved.models);
}
#[test]
fn models_found_before_the_address_changed_are_dropped() {
let saved = saved_openai();
let found = vec![ModelInfo::new(crate::ids::ModelId::new("llama-x").unwrap(), "Llama X")];
let mut d = ConnectionDraft::from_connection(&saved);
d.set_models(found);
d.endpoint = "https://other.example.com/v1".into();
assert!(d.prepare(std::slice::from_ref(&saved)).unwrap().connection.models.is_empty());
}
#[test]
fn editing_something_removed_elsewhere_is_an_error() {
let saved = saved_openai();
@@ -1,8 +1,8 @@
//! u1: the rules for a custom (unlisted, OpenAI-shaped) connection: a named key header, and what
//! a stored file may carry. The form is `ConnectionDraft`; these tests go through its public API.
use craft_ai_model::connection::ConnectionError;
use craft_ai_model::{Billing, Connection, ConnectionDraft, Field, KeyChange, Locality};
use craft_ai_core::connection::ConnectionError;
use craft_ai_core::{Billing, Connection, ConnectionDraft, Field, KeyChange, Locality};
fn custom(endpoint: &str, header: &str) -> ConnectionDraft {
let mut d = ConnectionDraft::custom();
@@ -97,7 +97,7 @@ fn a_stored_file_with_a_bad_header_name_is_refused_on_validate() {
c.key_header = Some("host".to_owned());
assert!(matches!(c.validate(), Err(ConnectionError::KeyHeader(_))));
c.key_header = Some("x-api-key".to_owned());
c.auth = craft_ai_model::Auth::None;
c.auth = craft_ai_core::Auth::None;
assert!(matches!(c.validate(), Err(ConnectionError::KeyHeader(_))));
}
@@ -1,4 +1,4 @@
use craft_ai_model::{CapabilityId, InputRole, Role, RoleId, StoreFile};
use craft_ai_core::{CapabilityId, InputRole, Role, RoleId, StoreFile};
fn tool(id: &str) -> Role {
Role {
@@ -45,8 +45,8 @@ fn hostile_tool_declarations_are_refused_on_load() {
#[test]
fn an_app_cannot_reinterpret_the_inputs_of_a_shared_custom_tool() {
use craft_ai_model::role::ResolveError;
use craft_ai_model::{AppId, Binding, CapabilitySpec, Connection, ConnectionId, ModelId, ModelInfo};
use craft_ai_core::role::ResolveError;
use craft_ai_core::{AppId, Binding, CapabilitySpec, Connection, ConnectionId, ModelId, ModelInfo};
let shared = tool("describe-photo");
let model = ModelInfo::new(ModelId::new("m").unwrap(), "M").with(CapabilitySpec::new(shared.capability.clone()).accepting(InputRole::Source, 1));
let connection: Connection = serde_json::from_value(serde_json::json!({
@@ -2,12 +2,12 @@
//! end to end the way an app would use it: declare roles, resolve through the store, check
//! consent, submit, poll, cancel. Also proves one library serves different modalities.
use craft_ai_model::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
use craft_ai_model::consent::Disclosure;
use craft_ai_model::job::{Event, JobState, Output};
use craft_ai_model::mock::{MockProvider, Outcome, Script};
use craft_ai_model::role::{Binding, Role};
use craft_ai_model::*;
use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
use craft_ai_core::consent::Disclosure;
use craft_ai_core::job::{Event, JobState, Output};
use craft_ai_core::mock::{MockProvider, Outcome, Script};
use craft_ai_core::role::{Binding, Role};
use craft_ai_core::*;
fn cap(s: &str) -> CapabilityId {
CapabilityId::parse(s).unwrap()
@@ -3,7 +3,7 @@
mod spec_support;
use craft_ai_model::spec::{MAX_SPEC_BYTES, Spec};
use craft_ai_core::spec::{MAX_SPEC_BYTES, Spec};
use serde_json::{Value, json};
use spec_support::{direct, parse, queue, refused, set};
@@ -1,7 +1,7 @@
//! Fixtures for the spec tests: the two documented shapes and small edit helpers.
#![allow(dead_code)]
use craft_ai_model::spec::{Spec, SpecError};
use craft_ai_core::spec::{Spec, SpecError};
use serde_json::{Value, json};
pub fn queue() -> Value {
@@ -1,7 +1,7 @@
//! Substitution rules for declarative specs: what a template may name, and that the structure of
//! a request can never come from the user's text.
use craft_ai_model::spec::template::{Slot, Values, render_text, render_value, slots, value_slots};
use craft_ai_core::spec::template::{Slot, Values, render_text, render_value, slots, value_slots};
use serde_json::json;
fn values() -> Values<'static> {
+3 -3
View File
@@ -108,7 +108,7 @@ live in the wasm-safe model crate so the launcher can check a spec as it is past
## Where it sits
- `ai-model`: `spec.rs`, the types and the pure validator (wasm-safe, so the launcher can validate
- `ai-core`: `spec.rs`, the types and the pure validator (wasm-safe, so the launcher can validate
live). No I/O.
- New native crate `craft-ai-declarative`: the runner that implements `Provider`, with the HTTP
layer copied in shape from `craft-ai-bfl` (no redirects, redacted errors, capped bodies).
@@ -155,9 +155,9 @@ tests), none above 500.
## As built (13/10/2026)
Code: `crates/ai-model/src/spec.rs` and `spec/` (types, checker, substitutions; wasm-safe),
Code: `crates/ai-core/src/spec.rs` and `spec/` (types, checker, substitutions; wasm-safe),
`crates/ai-declarative` (the runner), `crates/ai-client/src/declarative.rs` (the factory entry).
Tests: `ai-model/tests/spec_*.rs`, `ai-declarative/tests/{queue,direct}.rs`,
Tests: `ai-core/tests/spec_*.rs`, `ai-declarative/tests/{queue,direct}.rs`,
`ai-client/tests/declarative.rs`. Deliberate-bug cases: group `u2` in
`docs/research/phase2d/mutation_check.py` (cases in `mutation_u2.py`).
+3 -3
View File
@@ -1,6 +1,6 @@
# Suite-wide AI contract for app maintainers
**Status:** proposed for review, 13/10/2026. This repo receives `craft-ai-model` (the wasm-safe data model and contract) first; the store, client, adapters and widgets named below arrive in later pull requests and are described here so the whole contract can be reviewed at once. No app mounts it end to end yet.
**Status:** proposed for review, 13/10/2026. This repo receives `craft-ai-core` (the wasm-safe data model and contract) first; the store, client, adapters and widgets named below arrive in later pull requests and are described here so the whole contract can be reviewed at once. No app mounts it end to end yet.
## Ownership boundary
@@ -14,11 +14,11 @@ A registered tool is a **declaration**, not installed code. A compatible binding
## Declaring and adopting a tool
1. Define a `craft_ai_model::Role` with a stable `RoleId`, a display label, one `CapabilityId`, and a nonempty set of `InputRole`s. Choose a suite-wide ID with the other maintainers. `ToolDesk::register_tool(role)` writes it once to the shared store; `ToolDesk::custom_tools()` reads registered declarations in any process using that same store. Built-in IDs cannot be replaced. A declaration does not add it to the seven built-in catalogue.
1. Define a `craft_ai_core::Role` with a stable `RoleId`, a display label, one `CapabilityId`, and a nonempty set of `InputRole`s. Choose a suite-wide ID with the other maintainers. `ToolDesk::register_tool(role)` writes it once to the shared store; `ToolDesk::custom_tools()` reads registered declarations in any process using that same store. Built-in IDs cannot be replaced. A declaration does not add it to the seven built-in catalogue.
2. Add or discover a connection and a model advertising that capability and the required inputs. `ToolDesk::choose(&role, &connection_id, &model_id)` sets the suite default. The shared update verifies the declared tool contract and model compatibility before persisting the binding. An app can use `AiClient::bind` for an explicit per-app override. Removal with `ToolDesk::remove_tool(&role_id)` deletes the declaration and its suite/app bindings together.
3. An app reads the same store and resolves the role with `AiClient::status`, `choices`, `picker`, `plan` and `start` as appropriate. It may localise the label, but **must not reinterpret the same ID's capability or inputs**. Mismatched contracts are rejected during resolution, model listing, picker creation and binding. Apps still own their UI, input conversion and actual execution route.
Concrete API examples and regressions: `crates/ai-client/tests/custom_tools.rs`, `crates/ai-model/tests/custom_tools.rs` and `crates/ai-native/tests/shared_store.rs`. The shared store is `craft_ai_native::shared::SharedStore`; it uses locked atomic updates for registration, binding and removal. Old version-1 JSON files without `custom_tools` still load with an empty list. Secret values belong in the secret store, never in the shared JSON or a tool declaration.
Concrete API examples and regressions: `crates/ai-client/tests/custom_tools.rs`, `crates/ai-core/tests/custom_tools.rs` and `crates/ai-native/tests/shared_store.rs`. The shared store is `craft_ai_native::shared::SharedStore`; it uses locked atomic updates for registration, binding and removal. Old version-1 JSON files without `custom_tools` still load with an empty list. Secret values belong in the secret store, never in the shared JSON or a tool declaration.
## Deliberate limits and next gate