knowledge: agent self-correction with audit + per-persona gate + revert
Bundles three coupled changes so agent-side mutations stay
auditable and reversible:
1. Audit columns on entity_facts —
`last_modified_by_model` / `last_modified_by_backend` /
`last_modified_at`. Stamped on every mutation path
(update_fact, supersede_fact, manual PATCH, manual supersede,
the new revert). NULL on rows never touched since creation.
Partial index on `last_modified_at WHERE NOT NULL` keeps the
"show me recent edits" feed fast without bloating from legacy
rows.
2. Per-persona gate `personas.allow_agent_corrections` (BOOLEAN,
default 0). Defense in depth at two layers:
- build_tool_definitions: when off, `update_fact` and
`supersede_fact` aren't in the catalog at all, so even a
hallucinated tool call by the model fails fast.
- tool_update_fact / tool_supersede_fact: re-checks the persona
flag at call time and returns an explicit "corrections
disabled" error if it's somehow off (e.g. flag flipped mid-
loop).
ToolGateOpts grows the flag; current_gate_opts splits into
`current_gate_opts` (no persona context, defaults closed) +
`current_gate_opts_for_persona` for chat callers that have a
persona id. Both call sites in insight_chat are updated.
3. Revert action — new DAO method `revert_supersession` +
`POST /knowledge/facts/{id}/restore`. Flips status back to
'active', clears `superseded_by`, clears `valid_until` (we
don't track whether it was hand-set vs auto-stamped, so the
safe reset is to drop it — user can re-bound after). Stamps
`last_modified_*` so the revert itself is attributable.
Manual paths (PATCH / supersede via HTTP, plus restore) stamp the
audit columns with `("manual", "manual")`. Agent paths stamp the
loop-time chat model and backend (mirroring the existing
created_by_* convention).
FactDetail in the HTTP response now carries the audit triple
alongside the existing provenance. Apollo wires the new field set
in the matching commit.
PersonaView / UpdatePersonaRequest grow `allowAgentCorrections`;
the PersonaPatch + InsertPersona + bulk_import paths thread it.
317 lib tests pass, including unchanged update_fact / supersede
DAO tests (now passing audit=None — None means "no provenance
context to attribute", legacy semantics).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,12 @@ pub struct FactDetail {
|
||||
/// rows. `created_by_backend` is "local" / "hybrid" / "manual".
|
||||
pub created_by_model: Option<String>,
|
||||
pub created_by_backend: Option<String>,
|
||||
/// Audit trail — see migration 2026-05-10-000500. Set on any
|
||||
/// post-creation mutation. NULL on rows that have never been
|
||||
/// touched after they were first written.
|
||||
pub last_modified_by_model: Option<String>,
|
||||
pub last_modified_by_backend: Option<String>,
|
||||
pub last_modified_at: Option<i64>,
|
||||
/// Set when another active fact has the same subject+predicate,
|
||||
/// a different object, AND their valid-time intervals overlap.
|
||||
/// Detected at read time by the get_entity handler grouping
|
||||
@@ -314,6 +320,10 @@ where
|
||||
web::resource("/facts/{id}/supersede")
|
||||
.route(web::post().to(supersede_fact::<D>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/facts/{id}/restore")
|
||||
.route(web::post().to(restore_fact::<D>)),
|
||||
)
|
||||
.service(web::resource("/recent").route(web::get().to(get_recent::<D>))),
|
||||
)
|
||||
}
|
||||
@@ -438,6 +448,9 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
superseded_by: f.superseded_by,
|
||||
created_by_model: f.created_by_model,
|
||||
created_by_backend: f.created_by_backend,
|
||||
last_modified_by_model: f.last_modified_by_model,
|
||||
last_modified_by_backend: f.last_modified_by_backend,
|
||||
last_modified_at: f.last_modified_at,
|
||||
in_conflict: false,
|
||||
});
|
||||
}
|
||||
@@ -779,6 +792,9 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
// from agent-generated ones in the audit view.
|
||||
created_by_model: None,
|
||||
created_by_backend: Some("manual".to_string()),
|
||||
last_modified_by_model: None,
|
||||
last_modified_by_backend: None,
|
||||
last_modified_at: None,
|
||||
};
|
||||
|
||||
match dao.upsert_fact(&cx, insert) {
|
||||
@@ -815,7 +831,10 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
|
||||
};
|
||||
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.update_fact(&cx, fact_id, patch) {
|
||||
// Manual PATCH from the curation UI — provenance stamped as
|
||||
// "manual" so the audit feed can distinguish human edits from
|
||||
// agent corrections.
|
||||
match dao.update_fact(&cx, fact_id, patch, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
|
||||
Err(e) => {
|
||||
@@ -855,7 +874,9 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
||||
.json(serde_json::json!({"error": "old_id and by_fact_id must differ"}));
|
||||
}
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.supersede_fact(&cx, old_id, body.by_fact_id) {
|
||||
// Manual supersede from the curation UI — same stamping rule as
|
||||
// the PATCH path.
|
||||
match dao.supersede_fact(&cx, old_id, body.by_fact_id, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Old or new fact not found"})),
|
||||
@@ -866,6 +887,25 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_fact<D: KnowledgeDao + 'static>(
|
||||
_claims: Claims,
|
||||
id: web::Path<i32>,
|
||||
dao: web::Data<Mutex<D>>,
|
||||
) -> impl Responder {
|
||||
let cx = opentelemetry::Context::current();
|
||||
let fact_id = id.into_inner();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.revert_supersession(&cx, fact_id, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Fact not found"})),
|
||||
Err(e) => {
|
||||
log::error!("restore_fact error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_recent<D: KnowledgeDao + 'static>(
|
||||
req: HttpRequest,
|
||||
claims: Claims,
|
||||
|
||||
Reference in New Issue
Block a user