saya-cli 0.3.2

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
//! Tests for the read-only agent contract tools (slice 2b-3a).
//!
//! These exercise the `contract_search` and `contract_read` tools end-to-end
//! through the `ToolExecutor` surface the agent uses, against a real
//! `SqliteStateStore`. They assert the privacy gate, the store-failure
//! degradation, the absence of the opaque identity, and the `ToolDefinition`
//! invariants — see .claude/specs/spec-2b3a-agent-contract-tools.md §4.

use super::*;
use async_trait::async_trait;
use saya_agent::{ToolError, ToolExecutor};
use saya_connectors::DatabaseConnector;
use saya_store::{SchemaStore, SqliteStateStore};
use saya_types::{
    ClaimOrigin, ClaimPayload, Column, ConnectionError, Database, DatabaseObjectKind,
    DatabaseObjectRef, DatabaseProfile, KnowledgeSlot, KnowledgeState, ProfileIdentity,
    QueryRequest, QueryResult, Schema, SchemaFingerprint, SchemaTree, SqlDialect, Table,
};
use std::{
    fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use crate::connection::{ConnectionEntry, ConnectionRegistry};

/// A no-op connector: the contract tools never touch a live database, so the
/// registry only needs an entry to resolve a profile name + identity from.
struct IdleConnector;

#[async_trait]
impl DatabaseConnector for IdleConnector {
    fn dialect(&self) -> SqlDialect {
        SqlDialect::DuckDb
    }
    async fn connect(&self) -> Result<(), ConnectionError> {
        Ok(())
    }
    async fn schema(&self) -> Result<SchemaTree, ConnectionError> {
        Ok(SchemaTree::default())
    }
    async fn execute(&self, req: QueryRequest) -> Result<QueryResult, ConnectionError> {
        Ok(QueryResult::empty(req.sql))
    }
}

fn temp_root(label: &str) -> PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let root = std::env::temp_dir().join(format!(
        "saya-contract-tools-{label}-{}-{stamp}",
        std::process::id()
    ));
    fs::create_dir_all(&root).unwrap();
    root
}

fn profile_identity(name: &str) -> ProfileIdentity {
    crate::profile_identity::profile_identity(
        name,
        &DatabaseProfile::DuckDb {
            path: "contract-tools.duckdb".into(),
            read_only: Some(true),
        },
        Path::new("/contract-tools-test/connections.toml"),
    )
}

/// A registry whose primary entry carries a real profile identity, so the
/// contract tools can resolve the connection name to the store's identity.
fn registry_with_primary(name: &str, identity: &ProfileIdentity) -> ConnectionRegistry {
    let mut registry = ConnectionRegistry::new(name);
    registry.insert(
        name,
        ConnectionEntry {
            connector: Box::new(IdleConnector),
            dialect: SqlDialect::DuckDb,
            profile_id: Some(identity.as_str().to_string()),
        },
    );
    registry
}

async fn store_at(db: &Path) -> SqliteStateStore {
    let store = SqliteStateStore::new(db);
    store
        .upsert_schema(profile_identity("primary").as_str(), &SchemaTree::default())
        .await
        .unwrap();
    store
}

/// A one-column live table for `object`, so a claim stored under its
/// fingerprint reads `current` (the fingerprint matches and `table_alias` /
/// `table_description` claims reference no columns to drift). This keeps the
/// fixture honest: these tests exercise confirmed-vs-candidate filtering,
/// truncation and identity-leak — not staleness — so the claim must not be
/// stale. Storing under the all-zero `unobserved_fingerprint` against an empty
/// cached schema would compute `Stale`, which the model-facing policy now drops.
fn current_table(object: &DatabaseObjectRef) -> Table {
    Table {
        name: object.object().to_string(),
        columns: vec![Column {
            name: "id".into(),
            data_type: "bigint".into(),
            nullable: false,
        }],
    }
}

/// Seeds a cached schema for `object`'s profile carrying `object`'s table, and
/// returns that table's fingerprint — so a claim stored under it reads
/// `current` against the cache. Called by the remember-helpers below.
async fn seed_current(store: &SqliteStateStore, object: &DatabaseObjectRef) -> SchemaFingerprint {
    let table = current_table(object);
    let tree = SchemaTree {
        databases: vec![Database {
            name: object.catalog().to_string(),
            schemas: vec![Schema {
                name: object.schema().to_string(),
                tables: vec![table.clone()],
            }],
        }],
    };
    store
        .upsert_schema(object.profile().as_str(), &tree)
        .await
        .unwrap();
    SchemaFingerprint::of_table(DatabaseObjectKind::Table, &table)
}

/// Seeds a knowledge item into the D-3 `knowledge_items` table both contract
/// tools read: `contract_search` via `recall`, `contract_read` via `show`
/// (Chunk 3 migrated `show` onto `knowledge_items`). The slot and
/// `SchemaBinding` are derived from the payload the way the ingest path derives
/// them. The cached schema (`seed_current`) names the object's table with an
/// `id` column so a `Table` binding reads `current`; a `Column` binding needs
/// its column present too (a test that needs a richer schema upserts its own
/// after seeding).
async fn put_knowledge_item(
    store: &SqliteStateStore,
    object: &DatabaseObjectRef,
    payload: ClaimPayload,
    state: KnowledgeState,
) {
    use saya_store::{KnowledgeItemRequest, KnowledgeItemStore};
    use saya_types::SchemaBinding;
    let _ = seed_current(store, object).await;
    let slot = slot_for(&payload);
    let binding = SchemaBinding::derive(&slot, &payload).expect("slot/payload agree");
    let request = KnowledgeItemRequest {
        object: object.clone(),
        slot,
        value: payload,
        source: if state == KnowledgeState::Active {
            ClaimOrigin::UserExplicit
        } else {
            ClaimOrigin::AssistantInferred
        },
        state,
        schema_binding_json: serde_json::to_string(&binding).unwrap(),
        fingerprint: crate::commands::unobserved_fingerprint(),
    };
    store.put_knowledge_item(request).await.unwrap();
}

fn slot_for(payload: &ClaimPayload) -> KnowledgeSlot {
    match payload {
        ClaimPayload::TableDescription { .. } => KnowledgeSlot::TableDescription,
        ClaimPayload::TableAlias { .. } => KnowledgeSlot::TableAlias,
        ClaimPayload::TableGrain { .. } => KnowledgeSlot::TableGrain,
        ClaimPayload::DefaultTimeColumn { .. } => KnowledgeSlot::TableDefaultTime,
        ClaimPayload::ColumnDescription { column, .. } => KnowledgeSlot::ColumnDescription {
            column: column.clone(),
        },
        ClaimPayload::ColumnRole { column, .. } => KnowledgeSlot::ColumnRole {
            column: column.clone(),
        },
        _ => panic!("no slot for payload {:?}", payload),
    }
}

fn object_ref(profile: &ProfileIdentity, name: &str) -> DatabaseObjectRef {
    DatabaseObjectRef::new(
        profile.clone(),
        "catalog",
        "public",
        name,
        DatabaseObjectKind::Table,
    )
    .unwrap()
}

// ---------------------------------------------------------------------------
// Test 1: contract_search returns a confirmed claim; a candidate is absent.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn contract_search_returns_confirmed_and_hides_candidates() {
    let root = temp_root("search_confirmed");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::table_alias("orders").unwrap(),
        KnowledgeState::Active,
    )
    .await;
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::table_alias("secret_candidate").unwrap(),
        KnowledgeState::Pending,
    )
    .await;

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );
    let res = tools
        .execute("contract_search", serde_json::json!({"terms": ["orders"]}))
        .await
        .expect("contract_search should succeed");

    let contracts = res
        .get("contracts")
        .and_then(|v| v.as_array())
        .expect("result carries a `contracts` array");
    assert_eq!(contracts.len(), 1, "one matched object");
    let claims = contracts[0]["claims"].as_array().expect("claims array");
    assert!(
        claims
            .iter()
            .any(|c| c["kind"] == "table_alias" && c["value"] == "orders"),
        "confirmed alias should appear: {res}"
    );
    assert!(
        !claims.iter().any(|c| c["value"] == "secret_candidate"),
        "candidate alias must not appear: {res}"
    );

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 2: contract_read returns one object's contract; malformed table errors.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn contract_read_returns_one_contract_and_rejects_malformed_table() {
    let root = temp_root("read_one");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    // `contract_read` calls `show`, which Chunk 3 migrated to `knowledge_items`
    // — so seed an Active item there (a `Table` binding is valid against the
    // `id`-only schema `put_knowledge_item` caches).
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::table_description("sales fact table").unwrap(),
        KnowledgeState::Active,
    )
    .await;

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store.clone()),
    );

    let res = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.orders"}),
        )
        .await
        .expect("contract_read should succeed");
    let contract = res.get("contract").expect("one contract");
    assert_eq!(contract["object"], "catalog.public.orders");
    assert!(
        contract["claims"]
            .as_array()
            .unwrap()
            .iter()
            .any(|c| c["kind"] == "table_description" && c["value"] == "sales fact table"),
        "stored description should appear: {res}"
    );

    // A malformed table is a typed tool error, not a panic or an untyped string.
    // `ToolError` (in saya-agent, untouchable from this slice) has no variant that
    // names the expected form, so the form is named in the tool description the
    // model reads; the typed error is `InvalidQueryArguments` (SPEC REVIEW 2b-3a).
    let err = tools
        .execute("contract_read", serde_json::json!({"table": "orders"}))
        .await
        .expect_err("malformed table should be a typed error");
    assert!(
        matches!(err, ToolError::InvalidQueryArguments),
        "malformed table should be InvalidQueryArguments: {err}"
    );

    let defs = DatabaseTools::definitions(true, true, false);
    let read_def = defs
        .iter()
        .find(|t| t.name == "contract_read")
        .expect("contract_read is defined");
    assert!(
        read_def.description.contains("catalog.schema"),
        "the description names the expected form: {}",
        read_def.description
    );

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 3: unknown property, non-string connection, 17-entry terms.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn contract_tool_argument_validation_errors_are_typed() {
    let identity = profile_identity("primary");
    let tools =
        DatabaseTools::with_registry(registry_with_primary("primary", &identity), 100, true, None);

    let err = tools
        .execute(
            "contract_search",
            serde_json::json!({"terms": ["x"], "bogus": 1}),
        )
        .await
        .expect_err("unknown property must be rejected");
    assert!(matches!(err, ToolError::UnsupportedProperty), "got: {err}");

    let err = tools
        .execute(
            "contract_search",
            serde_json::json!({"terms": ["x"], "connection": 7}),
        )
        .await
        .expect_err("non-string connection must be rejected");
    assert!(matches!(err, ToolError::ConnectionNotString), "got: {err}");

    let terms: Vec<String> = (0..17).map(|i| format!("t{i}")).collect();
    let err = tools
        .execute("contract_search", serde_json::json!({"terms": terms}))
        .await
        .expect_err("17 terms must be rejected");
    assert!(
        matches!(err, ToolError::InvalidQueryArguments),
        "too many terms should be InvalidQueryArguments: {err}"
    );

    // terms not an array of strings is also a typed error.
    let err = tools
        .execute("contract_search", serde_json::json!({"terms": "orders"}))
        .await
        .expect_err("terms-as-string must be rejected");
    assert!(
        matches!(err, ToolError::InvalidQueryArguments),
        "got: {err}"
    );
}

// ---------------------------------------------------------------------------
// Test 4: no opaque identity in any tool result.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn no_opaque_profile_identity_in_any_tool_result() {
    let root = temp_root("no_identity");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    // Both `contract_read` (via `show`) and `contract_search` (via `recall`)
    // read `knowledge_items` now; seed one Active alias so each tool sees it
    // and the identity-leak assertion covers both.
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::table_alias("orders").unwrap(),
        KnowledgeState::Active,
    )
    .await;

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );

    let search = tools
        .execute("contract_search", serde_json::json!({"terms": ["orders"]}))
        .await
        .unwrap();
    let read = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.orders"}),
        )
        .await
        .unwrap();

    let identity_str = identity.as_str();
    assert_eq!(identity_str.len(), 66);
    assert!(identity_str.starts_with("p-"));
    let search_text = serde_json::to_string(&search).unwrap();
    let read_text = serde_json::to_string(&read).unwrap();
    assert!(
        !search_text.contains(identity_str),
        "identity leaked into contract_search: {search_text}"
    );
    assert!(
        !read_text.contains(identity_str),
        "identity leaked into contract_read: {read_text}"
    );
    // The profile NAME the model may see is present, not the identity.
    assert!(search_text.contains("\"profile\":\"primary\""));
    assert!(read_text.contains("\"profile\":\"primary\""));

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 5: privacy gate closed -> both tools return empty, no claims.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn privacy_gate_closed_returns_empty_without_claims() {
    let root = temp_root("privacy_closed");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::table_alias("secret_alias").unwrap(),
        KnowledgeState::Active,
    )
    .await;

    // allow_query_data = false models the closed privacy gate.
    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        false,
        Some(store),
    );

    let res = tools
        .execute(
            "contract_search",
            serde_json::json!({"terms": ["secret_alias"]}),
        )
        .await
        .expect("privacy gate returns empty, not an error");
    assert!(
        res.get("contracts")
            .map(|c| c.as_array().unwrap().is_empty())
            .unwrap_or(true),
        "no contracts when the gate is closed: {res}"
    );
    let text = serde_json::to_string(&res).unwrap();
    assert!(!text.contains("secret_alias"));

    let res = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.orders"}),
        )
        .await
        .expect("privacy gate returns empty, not an error");
    assert!(
        res.get("contract").is_none(),
        "no contract when closed: {res}"
    );
    let text = serde_json::to_string(&res).unwrap();
    assert!(!text.contains("secret_alias"));

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 6: unopenable store -> empty result, never Err.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unopenable_store_returns_empty_result_not_error() {
    let root = temp_root("unopenable");
    fs::write(root.join("blocker"), b"x").unwrap();
    let bad = root.join("blocker/state.sqlite3");
    let store = SqliteStateStore::new(&bad);
    let identity = profile_identity("primary");

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );

    let search = tools
        .execute("contract_search", serde_json::json!({"terms": ["orders"]}))
        .await
        .expect("store failure must be an empty result, not Err");
    assert!(
        search
            .get("contracts")
            .map(|c| c.as_array().unwrap().is_empty())
            .unwrap_or(true),
        "empty contracts on store failure: {search}"
    );

    let read = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.orders"}),
        )
        .await
        .expect("store failure must be an empty result, not Err");
    assert!(
        read.get("contract").is_none(),
        "no contract on store failure: {read}"
    );

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 7: ToolDefinition invariants — read-only, no approval, not writable.
// ---------------------------------------------------------------------------
#[test]
fn contract_tool_definitions_are_read_only_unapproved_and_not_writable() {
    let tools = DatabaseTools::definitions(true, true, false);
    let search = tools
        .iter()
        .find(|t| t.name == "contract_search")
        .expect("contract_search is registered when a store is present and data is allowed");
    let read = tools
        .iter()
        .find(|t| t.name == "contract_read")
        .expect("contract_read is registered");

    for tool in [search, read] {
        assert!(tool.read_only, "{} must be read_only", tool.name);
        assert!(
            !tool.effect.requires_approval,
            "{} must not require approval",
            tool.name
        );
        assert!(
            !tool.effect.external_side_effect,
            "{} must have no external side effect",
            tool.name
        );
        assert!(
            tool.effect.database_data,
            "{} is database-derived and subject to the sharing gate",
            tool.name
        );
    }
}

// ---------------------------------------------------------------------------
// Test 7b: availability gate — tools are hidden without a store or when the
// privacy gate is closed (spec §3: hide, do not advertise-empty).
// ---------------------------------------------------------------------------
#[test]
fn contract_tools_are_hidden_without_a_store_or_when_the_gate_is_closed() {
    let with_store_and_gate = DatabaseTools::definitions(true, true, false);
    assert!(
        with_store_and_gate
            .iter()
            .any(|t| t.name == "contract_search")
    );
    assert!(
        with_store_and_gate
            .iter()
            .any(|t| t.name == "contract_read")
    );

    let no_store = DatabaseTools::definitions(true, false, false);
    assert!(!no_store.iter().any(|t| t.name == "contract_search"));
    assert!(!no_store.iter().any(|t| t.name == "contract_read"));

    let gate_closed = DatabaseTools::definitions(false, true, false);
    assert!(!gate_closed.iter().any(|t| t.name == "contract_search"));
    assert!(!gate_closed.iter().any(|t| t.name == "contract_read"));
}

// ---------------------------------------------------------------------------
// Test 8: contract_read flags truncation past the per-object claim bound.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn contract_read_truncates_claims_past_the_bound_and_says_so() {
    let root = temp_root("read_truncated");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "wide");
    // More claims than the 12-per-object `read_payload` bound. D-4 caps a
    // multi-valued slot at 4 values, so seed `column_description` items across
    // five columns (4 each = 20): each is a distinct `Column { colN, Exists }`
    // slot, so cardinality admits four per column. `put_knowledge_item` caches
    // an `id`-only schema per call; upsert a five-column schema once at the end
    // so every binding reads `Valid` (Current) and `show` returns all 20.
    for col in 0..5 {
        for n in 0..4 {
            put_knowledge_item(
                &store,
                &obj,
                ClaimPayload::column_description(format!("c{col}"), format!("desc {n}")).unwrap(),
                KnowledgeState::Active,
            )
            .await;
        }
    }
    let wide = SchemaTree {
        databases: vec![Database {
            name: obj.catalog().to_string(),
            schemas: vec![Schema {
                name: obj.schema().to_string(),
                tables: vec![Table {
                    name: obj.object().to_string(),
                    columns: (0..5)
                        .map(|col| Column {
                            name: format!("c{col}"),
                            data_type: "text".into(),
                            nullable: true,
                        })
                        .collect(),
                }],
            }],
        }],
    };
    store
        .upsert_schema(obj.profile().as_str(), &wide)
        .await
        .unwrap();

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );
    let res = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.wide"}),
        )
        .await
        .expect("contract_read should succeed");
    let contract = res.get("contract").expect("one contract");
    let claims = contract["claims"].as_array().expect("claims array");
    assert!(
        claims.len() <= 12,
        "claims bounded to 12, got {}: {res}",
        claims.len()
    );
    assert!(
        contract["truncated"] == serde_json::Value::Bool(true),
        "truncation must be flagged explicitly: {res}"
    );

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 9: contract_read on an object with no contract returns empty + reason.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn contract_read_on_unknown_object_returns_empty_with_reason() {
    let root = temp_root("read_unknown");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );

    let res = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.missing"}),
        )
        .await
        .expect("no contract is an empty result, not an error");
    assert!(res.get("contract").is_none(), "no contract object: {res}");
    let reason = res
        .get("reason")
        .and_then(|v| v.as_str())
        .expect("a short reason explains the empty result");
    assert!(!reason.is_empty());

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 10: a confirmed claim computed `stale` is dropped from contract_search,
// and the result says so rather than silently returning an empty list.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn contract_search_drops_a_stale_claim_and_says_so() {
    let root = temp_root("search_stale");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    // A confirmed `default_time_column` item on `created_at`, filed against a
    // cached schema that has since dropped `created_at`. The D-4 binding
    // (`Column { created_at, Time }`) is no longer satisfied, so the contract
    // aggregates to `Invalid` (→ `Stale`) and the model-facing `contract_search`
    // drops it and says why. (`remember_confirmed_stale` seeds the legacy table
    // the `contract_read` companion uses; `contract_search` reads knowledge_items.)
    let drifted = SchemaTree {
        databases: vec![Database {
            name: obj.catalog().to_string(),
            schemas: vec![Schema {
                name: obj.schema().to_string(),
                tables: vec![Table {
                    name: obj.object().to_string(),
                    columns: vec![Column {
                        name: "id".into(),
                        data_type: "bigint".into(),
                        nullable: false,
                    }],
                }],
            }],
        }],
    };
    store
        .upsert_schema(obj.profile().as_str(), &drifted)
        .await
        .unwrap();
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::default_time_column("created_at", None).unwrap(),
        KnowledgeState::Active,
    )
    .await;

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );
    let res = tools
        .execute("contract_search", serde_json::json!({"terms": ["orders"]}))
        .await
        .expect("contract_search should succeed");
    // The stale contract is dropped for the model — no contracts array entries.
    let contracts = res
        .get("contracts")
        .and_then(|v| v.as_array())
        .expect("result carries a `contracts` array");
    assert!(
        contracts.is_empty(),
        "stale contract must not appear: {res}"
    );
    // The result says *why* it is empty, so the model does not retry the same
    // terms expecting a different answer.
    let reason = res
        .get("reason")
        .and_then(|v| v.as_str())
        .expect("a stale exclusion carries a reason");
    assert!(
        reason.contains("stale"),
        "reason names staleness, got: {reason}"
    );
    // The gone-column claim value never reaches the result.
    let text = serde_json::to_string(&res).unwrap();
    assert!(
        !text.contains("created_at"),
        "stale claim text must not leak: {text}"
    );

    let _ = fs::remove_dir_all(root);
}

// ---------------------------------------------------------------------------
// Test 11: contract_read on a stale object reports the object as stale and
// returns no claims to act on.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn contract_read_on_a_stale_object_reports_stale_with_no_claims() {
    let root = temp_root("read_stale");
    let store = store_at(&root.join("state.sqlite3")).await;
    let identity = profile_identity("primary");
    let obj = object_ref(&identity, "orders");
    // An Active `default_time_column` on `created_at`. `put_knowledge_item`
    // caches an `id`-only schema (no `created_at`), so the item's
    // `Column { created_at, Time }` binding reads `Invalid` → the contract
    // aggregates to `Stale`, and the model-facing `contract_read` (`show` with
    // `ForModel`) reports the object stale with no claims — the gone-column
    // fact is not handed to the model as current.
    put_knowledge_item(
        &store,
        &obj,
        ClaimPayload::default_time_column("created_at", None).unwrap(),
        KnowledgeState::Active,
    )
    .await;

    let tools = DatabaseTools::with_registry(
        registry_with_primary("primary", &identity),
        100,
        true,
        Some(store),
    );
    let res = tools
        .execute(
            "contract_read",
            serde_json::json!({"table": "catalog.public.orders"}),
        )
        .await
        .expect("contract_read should succeed");
    // The object is reported as stale — the model learns the object is stale,
    // not that no contract exists (which would be the same bug in a different
    // hat).
    let contract = res.get("contract").expect("contract object present: {res}");
    assert_eq!(
        contract["schema_state"], "stale",
        "stale object is reported stale: {res}"
    );
    assert_eq!(
        contract["object"], "catalog.public.orders",
        "the stale object is named: {res}"
    );
    // And there are no claims to act on — the gone-column claim is not handed
    // to the model as a current fact.
    let claims = contract
        .get("claims")
        .and_then(|v| v.as_array())
        .expect("claims array present");
    assert!(
        claims.is_empty(),
        "no claims to act on for a stale object: {res}"
    );
    let text = serde_json::to_string(&res).unwrap();
    assert!(
        !text.contains("created_at"),
        "stale claim text must not leak: {text}"
    );

    let _ = fs::remove_dir_all(root);
}