From a9513a4d9d456a9c67391b9c1cc5948f7c49ef7e Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Thu, 12 Nov 2026 07:31:27 +0100 Subject: [PATCH 1/3] feat: let a task name a task definition, and take its checks and its tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paper makes a task definition the record for one kind of work (§8 Matching, §10). A task names one. The definition gives the acceptance checks, and the tools that a claimant can use. The code had no such record. - A task definition is a new kind of artifact in the platform domain: `platform task `, in TOML. It has a purpose, tools, a scope, acceptance checks, outputs and an autonomy level. - A definition is in force when its entry is active and verified. Its version is its place among the verified definitions of that name. - A task has a `definition` and `acceptance`. When a task is filed with a definition, it records `@`. It takes the checks of the definition if it gives none. It requires the tools of the definition. - A task cannot name a definition that is not in force. - A session that holds a task of a definition can call only the tools that the definition declares. - `[gates] require_definition = true` in the grants makes a definition and acceptance checks necessary for admission. It is off by default, because the tasks that the system files for itself name no definition yet. - `create_task` and `amem task create` take the definition and the checks. `amem work` puts them in the prompt. - The ledger keeps the two fields in a new `extra` column, as JSON. - Act 7 of the demo proposes, verifies and uses a definition. Not in this change: the autonomy level has no effect, no suite gates a version of a definition, and the scope is not enforced. Co-Authored-By: Claude Fable 5.2 --- Cargo.lock | 1 + crates/client/src/lib.rs | 1 + crates/ledger/src/lib.rs | 43 +++- crates/ledger/src/postgres.rs | 15 +- crates/ledger/src/sqlite.rs | 18 +- crates/memory/src/auditor.rs | 2 + crates/memory/src/canary.rs | 1 + crates/memory/src/curation.rs | 1 + crates/memory/src/curator.rs | 1 + crates/memory/src/drift.rs | 1 + crates/memory/src/gofannon.rs | 1 + crates/memory/src/main.rs | 12 + crates/memory/src/work.rs | 22 ++ crates/policy/src/lib.rs | 11 + crates/server/Cargo.toml | 1 + crates/server/src/lib.rs | 28 ++- crates/server/src/mcp.rs | 21 +- crates/server/src/platform.rs | 349 ++++++++++++++++++++++++++- crates/server/src/sources.rs | 1 + crates/server/src/taps.rs | 4 + crates/taps/src/lib.rs | 1 + crates/taps/tests/github_comments.rs | 1 + crates/web/src/lib.rs | 3 + docs/demo-narrative.md | 10 +- docs/terms.md | 1 + local/README.md | 47 ++++ scripts/demo/7-platform.sh | 17 ++ 27 files changed, 596 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2eeaf966..7c26053f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,6 +1046,7 @@ dependencies = [ "thiserror 2.0.21", "time", "tokio", + "toml", ] [[package]] diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e80a08b3..6494ac2b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -537,6 +537,7 @@ mod tests { created_by: "human:test:me".into(), priority: 0, classification: Default::default(), + ..dm_ledger::TaskSpec::default() } } diff --git a/crates/ledger/src/lib.rs b/crates/ledger/src/lib.rs index 5b2e4d14..5ffa8da8 100644 --- a/crates/ledger/src/lib.rs +++ b/crates/ledger/src/lib.rs @@ -155,7 +155,7 @@ impl Status { } /// What a caller files. Everything else on a [`Task`] the ledger fills in. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskSpec { /// The domain whose ledger partition holds it. pub domain: String, @@ -183,6 +183,33 @@ pub struct TaskSpec { /// Higher is claimed first. #[serde(default)] pub priority: i64, + /// The task definition this task runs under, as `@`: the record in + /// the platform's domain for its kind of work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition: Option, + /// The checks that say this task is done, its own or those its definition gave it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acceptance: Vec, +} + +/// The fields of a task that the ledger keeps and never queries by, as one JSON column. +#[derive(Default, Serialize, Deserialize)] +struct Extra { + #[serde(default, skip_serializing_if = "Option::is_none")] + definition: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + acceptance: Vec, +} + +impl TaskSpec { + /// The `extra` column of this spec's row. + pub(crate) fn extra(&self) -> String { + let extra = Extra { + definition: self.definition.clone(), + acceptance: self.acceptance.clone(), + }; + serde_json::to_string(&extra).unwrap_or_default() + } } /// A task as the ledger holds it. @@ -589,7 +616,7 @@ impl RawPromotion { const TASK_COLUMNS: &str = "id, domain, title, body, requirements, depends_on, origin, upstream_event, created_by, label_rank, priority, created, status, holder, lease_until, attempts, handoff, - reason, superseded_by, posted_to"; + reason, superseded_by, posted_to, extra"; /// A task row as the database returns it, before the ledger has checked it. struct RawTask { @@ -613,6 +640,7 @@ struct RawTask { reason: Option, superseded_by: Option, posted_to: Option, + extra: Option, } impl RawTask { @@ -626,6 +654,14 @@ impl RawTask { }; let requirements = json_list(&self.requirements, "requirements")?; let depends_on = json_list(&self.depends_on, "depends_on")?; + // a row from before the column has none, and reads as a task with nothing more + let extra: Extra = match self.extra.as_deref() { + None | Some("") => Extra::default(), + Some(text) => serde_json::from_str(text).map_err(|_| Error::BadRow { + id: id.clone(), + what: format!("extra {text:?}"), + })?, + }; Ok(Task { spec: TaskSpec { domain: self.domain, @@ -638,6 +674,8 @@ impl RawTask { created_by: self.created_by, classification: label_from_rank(&id, self.label_rank)?, priority: self.priority, + definition: extra.definition, + acceptance: extra.acceptance, }, created: parse_time(&id, &self.created)?, status: Status::parse(&id, &self.status)?, @@ -1786,6 +1824,7 @@ mod tests { created_by: "human:test:me".into(), classification: Label::Public, priority: 0, + ..TaskSpec::default() } } diff --git a/crates/ledger/src/postgres.rs b/crates/ledger/src/postgres.rs index bf65e7d1..7380629f 100644 --- a/crates/ledger/src/postgres.rs +++ b/crates/ledger/src/postgres.rs @@ -70,7 +70,8 @@ CREATE TABLE IF NOT EXISTS {s}.tasks ( handoff TEXT, reason TEXT, superseded_by TEXT, - posted_to TEXT + posted_to TEXT, + extra TEXT ); CREATE INDEX IF NOT EXISTS tasks_queue ON {s}.tasks (status, priority DESC, created); CREATE UNIQUE INDEX IF NOT EXISTS tasks_origin ON {s}.tasks (origin, upstream_event) @@ -176,6 +177,7 @@ fn task_from_row(row: &Row) -> Result { reason: row.try_get(17)?, superseded_by: row.try_get(18)?, posted_to: row.try_get(19)?, + extra: row.try_get(20)?, } .into_task() } @@ -213,6 +215,12 @@ impl Db { /// stands, such a task would be in no list and no queue, without a word. async fn rebuild_stale_projections(&mut self) -> Result<(), Error> { for (domain, s) in self.schemas().await? { + // a schema from before a task carried more than the ledger queries by + self.client + .batch_execute(&format!( + "ALTER TABLE {s}.tasks ADD COLUMN IF NOT EXISTS extra TEXT" + )) + .await?; let row = self .client .query_one( @@ -1160,8 +1168,8 @@ async fn insert_task( conn.execute( &format!( "INSERT INTO {s}.tasks (id, domain, title, body, requirements, depends_on, origin, - upstream_event, created_by, label_rank, priority, created, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'proposed')" + upstream_event, created_by, label_rank, priority, created, status, extra) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'proposed', $13)" ), &[ &id, @@ -1176,6 +1184,7 @@ async fn insert_task( &(spec.classification as i64), &spec.priority, &crate::fmt_time(at), + &spec.extra(), ], ) .await?; diff --git a/crates/ledger/src/sqlite.rs b/crates/ledger/src/sqlite.rs index 8684dd0c..a318b31d 100644 --- a/crates/ledger/src/sqlite.rs +++ b/crates/ledger/src/sqlite.rs @@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS tasks ( handoff TEXT, reason TEXT, superseded_by TEXT, - posted_to TEXT + posted_to TEXT, + extra TEXT ); CREATE INDEX IF NOT EXISTS tasks_queue ON tasks (domain, status, priority DESC, created); DROP INDEX IF EXISTS tasks_origin; @@ -88,6 +89,7 @@ fn task_from_row(row: &Row<'_>) -> Result { reason: row.get(17)?, superseded_by: row.get(18)?, posted_to: row.get(19)?, + extra: row.get(20)?, } .into_task() } @@ -118,6 +120,15 @@ impl Db { if has_posted == 0 { conn.execute_batch("ALTER TABLE tasks ADD COLUMN posted_to TEXT;")?; } + // and one from before a task carried more than the ledger queries by + let has_extra: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name = 'extra'", + [], + |r| r.get(0), + )?; + if has_extra == 0 { + conn.execute_batch("ALTER TABLE tasks ADD COLUMN extra TEXT;")?; + } migrate_promotions(&conn)?; // a projection whose state names this code does not have, as a ledger from before // the states were `open` and `done`: read as it stands, its tasks would be in no @@ -755,8 +766,8 @@ fn insert_task( let depends_on = serde_json::to_string(&spec.depends_on).unwrap_or_default(); conn.execute( "INSERT INTO tasks (id, domain, title, body, requirements, depends_on, origin, - upstream_event, created_by, label_rank, priority, created, status) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'proposed')", + upstream_event, created_by, label_rank, priority, created, status, extra) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'proposed', ?13)", params![ id, spec.domain, @@ -770,6 +781,7 @@ fn insert_task( spec.classification as i64, spec.priority, crate::fmt_time(at), + spec.extra(), ], )?; Ok(()) diff --git a/crates/memory/src/auditor.rs b/crates/memory/src/auditor.rs index 150b387b..2da8bb03 100644 --- a/crates/memory/src/auditor.rs +++ b/crates/memory/src/auditor.rs @@ -282,6 +282,7 @@ pub async fn file_contradictions( created_by: String::new(), classification: dm_core::Label::Public, priority: 1, + ..dm_ledger::TaskSpec::default() }) .await?; if !created.deduplicated { @@ -326,6 +327,7 @@ pub async fn file( created_by: String::new(), classification: dm_core::Label::Public, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await?; if !created.deduplicated { diff --git a/crates/memory/src/canary.rs b/crates/memory/src/canary.rs index c1aa3206..9ba9b427 100644 --- a/crates/memory/src/canary.rs +++ b/crates/memory/src/canary.rs @@ -169,6 +169,7 @@ pub async fn file( created_by: String::new(), classification: dm_core::Label::Public, priority: 10, + ..dm_ledger::TaskSpec::default() }) .await?; if !created.deduplicated { diff --git a/crates/memory/src/curation.rs b/crates/memory/src/curation.rs index d95a8a6e..ebd5fa64 100644 --- a/crates/memory/src/curation.rs +++ b/crates/memory/src/curation.rs @@ -299,6 +299,7 @@ pub async fn file_alarms( created_by: String::new(), classification: dm_core::Label::Public, priority: 5, + ..dm_ledger::TaskSpec::default() }) .await?; if !created.deduplicated { diff --git a/crates/memory/src/curator.rs b/crates/memory/src/curator.rs index f5060305..9b7044e3 100644 --- a/crates/memory/src/curator.rs +++ b/crates/memory/src/curator.rs @@ -443,6 +443,7 @@ pub async fn file( created_by: String::new(), classification: dm_core::Label::Public, priority: -1, + ..dm_ledger::TaskSpec::default() }) .await?; if created.deduplicated { diff --git a/crates/memory/src/drift.rs b/crates/memory/src/drift.rs index e5739daf..203c41b0 100644 --- a/crates/memory/src/drift.rs +++ b/crates/memory/src/drift.rs @@ -1015,6 +1015,7 @@ pub async fn file( created_by: String::new(), classification: dm_core::Label::Public, priority: 5, + ..dm_ledger::TaskSpec::default() }) .await?; Ok((!created.deduplicated).then_some(created.task.id)) diff --git a/crates/memory/src/gofannon.rs b/crates/memory/src/gofannon.rs index b91732d6..d1d81fa2 100644 --- a/crates/memory/src/gofannon.rs +++ b/crates/memory/src/gofannon.rs @@ -340,6 +340,7 @@ mod tests { created_by: String::new(), classification: dm_core::Label::Public, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() diff --git a/crates/memory/src/main.rs b/crates/memory/src/main.rs index 69315c00..0653862e 100644 --- a/crates/memory/src/main.rs +++ b/crates/memory/src/main.rs @@ -1117,6 +1117,13 @@ enum TaskOp { depends_on: Vec, #[arg(long, default_value_t = 0)] priority: i64, + /// The task definition this kind of work runs under, by name. + #[arg(long)] + definition: Option, + /// A check that says this task is done; repeat for more. Without one, the task + /// takes those of its definition. + #[arg(long = "accept")] + acceptance: Vec, }, /// Admit a proposed task. Admit { id: String }, @@ -4715,6 +4722,7 @@ async fn policy_lint(m: &Memory, file: bool) -> Fallible { created_by: String::new(), classification: dm_core::Label::Internal, priority: 4, + ..dm_ledger::TaskSpec::default() }) .await?; if !created.deduplicated { @@ -4909,6 +4917,8 @@ async fn task(m: &Memory, op: TaskOp) -> Fallible { requirements, depends_on, priority, + definition, + acceptance, } => { let c = m .create_task(TaskSpec { @@ -4922,6 +4932,8 @@ async fn task(m: &Memory, op: TaskOp) -> Fallible { created_by: String::new(), classification: Label::Public, priority, + definition, + acceptance, }) .await?; json(&c.task) diff --git a/crates/memory/src/work.rs b/crates/memory/src/work.rs index cc92ca50..2c6a392a 100644 --- a/crates/memory/src/work.rs +++ b/crates/memory/src/work.rs @@ -597,6 +597,27 @@ async fn late_handoffs_of(client: &MemoryClient, task: &str) -> String { p } +/// The task's definition and the checks that say it is done, as a part of the prompt. +fn checks_of(task: &Task) -> String { + let mut out = String::new(); + if let Some(d) = task.raw["definition"].as_str() { + out.push_str(&format!("\nTask definition: {d}\n")); + } + let accept: Vec<&str> = task.raw["acceptance"] + .as_array() + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect(); + if !accept.is_empty() { + out.push_str("\n# Done when\n\n"); + for check in accept { + out.push_str(&format!("- {check}\n")); + } + } + out +} + async fn prompt_for( client: &MemoryClient, task: &Task, @@ -610,6 +631,7 @@ async fn prompt_for( task.title, task.body.trim() )); + p.push_str(&checks_of(task)); if !task.requirements.is_empty() { p.push_str(&format!("\nRequires: {}\n", task.requirements.join(", "))); } diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 84c09f3c..03b8f8ad 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -80,6 +80,16 @@ pub struct Gates { /// Whether replacing a verified entry needs an approver who is not the proposer. #[serde(default = "default_true")] pub verified_needs_approver: bool, + /// Whether a task is admitted only when it names a task definition in force and has + /// acceptance criteria. Off unless the owner turns it on: the tasks the system files + /// for itself name no definition yet. + #[serde(default, skip_serializing_if = "is_false")] + pub require_definition: bool, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde passes a reference +fn is_false(b: &bool) -> bool { + !*b } fn default_max_supersedes() -> usize { @@ -95,6 +105,7 @@ impl Default for Gates { Gates { max_supersedes_per_session: default_max_supersedes(), verified_needs_approver: default_true(), + require_definition: false, } } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 9082aec6..baac772e 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,6 +15,7 @@ dm-store = { version = "0.1.0", path = "../store" } rmcp = { version = "3.5.0", features = ["server", "transport-io", "macros"] } serde = { workspace = true, features = ["derive"] } serde_json = "1.0.151" +toml = "1.1.6" thiserror.workspace = true time = { version = "0.3.55", features = ["formatting", "serde-well-known", "macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "io-std", "fs"] } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 69653088..90174d23 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1273,6 +1273,7 @@ impl Memory { created_by: String::new(), classification: entry.front.classification, priority: 3, + ..TaskSpec::default() }, Filing::Own, None, @@ -1757,6 +1758,7 @@ impl Memory { created_by: String::new(), classification: entry.front.classification, priority: 5, + ..TaskSpec::default() }) .await; match filed { @@ -2090,7 +2092,8 @@ impl Memory { filing: Filing, parent: Option<&Task>, ) -> Result { - let spec = self.stamp(spec, filing, parent)?; + let mut spec = self.stamp(spec, filing, parent)?; + self.bind_definition(&mut spec).await?; let created = l.create(spec).await?; // a filing that names a task already there is handed that task, and admits it on // its say, only when this session may see it; the answer names nothing of it. @@ -2142,7 +2145,11 @@ impl Memory { /// A ledger error, or a task outside the grants. pub async fn admit(&self, id: &str) -> Result { let mut l = self.ledger.lock().await; - self.check_task(&l.get(id).await?)?; + let task = l.get(id).await?; + self.check_task(&task)?; + if let Some(gap) = self.admission_gap(&task.spec) { + return Err(Error::Denied(format!("{id} is not admitted: {gap}"))); + } let admitted = l.admit(id).await?; self.rank_edges(&mut l, &admitted).await?; self.observe_task(&admitted); @@ -2189,7 +2196,8 @@ impl Memory { filing: Filing, parent: Option<&Task>, ) -> Result { - if task.status != dm_ledger::Status::Proposed { + // a task nobody can check stays proposed, whatever the policy says of its filer + if task.status != dm_ledger::Status::Proposed || self.admission_gap(&task.spec).is_some() { return Ok(task); } let d = self.decide(&Input { @@ -2264,13 +2272,12 @@ impl Memory { domain: parent.spec.domain.clone(), title: title.clone(), body: body.clone(), - requirements: Vec::new(), - depends_on: Vec::new(), origin: Some(format!("task:{}", parent.id)), upstream_event: Some(hash), created_by: self.author.clone(), classification: parent.spec.classification.max(self.label()), priority: parent.spec.priority, + ..TaskSpec::default() }) .await?; // the ledger dedups on the parent and the hash whatever the task's status: a @@ -2403,6 +2410,7 @@ impl Memory { created_by: self.author.clone(), classification: parent.spec.classification.max(self.label()), priority: parent.spec.priority, + ..TaskSpec::default() }) .await?; let continuation = self @@ -2561,6 +2569,7 @@ impl Memory { created_by: self.author.clone(), classification, priority: root.spec.priority, + ..TaskSpec::default() }) .await?; let retry = self @@ -3562,6 +3571,7 @@ mod tests { created_by: String::new(), classification: Label::Public, priority: 0, + ..TaskSpec::default() }; let parent = owner.file_own(spec(&["github"])).await.unwrap().task; assert_eq!(parent.status, Status::Open, "the system's own, admitted"); @@ -4292,6 +4302,7 @@ mod tests { created_by: String::new(), classification: Label::Internal, priority: 3, + ..TaskSpec::default() }; let refused = curator(dir.path()).create_task(squat.clone()).await; assert!( @@ -4890,6 +4901,7 @@ mod tests { created_by: String::new(), classification: Label::Internal, priority: 3, + ..TaskSpec::default() }) .await .unwrap() @@ -5911,6 +5923,7 @@ mod tests { created_by: String::new(), classification: Label::Internal, priority: 3, + ..TaskSpec::default() }) .await; assert!(forged.is_err(), "{forged:?}"); @@ -5930,6 +5943,7 @@ mod tests { created_by: "tap:issues".into(), classification: Label::Internal, priority: 3, + ..TaskSpec::default() } } @@ -6129,6 +6143,7 @@ mod tests { created_by: String::new(), classification: Label::Public, priority: -1, + ..TaskSpec::default() }) .await .unwrap() @@ -6266,6 +6281,7 @@ mod tests { created_by: String::new(), classification: Label::Public, priority: 0, + ..TaskSpec::default() }) .await .unwrap() @@ -6333,6 +6349,7 @@ mod tests { created_by: String::new(), classification: Label::Public, priority, + ..TaskSpec::default() } } @@ -6788,6 +6805,7 @@ mod tests { created_by: String::new(), classification: Label::Public, priority: 0, + ..TaskSpec::default() } } diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index c3fcfe2a..bfdb7e38 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -285,6 +285,13 @@ pub struct CreateTaskParams { /// Higher is claimed first. Default 0. #[serde(default)] pub priority: Option, + /// The task definition this kind of work runs under, by name. The task takes the + /// definition's acceptance criteria and requires the tools it declares. + #[serde(default)] + pub definition: Option, + /// Checks that say this task is done, in place of its definition's. + #[serde(default)] + pub acceptance: Vec, } /// Parameters that name a task. @@ -784,9 +791,19 @@ impl McpServer { created_by: String::new(), classification: dm_core::Label::Public, priority: p.priority.unwrap_or(0), + definition: p.definition, + acceptance: p.acceptance, }; let c = self.memory.create_task(spec).await.map_err(mcp_err)?; - ok(json!({ "task": c.task, "deduplicated": c.deduplicated })) + // what a task that stays proposed lacks goes back to whoever filed it + let gap = (c.task.status == dm_ledger::Status::Proposed) + .then(|| self.memory.admission_gap(&c.task.spec)) + .flatten(); + ok(json!({ + "task": c.task, + "deduplicated": c.deduplicated, + "not_admitted_because": gap, + })) } #[tool( @@ -1551,6 +1568,7 @@ mod tests { created_by: String::new(), classification, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() @@ -1612,6 +1630,7 @@ mod tests { created_by: String::new(), classification: dm_core::Label::Public, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() diff --git a/crates/server/src/platform.rs b/crates/server/src/platform.rs index 353716d5..789c36ab 100644 --- a/crates/server/src/platform.rs +++ b/crates/server/src/platform.rs @@ -10,7 +10,8 @@ //! ``` //! ```` //! -//! The info string is `platform `. Four kinds are read today: an `agent` +//! The info string is `platform `. Five kinds are read today: a `task` +//! definition, TOML, the record for one kind of work ([`TaskDefinition`]); an `agent` //! definition, instructions added to a harness's work prompt (named for the harness's //! binary, or `default`); a `template`, the opening of the work prompt (`work`); an //! `eval` suite, a golden suite's TOML; and a `harness` configuration, JSON, of which @@ -24,7 +25,71 @@ use dm_core::{Entry, TrustTier}; use serde::Serialize; /// The kinds of artifact the launcher reads. -pub const KINDS: &[&str] = &["agent", "template", "eval", "harness"]; +pub const KINDS: &[&str] = &["agent", "template", "eval", "harness", "task"]; + +/// What a completion of a task needs before it counts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Autonomy { + /// A person's verdict. Where every definition starts. + #[default] + Person, + /// A review by a session on another model than the author's. + Review, + /// The acceptance checks alone. + Checks, +} + +/// A task definition: the record for one kind of work. A task names one, and what a +/// session that claims the task may use comes from it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TaskDefinition { + /// Its name, from the block's info string. + #[serde(skip_deserializing)] + pub name: String, + /// Which verified definition of that name this is, counted from one. + #[serde(skip_deserializing)] + pub version: u32, + /// The entry it is in. + #[serde(skip_deserializing)] + pub entry: String, + /// What the work is for, in a sentence: review reads the tools against it. + #[serde(default)] + pub purpose: String, + /// The capabilities the work needs, as a task's requirements name them + /// (`tool:fetch`). A session that holds a task of this definition calls no tool + /// outside them. + #[serde(default)] + pub tools: Vec, + /// The domains the work reads. + #[serde(default)] + pub scope: Vec, + /// The checks that say a task of this kind is done. A task that gives none of its + /// own takes these. + #[serde(default)] + pub acceptance: Vec, + /// What the work leaves behind. + #[serde(default)] + pub outputs: Vec, + /// What a completion needs before it counts. + #[serde(default)] + pub autonomy: Autonomy, +} + +impl TaskDefinition { + /// How a task names this definition: `@`. + #[must_use] + pub fn reference(&self) -> String { + format!("{}@{}", self.name, self.version) + } +} + +/// The name in a task's `definition`, with or without its `@`. +#[must_use] +pub fn definition_name(reference: &str) -> &str { + reference.split('@').next().unwrap_or(reference) +} /// One artifact, as an entry holds it. #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -190,6 +255,121 @@ impl Memory { } Ok(found) } + + /// The task definition named `name` that is in force: the newest in an active entry + /// a person verified. Its version is its place among the verified definitions of + /// that name, replaced ones counted, oldest first. + /// + /// # Errors + /// + /// A store error, or [`Error::Denied`] when the block in force is not a definition + /// this code reads, with what is wrong in it. + pub async fn task_definition(&self, name: &str) -> Result, Error> { + let mut verified: Vec<(Artifact, bool)> = Vec::new(); + for e in self.platform_entries().await? { + if e.front.trust_tier != TrustTier::Verified { + continue; + } + for a in artifacts_in(&e) { + if a.kind == "task" && a.name == name { + verified.push((a, e.is_active())); + } + } + } + verified.sort_by(|a, b| a.0.created.cmp(&b.0.created)); + let Some(at) = verified.iter().rposition(|(_, active)| *active) else { + return Ok(None); + }; + let artifact = &verified[at].0; + let mut def: TaskDefinition = toml::from_str(&artifact.text).map_err(|e| { + Error::Denied(format!( + "the task definition {name} in {} does not read: {e}", + artifact.entry + )) + })?; + def.name = name.to_string(); + def.version = u32::try_from(at + 1).unwrap_or(u32::MAX); + def.entry.clone_from(&artifact.entry); + Ok(Some(def)) + } + + /// Bind a task to the definition it names: the name must be in force, the task then + /// names it at its version, takes its acceptance criteria if it gave none, and + /// requires what the definition declares, so only a session that has those claims it. + /// + /// # Errors + /// + /// [`Error::Denied`] when no definition of that name is in force. + pub(crate) async fn bind_definition( + &self, + spec: &mut dm_ledger::TaskSpec, + ) -> Result<(), Error> { + let Some(named) = spec.definition.clone() else { + return Ok(()); + }; + let name = definition_name(&named); + let Some(def) = self.task_definition(name).await? else { + return Err(Error::Denied(format!( + "no task definition {name} is in force: a verified entry of the platform's domain defines one" + ))); + }; + spec.definition = Some(def.reference()); + if spec.acceptance.is_empty() { + spec.acceptance.clone_from(&def.acceptance); + } + for tool in &def.tools { + if !spec.requirements.contains(tool) { + spec.requirements.push(tool.clone()); + } + } + Ok(()) + } + + /// Why triage does not admit a task, when what it lacks is a definition or a way to + /// check it: the missing items, for whoever filed it. None when nothing is missing. + #[must_use] + pub fn admission_gap(&self, spec: &dm_ledger::TaskSpec) -> Option { + if spec.definition.is_none() { + return self + .grants + .gates + .require_definition + .then(|| "it names no task definition".to_string()); + } + spec.acceptance + .is_empty() + .then(|| "it has no acceptance criteria, its own or its definition's".to_string()) + } + + /// Whether the task this session holds lets it call `tool`: a task under a + /// definition reaches only the tools the definition declares. + /// + /// # Errors + /// + /// [`Error::Denied`] when the held task's definition does not declare the tool. + pub(crate) async fn definition_allows(&self, tool: &str) -> Result<(), Error> { + let Some(held) = self.held.get() else { + return Ok(()); + }; + let Ok(task) = self.ledger.lock().await.get(held).await else { + return Ok(()); + }; + let Some(named) = task.spec.definition else { + return Ok(()); + }; + let name = definition_name(&named); + let declared = self + .task_definition(name) + .await? + .is_some_and(|d| d.tools.iter().any(|t| t == &format!("tool:{tool}"))); + if declared { + Ok(()) + } else { + Err(Error::Denied(format!( + "the task definition {named} does not declare tool:{tool}; a task reaches only the tools its definition declares" + ))) + } + } } #[cfg(test)] @@ -312,4 +492,169 @@ mod tests { let back = dm_policy::Grants::parse(&g.render().unwrap()).unwrap(); assert_eq!(back.platform.as_deref(), Some("dom-me")); } + const REVIEW: &str = "# Review a change\n\n```platform task review-change\npurpose = \"Read a change against the decisions it touches.\"\ntools = [\"tool:fetch\"]\nscope = [\"dom-me\"]\nacceptance = [\"The handoff names each decision the change touches.\"]\noutputs = [\"handoff\"]\n```\n"; + + fn task(title: &str, definition: Option<&str>) -> dm_ledger::TaskSpec { + dm_ledger::TaskSpec { + domain: "dom-me".into(), + title: title.into(), + body: "Do it.".into(), + definition: definition.map(str::to_string), + ..dm_ledger::TaskSpec::default() + } + } + + #[tokio::test] + async fn a_task_definition_is_in_force_once_verified_and_counts_its_versions() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + written(&m, REVIEW, false).await; + assert_eq!(m.task_definition("review-change").await.unwrap(), None); + let first = written(&m, REVIEW, true).await; + let def = m.task_definition("review-change").await.unwrap().unwrap(); + assert_eq!( + (def.reference().as_str(), def.entry.as_str()), + ("review-change@1", first.as_str()) + ); + assert_eq!(def.tools, ["tool:fetch"]); + assert_eq!( + def.autonomy, + Autonomy::Person, + "where every definition starts" + ); + // a verified replacement is the next version; the one it replaced still counts + let wider = REVIEW.replace("[\"tool:fetch\"]", "[\"tool:fetch\", \"tool:search\"]"); + let second = m + .write(WriteRequest { + body: wider, + domain: Some("dom-plat".into()), + entry_type: dm_core::EntryType::Convention, + supersedes: Some(first), + ..WriteRequest::default() + }) + .await + .unwrap() + .id; + // the owner replaced the entry, as only the owner replaces a verified one unheld, + // and nothing is in force until the replacement is verified in its turn + assert_eq!(m.task_definition("review-change").await.unwrap(), None); + let mut e = m.store.get("dom-plat", &second).await.unwrap().unwrap(); + e.front.trust_tier = TrustTier::Verified; + m.store.put(&e).await.unwrap(); + let def = m.task_definition("review-change").await.unwrap().unwrap(); + assert_eq!(def.reference(), "review-change@2"); + assert_eq!(def.tools, ["tool:fetch", "tool:search"]); + // a block that is no definition is refused in words, not read as an empty one + written(&m, "```platform task broken\ntool = \"x\"\n```\n", true).await; + let broken = m.task_definition("broken").await; + assert!( + matches!(&broken, Err(Error::Denied(why)) if why.contains("broken")), + "{broken:?}" + ); + } + + #[tokio::test] + async fn a_task_takes_its_definition_s_version_checks_and_tools() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + written(&m, REVIEW, true).await; + let filed = m + .create_task(task("Review the importer", Some("review-change"))) + .await + .unwrap() + .task; + assert_eq!(filed.spec.definition.as_deref(), Some("review-change@1")); + assert_eq!( + filed.spec.acceptance, + ["The handoff names each decision the change touches."] + ); + assert_eq!(filed.spec.requirements, ["tool:fetch"]); + // its own checks stand in place of the definition's + let mut own = task("Review the exporter", Some("review-change@1")); + own.acceptance = vec!["The handoff lists the files read.".into()]; + let own = m.create_task(own).await.unwrap().task; + assert_eq!(own.spec.acceptance, ["The handoff lists the files read."]); + // a name nothing verified defines is refused, and nothing is filed + let unknown = m.create_task(task("Deploy", Some("deploy"))).await; + assert!(matches!(&unknown, Err(Error::Denied(why)) if why.contains("deploy"))); + let l = m.ledger.lock().await; + let all = l + .list("dom-me", dm_ledger::Status::Proposed) + .await + .unwrap() + .len() + + l.list("dom-me", dm_ledger::Status::Open) + .await + .unwrap() + .len(); + assert_eq!(all, 2); + } + + #[tokio::test] + async fn where_the_owner_asks_for_it_a_task_without_a_definition_stays_proposed() { + let dir = tempfile::tempdir().unwrap(); + root(dir.path()); + let path = dir.path().join(crate::GRANTS_FILE); + let mut g = dm_policy::Grants::load(&path).unwrap(); + g.gates.require_definition = true; + std::fs::write(&path, g.render().unwrap()).unwrap(); + let m = Memory::open(dir.path()).unwrap(); + written(&m, REVIEW, true).await; + let bare = m.file_own(task("Tidy", None)).await.unwrap().task; + assert_eq!(bare.status, dm_ledger::Status::Proposed); + assert_eq!( + m.admission_gap(&bare.spec).as_deref(), + Some("it names no task definition") + ); + let refused = m.admit(&bare.id).await; + assert!(matches!(&refused, Err(Error::Denied(why)) if why.contains("task definition"))); + let defined = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + assert_eq!( + defined.status, + dm_ledger::Status::Open, + "the owner's filing is admitted" + ); + // a definition with no checks gives a task nothing to be checked by + written( + &m, + "```platform task loose\npurpose = \"Anything.\"\n```\n", + true, + ) + .await; + let loose = m.file_own(task("Loose", Some("loose"))).await.unwrap().task; + assert_eq!(loose.status, dm_ledger::Status::Proposed); + assert!(m.admission_gap(&loose.spec).unwrap().contains("acceptance")); + } + + #[tokio::test] + async fn a_session_that_holds_a_defined_task_reaches_only_the_tools_it_declares() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + written(&m, REVIEW, true).await; + let defined = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + let bare = m.file_own(task("Tidy", None)).await.unwrap().task; + let holding = |id: &str| { + let session = Memory::open(dir.path()).unwrap(); + session.set_held_task(id); + session + }; + let on_defined = holding(&defined.id); + on_defined.definition_allows("fetch").await.unwrap(); + let refused = on_defined.definition_allows("search").await; + assert!( + matches!(&refused, Err(Error::Denied(why)) if why.contains("tool:search") && why.contains("review-change@1")), + "{refused:?}" + ); + // a task under no definition is held as before, and so is no task at all + holding(&bare.id).definition_allows("search").await.unwrap(); + m.definition_allows("search").await.unwrap(); + } } diff --git a/crates/server/src/sources.rs b/crates/server/src/sources.rs index ab9924a2..dc0c0d0d 100644 --- a/crates/server/src/sources.rs +++ b/crates/server/src/sources.rs @@ -218,6 +218,7 @@ impl Memory { /// [`Error::Tool`] when the tool fails or a request it needed was refused, with /// what it said withheld when the scan finds anything in that. pub async fn use_tool(self: &Arc, tool: &str, args: Value) -> Result { + self.definition_allows(tool).await?; let (grant, label) = self.tool_grant(tool)?; let id = source_id(tool, &args); let now = OffsetDateTime::now_utc(); diff --git a/crates/server/src/taps.rs b/crates/server/src/taps.rs index ae836188..948e29a6 100644 --- a/crates/server/src/taps.rs +++ b/crates/server/src/taps.rs @@ -116,6 +116,7 @@ mod tests { created_by: String::new(), classification: label, priority: 0, + ..TaskSpec::default() }) .await .unwrap() @@ -227,6 +228,7 @@ mod tests { created_by: String::new(), classification: Label::Internal, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() @@ -328,6 +330,7 @@ mod tests { created_by: String::new(), classification: Label::Restricted, priority: 0, + ..TaskSpec::default() }) .await .unwrap() @@ -399,6 +402,7 @@ mod tests { created_by: String::new(), classification: Label::Internal, priority: 0, + ..TaskSpec::default() }) .await .unwrap() diff --git a/crates/taps/src/lib.rs b/crates/taps/src/lib.rs index dc51c5ed..85ce243a 100644 --- a/crates/taps/src/lib.rs +++ b/crates/taps/src/lib.rs @@ -216,6 +216,7 @@ impl Host for State { .get(&self.domain) .map_or(dm_core::Label::Public, |d| d.default_classification), priority: t.priority, + ..dm_ledger::TaskSpec::default() }; Ok(match self.memory.create_task(spec).await { Ok(c) => Ok(Filed { diff --git a/crates/taps/tests/github_comments.rs b/crates/taps/tests/github_comments.rs index 6ed420b4..17d1790c 100644 --- a/crates/taps/tests/github_comments.rs +++ b/crates/taps/tests/github_comments.rs @@ -125,6 +125,7 @@ async fn done(root: &Path, origin: Option<&str>, label: Label, text: &str) -> St created_by: String::new(), classification: label, priority: 0, + ..TaskSpec::default() }) .await .unwrap() diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index ad988d59..6f5aa7c4 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -980,6 +980,7 @@ async fn file_task(State(app): State, Json(req): Json) -> Respons created_by: String::new(), classification: dm_core::Label::Public, priority: req.priority.unwrap_or(0), + ..dm_ledger::TaskSpec::default() }; match m.create_task(spec).await { Ok(c) => Json(json!({ "task": c.task, "deduplicated": c.deduplicated })).into_response(), @@ -2010,6 +2011,7 @@ mod tests { created_by: String::new(), classification, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() @@ -2076,6 +2078,7 @@ mod tests { created_by: String::new(), classification: dm_core::Label::Restricted, priority: 0, + ..dm_ledger::TaskSpec::default() }) .await .unwrap() diff --git a/docs/demo-narrative.md b/docs/demo-narrative.md index aa0f7143..c4411941 100644 --- a/docs/demo-narrative.md +++ b/docs/demo-narrative.md @@ -331,12 +331,18 @@ includes the definition. Its handoff ends with the definition's line. That is ho can tell that Codex followed the definition. If a model does not follow it, the step says so. -**7.5 The suite.** The recall suite comes from the platform, not from a file. All three +**7.5 A task definition.** A kind of work is also a record in the platform domain. It +gives the purpose of the work, the tools that the work can use, and the checks that say +when a task is done. `agent:architect` proposes one. A task that names it is refused, +because nobody verified it. After you approve it, the same task is filed. The task shows +the definition with its version, and the checks that it took from the definition. + +**7.6 The suite.** The recall suite comes from the platform, not from a file. All three questions find their entry in the top eight, however they are asked. When the question is keywords, the entry comes first every time (mean reciprocal rank 1.00). When the question is as written, the entry ranks lower (0.67). -**7.6 What it cost.** Tokens are one resource. The task took 61 seconds of wall clock and +**7.7 What it cost.** Tokens are one resource. The task took 61 seconds of wall clock and 4.0 seconds of CPU. The CPU time was 2.4 in the sandbox and 1.6 in the launcher, which includes the egress. Its largest process had a peak of 158 MiB. It made 6 model calls, about 223,000 tokens including cached input. It spent 53 of its 61 seconds waiting on diff --git a/docs/terms.md b/docs/terms.md index c5a43f88..2e4ad4e7 100644 --- a/docs/terms.md +++ b/docs/terms.md @@ -62,6 +62,7 @@ term here in the same commit as the first document that needs it. | task state | `proposed`, `open`, `blocked`, `claimed`, `done` or `invalidated`: where a task is on the ledger | admitted (for `open`), completed (for `done`), status (for a state in prose) | | generation | The number of a claim of a task. Each claim gets the next number. A heartbeat, a completion and a release carry it, and the ledger refuses an earlier one | attempt (for a generation), fencing token | | late handoff | A handoff that a holder leaves on a task after its claim is over | | +| task definition | The record in the platform domain for one kind of work: its purpose, its tools, its acceptance checks and its outputs. A task names one as `@` | task type, task template | | quarantine | `state/quarantine`, where the policy holds a write that it refused, for the owner to release or drop | | | owner | The person who holds the root and its grants | user (for the owner), admin | | projection | The read-only copy of the record's entries at `/memory` in the sandbox | | diff --git a/local/README.md b/local/README.md index 9ab382eb..2be66a51 100644 --- a/local/README.md +++ b/local/README.md @@ -610,6 +610,53 @@ invalidation does. Not built yet: an attempt key for each operation, a block on a new claim while an external effect is uncertain, and a `blocked` state for a task that waits on an outside event. +## Task definitions: `platform task` + +A task definition is the record for one kind of work. It is an artifact in the platform +domain, in a fenced block with the info string `platform task `. The block is TOML: + +```toml +purpose = "Answer a question from the record, with the entries it rests on." +tools = ["tool:fetch"] +scope = ["dom-demo"] +acceptance = ["The handoff names each entry the answer rests on."] +outputs = ["handoff"] +autonomy = "person" +``` + +A definition is in force when its entry is active and a person verified it. Its version +is its place among the verified definitions of that name, and the first is version 1. + +A task names a definition with `amem task create --definition `, or with +`definition` in the memory tool `create_task`. Then: + +- The task records the definition with its version, for example `answer-from-record@1`. +- The task takes the acceptance checks of the definition, if it gives none. Give checks + for one task with `--accept`. +- The task requires the tools that the definition declares. Thus only a session that has + those tools can claim it. +- A session that holds the task can call only the tools that the definition declares. + The memory server refuses a different tool. +- `amem work` puts the definition and the checks in the prompt, below "Done when". + +A task cannot name a definition that is not in force. The memory server refuses it, and +files nothing. + +By default, triage also admits a task that names no definition. The tasks that the +system files for itself name none yet. To require a definition, set this in `grants.toml`: + +```toml +[gates] +require_definition = true +``` + +Then a task with no definition, or with no acceptance checks, stays `proposed`. +`create_task` gives the reason in `not_admitted_because`, and `amem task admit` refuses +the task with the same reason. + +Not built yet: the autonomy level has no effect, no suite gates a version of a +definition, and the data scope is not enforced. + ## The task fabric: `amem task rescore` and `stats` An L1 policy entry can carry the rubric that it compiles into, as a `[rubric]` table in diff --git a/scripts/demo/7-platform.sh b/scripts/demo/7-platform.sh index 3886f4f4..b943357d 100644 --- a/scripts/demo/7-platform.sh +++ b/scripts/demo/7-platform.sh @@ -58,6 +58,23 @@ if gateway; then fi fi +say "A task definition" "A kind of work is a record in the platform domain too: what it is for, the tools it may use, and the checks that say it is done. A task names its definition, takes its checks and requires its tools. A definition an agent proposed is not in force until a person verifies it, and a task cannot name one that is not in force." +tdef=$(agent agent:architect write "$(python3 - <<'PY' +import json +block = "```platform task answer-from-record\npurpose = \"Answer a question from the record, with the entries it rests on.\"\ntools = []\nscope = [\"dom-demo\"]\nacceptance = [\"The handoff names each entry the answer rests on.\"]\noutputs = [\"handoff\"]\n```\n" +print(json.dumps({"title": "How a question is answered from the record", "domain": "dom-platform", + "entry_type": "convention", "body": "# Answer from the record\n\n" + block})) +PY +)" | j "d['id']") +echo " proposed by agent:architect, not verified; a task that names it:" +a task create "Which decision set the lease length?" --domain dom-demo --definition answer-from-record 2>&1 | sed 's/^/ /' || true +t=$(agent agent:architect promote "{\"id\": \"$tdef\", \"to\": \"verified\", \"why\": \"the checks say what an answer must show\"}" | j "d['task']['id']") +a approve "$t" >/dev/null +echo " verified by you; the same task now:" +defined=$(a task create "Which decision set the lease length?" --domain dom-demo --definition answer-from-record) +echo "$defined" | j "f\" {d['id']} runs under {d['definition']}; done when: {d['acceptance'][0]}\"" +a task invalidate "$(echo "$defined" | j "d['id']")" --reason "obsolete: shown" >/dev/null + say "The suite" "The recall suite, loaded from the platform rather than a file: each question asked as written, stripped, and by its keywords, scored through the pack with no model." a golden --platform recall 2>/dev/null | sed 's/^/ /' From f64dacf4a159768dc7454a62d34414db4b1a8972 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Thu, 12 Nov 2026 08:13:53 +0100 Subject: [PATCH 2/3] fix: answer the adversarial pass on the task definitions The pass ran on a9513a4 and confirmed seven findings. - A task that a session filed while it held a defined task had no definition. A session that held such a task could call each tool. A task filed under a held task, a subagent task, its continuation and a retry now get the definition and the checks of that task. - A session with low handling could take the checks of a definition that it could not read. The refusal for a block that did not parse quoted the text of the entry. The memory server now reads the entry of the definition as each entry is read. It gives one refusal for a definition that the session cannot read and for one that does not exist. The task gets the classification of the entry. No refusal quotes the text of the block. - The version was a count of verified entries. It changed when an entry left the verified tier, and the tool check used only the name. The version is now the id of the entry. The tool check uses the definition that the task named. When that entry is not in force, the session can call no tool. - The tool check passed when it could not read the held task. It now gives the error. - A definition that declared a tool made a task that `amem work` did not claim, because that session had no manifest. A session now has the tools that the grants give it among its capabilities. A tool that is not `tool:` makes the definition unreadable. - With `require_definition`, the owner could not admit a task that the system filed. The owner can now admit a task with no definition. Nobody can admit a task whose definition is no longer in force. - Four changes to the code left all tests green. Each now has a test: the tool path refuses, the newer of two definitions is in force, `not_admitted_because` has the reason, and a ledger file with no `extra` column gets one. On PostgreSQL, a connect asks the catalog before it adds the column. The first form of that check gave the schema name with its quotes, and never matched. A test of an old domain shape found it. README.md and PLAN.md now say what packages 2 and 3 built, and what is open. Co-Authored-By: Claude Fable 5.2 --- PLAN.md | 9 + README.md | 19 +- crates/ledger/src/lib.rs | 28 +++ crates/ledger/src/postgres.rs | 25 +- crates/server/src/lib.rs | 59 +++-- crates/server/src/mcp.rs | 23 ++ crates/server/src/platform.rs | 450 ++++++++++++++++++++++++++-------- local/README.md | 32 ++- 8 files changed, 499 insertions(+), 146 deletions(-) diff --git a/PLAN.md b/PLAN.md index cf0e71b7..05a58389 100644 --- a/PLAN.md +++ b/PLAN.md @@ -62,6 +62,15 @@ One package is one branch and one pull request. A package that adds a mechanism | 14 | The surface of Appendix A | 5, 7 | Appendix A | `get` by anchor and by version, with `related` and its weights; `search` with filters and snippets; `recall` with `context_tokens` and `answer_kind`; `approve`, `erase`, `export` and `declassify` as memory tools | Act 1 | | 15 | Long timescales | 2, 7 | §7 Keeping strategy in touch with the world; §9 outage; §12; Appendix E Recovery | The identity form `human:,:`; an ownership registry that flags a domain without an owner; personal domains; recovery bundles with a claim epoch, and a restore drill; work from T0 during an outage; a scheduled task that gathers evidence for the horizon scanner, who is a person | Act 10: a restore drill | +Landed so far: + +| # | Pull request | What is still open in the package | +|---|---|---| +| 0 | #69 | Nothing | +| 1 | #71 | Nothing | +| 2 | #73 | An attempt key on every operation; an uncertain external effect blocking reassignment; waiting on an outside event as a state; the record's remaining fields (`parent`, `scores`, `implements`, `reversibility`, `artifacts_touched`, `policy_context`) | +| 3 | the pull request after #73 | A suite that gates a version of a definition; the data scope enforced; a definition required by default, once the system's own filings name one; tools offered a few at boot and the rest by search | + What stays unbuilt after these, and stays on README's list: federation across domains, authentication of people, logprob-based drift, and accelerator time, which only a gateway can count. So do the items on that list that need another machine or an outside service more than they need code: the limits on re-enactment, harness settings and decomposition templates in the platform domain, bytes stored and resource limits on the sandbox, the authoring of agent definitions, the search tool against its live provider, a harness under Seatbelt on a Mac, and the Gofannon bridge against a live deployment, whose agents make model calls that the egress does not meter. ## Toward the production shape diff --git a/README.md b/README.md index a398fd26..2b102984 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,17 @@ two weeks. It is also the reason that none of it is the production shape. - accelerator time, which only the gateway can count - bytes stored - CPU or memory limits on the sandbox -- **The task record, in part.** A claim has no generation. The ledger accepts a caller - when the caller's name is the name of the holder and the lease of the task has not - expired. It checks nothing else, and two sessions of one harness have the same name. A task has no `blocked` state: the ledger computes a block when a session - claims. The ledger does not record a refused claim. An uncertain external effect does - not stop a new claim. -- **Task definitions and autonomy levels.** A task does not name a task definition, and - triage admits a task without one. A session gets its tools from the grants file, not - from a definition. There is no autonomy level, no held-out suite and no evaluator. +- **The task record, in part.** A claim has a generation, and the ledger refuses a + holder that gives an earlier one. A task can be `blocked` on a task that it depends on. + These parts are not built: an attempt key for each operation, a block on a new claim + while an external effect is uncertain, and a `blocked` state for a task that waits on an + outside event. The generation does not prove who the caller is. +- **Task definitions, in part, and autonomy levels.** A task can name a task definition. + It then takes the checks of the definition, and its holder can call only the tools that + the definition declares. Triage requires a definition only when the grants say so, + because the tasks that the system files name none. No suite gates a version of a + definition, and the data scope is not enforced. The autonomy level has no effect, and + there is no held-out suite and no evaluator. - **Positions.** There is no `position` entry, and a task cannot be pinned. - **Approval events.** An approval is the completion of a promotion task. It has no scope and no expiry, and the perimeter does not check it when an action runs. There is diff --git a/crates/ledger/src/lib.rs b/crates/ledger/src/lib.rs index 5ffa8da8..bcd3604e 100644 --- a/crates/ledger/src/lib.rs +++ b/crates/ledger/src/lib.rs @@ -2135,6 +2135,34 @@ mod tests { assert_eq!(l.get(&rests.id).await.unwrap().status, Status::Proposed); } + /// A ledger file from before a task carried a definition: no `extra` column. + #[tokio::test] + async fn on_sqlite_a_ledger_from_before_the_extra_column_is_given_it() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("ledger.db"); + let id = { + let mut l = Ledger::open(&file).unwrap(); + admitted(&mut l, spec("Old")).await.id + }; + rusqlite::Connection::open(&file) + .unwrap() + .execute_batch("ALTER TABLE tasks DROP COLUMN extra;") + .unwrap(); + let mut l = Ledger::open(&file).unwrap(); + assert_eq!(l.get(&id).await.unwrap().spec.definition, None); + let mut defined = spec("New"); + defined.definition = Some("review-change@mem-p-1".into()); + defined.acceptance = vec!["It is checked.".into()]; + let new = l.create(defined.clone()).await.unwrap().task; + assert_eq!(new.spec.definition, defined.definition); + // a rebuild reads the same out of the events + l.rebuild().await.unwrap(); + assert_eq!( + l.get(&new.id).await.unwrap().spec.acceptance, + defined.acceptance + ); + } + /// Every task row's state set to a name this code does not have. async fn make_stale(b: &Site) { match b { diff --git a/crates/ledger/src/postgres.rs b/crates/ledger/src/postgres.rs index 7380629f..57ce8697 100644 --- a/crates/ledger/src/postgres.rs +++ b/crates/ledger/src/postgres.rs @@ -215,12 +215,25 @@ impl Db { /// stands, such a task would be in no list and no queue, without a word. async fn rebuild_stale_projections(&mut self) -> Result<(), Error> { for (domain, s) in self.schemas().await? { - // a schema from before a task carried more than the ledger queries by - self.client - .batch_execute(&format!( - "ALTER TABLE {s}.tasks ADD COLUMN IF NOT EXISTS extra TEXT" - )) - .await?; + // a schema from before a task carried more than the ledger queries by: asked + // of the catalog first, so a connect takes no lock on a table that has it + let has_extra = self + .client + .query_opt( + "SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'tasks' AND column_name = 'extra'", + // the catalog holds the name without the quotes the schema is written in + &[&s.trim_matches('"')], + ) + .await? + .is_some(); + if !has_extra { + self.client + .batch_execute(&format!( + "ALTER TABLE {s}.tasks ADD COLUMN IF NOT EXISTS extra TEXT" + )) + .await?; + } let row = self .client .query_one( diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 16954749..be25562e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2112,6 +2112,14 @@ impl Memory { parent: Option<&Task>, ) -> Result { let mut spec = self.stamp(spec, filing, parent)?; + // what a session files while it holds a task is that task's work: it runs under + // the same definition, and is checked the same way, unless it names its own + if let (Filing::Held, Some(p), None) = (filing, parent, &spec.definition) { + spec.definition.clone_from(&p.spec.definition); + if spec.acceptance.is_empty() { + spec.acceptance.clone_from(&p.spec.acceptance); + } + } self.bind_definition(&mut spec).await?; let created = l.create(spec).await?; // a filing that names a task already there is handed that task, and admits it on @@ -2166,8 +2174,10 @@ impl Memory { let mut l = self.ledger.lock().await; let task = l.get(id).await?; self.check_task(&task)?; - if let Some(gap) = self.admission_gap(&task.spec) { - return Err(Error::Denied(format!("{id} is not admitted: {gap}"))); + // the owner is the triage here, and may admit a task that names no definition: the + // system's own filings name none. Nobody admits one whose definition is gone + if let Some(gone) = self.definition_gone(&task.spec).await? { + return Err(Error::Denied(format!("{id} is not admitted: {gone}"))); } let admitted = l.admit(id).await?; self.rank_edges(&mut l, &admitted).await?; @@ -2216,7 +2226,10 @@ impl Memory { parent: Option<&Task>, ) -> Result { // a task nobody can check stays proposed, whatever the policy says of its filer - if task.status != dm_ledger::Status::Proposed || self.admission_gap(&task.spec).is_some() { + if task.status != dm_ledger::Status::Proposed + || self.admission_gap(&task.spec).is_some() + || self.definition_gone(&task.spec).await?.is_some() + { return Ok(task); } let d = self.decide(&Input { @@ -2246,6 +2259,24 @@ impl Memory { } } + /// The task a fan-out files for one subagent: its parent's work, under the parent's + /// definition and checks, no higher in rank, and named by the hash of what it asks. + fn child_of(&self, parent: &Task, title: &str, body: &str, hash: String) -> TaskSpec { + TaskSpec { + domain: parent.spec.domain.clone(), + title: title.to_string(), + body: body.to_string(), + origin: Some(format!("task:{}", parent.id)), + upstream_event: Some(hash), + created_by: self.author.clone(), + classification: parent.spec.classification.max(self.label()), + priority: parent.spec.priority, + definition: parent.spec.definition.clone(), + acceptance: parent.spec.acceptance.clone(), + ..TaskSpec::default() + } + } + /// Fan a held task out: file the subagent tasks its run asked for, one per item, /// and the continuation that resumes the parent once they are done. A child carries /// `origin: task:` and a hash of its body, so the same spawn asked twice is @@ -2287,17 +2318,7 @@ impl Memory { let mut children = Vec::new(); for (title, body, hash) in asked { let created = l - .create(TaskSpec { - domain: parent.spec.domain.clone(), - title: title.clone(), - body: body.clone(), - origin: Some(format!("task:{}", parent.id)), - upstream_event: Some(hash), - created_by: self.author.clone(), - classification: parent.spec.classification.max(self.label()), - priority: parent.spec.priority, - ..TaskSpec::default() - }) + .create(self.child_of(&parent, &title, &body, hash)) .await?; // the ledger dedups on the parent and the hash whatever the task's status: a // task it hands back is not this call's, and one this session may not read @@ -2429,7 +2450,8 @@ impl Memory { created_by: self.author.clone(), classification: parent.spec.classification.max(self.label()), priority: parent.spec.priority, - ..TaskSpec::default() + definition: parent.spec.definition.clone(), + acceptance: parent.spec.acceptance.clone(), }) .await?; let continuation = self @@ -2588,7 +2610,8 @@ impl Memory { created_by: self.author.clone(), classification, priority: root.spec.priority, - ..TaskSpec::default() + definition: root.spec.definition.clone(), + acceptance: root.spec.acceptance.clone(), }) .await?; let retry = self @@ -2617,7 +2640,7 @@ impl Memory { .ledger .lock() .await - .claim_next(&domain, &self.author, lease, &self.manifest, handling) + .claim_next(&domain, &self.author, lease, &self.capabilities(), handling) .await?; if let Some(c) = &claim { lock(&self.session).observe(c.task.spec.classification); @@ -2637,7 +2660,7 @@ impl Memory { self.check_task(&l.get(id).await?)?; let handling = self.handling()?; let c = l - .claim(id, &self.author, lease, &self.manifest, handling) + .claim(id, &self.author, lease, &self.capabilities(), handling) .await?; lock(&self.session).observe(c.task.spec.classification); lock(&self.claims).insert(c.task.id.clone(), c.generation); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 41d21bab..651f5a63 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -1382,6 +1382,29 @@ mod tests { id } + /// What a task that stays proposed lacks goes back to whoever filed it. + #[tokio::test] + async fn create_task_says_why_a_task_was_not_admitted() { + let dir = tempfile::tempdir().unwrap(); + Memory::init(dir.path(), "human:test:me", "dom-me").unwrap(); + let path = dir.path().join(crate::GRANTS_FILE); + let mut g = dm_policy::Grants::load(&path).unwrap(); + g.gates.require_definition = true; + std::fs::write(&path, g.render().unwrap()).unwrap(); + let s = McpServer::new(Arc::new(Memory::open(dir.path()).unwrap())); + let filed = payload( + &s.create_task(Parameters(CreateTaskParams { + title: "Tidy".into(), + body: "Do it.".into(), + ..CreateTaskParams::default() + })) + .await + .unwrap(), + ); + assert_eq!(filed["task"]["status"], "proposed"); + assert_eq!(filed["not_admitted_because"], "it names no task definition"); + } + #[test] fn write_refuses_a_parameter_it_does_not_have() { let old = diff --git a/crates/server/src/platform.rs b/crates/server/src/platform.rs index 789c36ab..dcd280cf 100644 --- a/crates/server/src/platform.rs +++ b/crates/server/src/platform.rs @@ -48,12 +48,13 @@ pub struct TaskDefinition { /// Its name, from the block's info string. #[serde(skip_deserializing)] pub name: String, - /// Which verified definition of that name this is, counted from one. - #[serde(skip_deserializing)] - pub version: u32, - /// The entry it is in. + /// The entry it is in. The entry's id is the definition's version: an entry is never + /// edited, so a task that names `@` names one text for good. #[serde(skip_deserializing)] pub entry: String, + /// The classification of that entry: a task that takes its checks is no lower. + #[serde(skip_deserializing)] + pub classification: dm_core::Label, /// What the work is for, in a sentence: review reads the tools against it. #[serde(default)] pub purpose: String, @@ -78,14 +79,43 @@ pub struct TaskDefinition { } impl TaskDefinition { - /// How a task names this definition: `@`. + /// How a task names this definition: `@`. #[must_use] pub fn reference(&self) -> String { - format!("{}@{}", self.name, self.version) + format!("{}@{}", self.name, self.entry) } } -/// The name in a task's `definition`, with or without its `@`. +/// The definition an entry holds under `name`, when the entry is active and verified +/// and the block reads as one. +fn definition_in(e: &Entry, name: &str) -> Option> { + if !e.is_active() || e.front.trust_tier != TrustTier::Verified { + return None; + } + let block = artifacts_in(e) + .into_iter() + .find(|a| a.kind == "task" && a.name == name)?; + let parsed = toml::from_str::(&block.text) + .ok() + // a tool is named as a task's requirements name it + .filter(|d| { + d.tools + .iter() + .all(|t| t.starts_with("tool:") && t.len() > 5) + }); + Some( + parsed + .map(|mut d| { + d.name = name.to_string(); + d.entry.clone_from(&e.front.id); + d.classification = e.front.classification; + d + }) + .ok_or(()), + ) +} + +/// The name in a task's `definition`, with or without its `@`. #[must_use] pub fn definition_name(reference: &str) -> &str { reference.split('@').next().unwrap_or(reference) @@ -256,50 +286,62 @@ impl Memory { Ok(found) } - /// The task definition named `name` that is in force: the newest in an active entry - /// a person verified. Its version is its place among the verified definitions of - /// that name, replaced ones counted, oldest first. + /// The task definition named `name` that is in force: the one in the newest active + /// entry a person verified. /// /// # Errors /// - /// A store error, or [`Error::Denied`] when the block in force is not a definition - /// this code reads, with what is wrong in it. + /// A store error, or [`Error::Denied`] when the block in force does not read as a + /// definition. The refusal names the definition and says nothing of its text. pub async fn task_definition(&self, name: &str) -> Result, Error> { - let mut verified: Vec<(Artifact, bool)> = Vec::new(); + let mut newest: Option = None; for e in self.platform_entries().await? { - if e.front.trust_tier != TrustTier::Verified { - continue; - } - for a in artifacts_in(&e) { - if a.kind == "task" && a.name == name { - verified.push((a, e.is_active())); - } + let later = newest + .as_ref() + .is_none_or(|n| e.front.created > n.front.created); + if later && definition_in(&e, name).is_some() { + newest = Some(e); } } - verified.sort_by(|a, b| a.0.created.cmp(&b.0.created)); - let Some(at) = verified.iter().rposition(|(_, active)| *active) else { + newest + .and_then(|e| definition_in(&e, name)) + .transpose() + .map_err(|()| unreadable(name)) + } + + /// The definition a task names as `@`, while that entry is still active + /// and verified; none once it was replaced, demoted or erased. + /// + /// # Errors + /// + /// A store error, or [`Error::Denied`] when the block does not read as a definition. + pub async fn pinned_definition(&self, named: &str) -> Result, Error> { + let name = definition_name(named); + let Some((_, entry)) = named.split_once('@') else { + return self.task_definition(name).await; + }; + let Some(domain) = self.platform_domain() else { return Ok(None); }; - let artifact = &verified[at].0; - let mut def: TaskDefinition = toml::from_str(&artifact.text).map_err(|e| { - Error::Denied(format!( - "the task definition {name} in {} does not read: {e}", - artifact.entry - )) - })?; - def.name = name.to_string(); - def.version = u32::try_from(at + 1).unwrap_or(u32::MAX); - def.entry.clone_from(&artifact.entry); - Ok(Some(def)) + let Some(e) = self.store.get(&domain, entry).await? else { + return Ok(None); + }; + definition_in(&e, name) + .transpose() + .map_err(|()| unreadable(name)) } - /// Bind a task to the definition it names: the name must be in force, the task then - /// names it at its version, takes its acceptance criteria if it gave none, and - /// requires what the definition declares, so only a session that has those claims it. + /// Bind a task to the definition it names. The definition must be in force, and the + /// session must be one that may read its entry. The task then names it with its + /// entry, takes its acceptance criteria if it gave none, is classified no lower than + /// the entry, and requires what the definition declares, so only a session that has + /// those claims it. /// /// # Errors /// - /// [`Error::Denied`] when no definition of that name is in force. + /// [`Error::Denied`] when no definition of that name is in force for this session. + /// A definition the session may not read is refused in the same words as one that + /// does not exist. pub(crate) async fn bind_definition( &self, spec: &mut dm_ledger::TaskSpec, @@ -308,12 +350,23 @@ impl Memory { return Ok(()); }; let name = definition_name(&named); - let Some(def) = self.task_definition(name).await? else { - return Err(Error::Denied(format!( + let none = || { + Error::Denied(format!( "no task definition {name} is in force: a verified entry of the platform's domain defines one" - ))); + )) }; + let def = match self.pinned_definition(&named).await { + Ok(Some(def)) => def, + Ok(None) | Err(Error::Denied(_)) => return Err(none()), + Err(e) => return Err(e), + }; + // read as any entry is: refused above the session's handling, and the session's + // label rises to what it read + if self.get(&def.entry).await.is_err() { + return Err(none()); + } spec.definition = Some(def.reference()); + spec.classification = spec.classification.max(def.classification); if spec.acceptance.is_empty() { spec.acceptance.clone_from(&def.acceptance); } @@ -325,8 +378,9 @@ impl Memory { Ok(()) } - /// Why triage does not admit a task, when what it lacks is a definition or a way to - /// check it: the missing items, for whoever filed it. None when nothing is missing. + /// Why triage does not admit a task on its own, when what it lacks is a definition + /// or a way to check it: the missing items, for whoever filed it. None when nothing + /// is missing. #[must_use] pub fn admission_gap(&self, spec: &dm_ledger::TaskSpec) -> Option { if spec.definition.is_none() { @@ -341,28 +395,48 @@ impl Memory { .then(|| "it has no acceptance criteria, its own or its definition's".to_string()) } + /// Whether the definition a task names is out of force: replaced, demoted or erased + /// since the task was filed. Such a task is not admitted by anyone. + pub(crate) async fn definition_gone( + &self, + spec: &dm_ledger::TaskSpec, + ) -> Result, Error> { + let Some(named) = &spec.definition else { + return Ok(None); + }; + Ok(match self.pinned_definition(named).await { + Ok(Some(_)) => None, + Ok(None) | Err(Error::Denied(_)) => { + Some(format!("its task definition {named} is no longer in force")) + } + Err(e) => return Err(e), + }) + } + /// Whether the task this session holds lets it call `tool`: a task under a - /// definition reaches only the tools the definition declares. + /// definition reaches only the tools that definition declares, as the task named + /// it. A session that holds no task, or a task under no definition, is held to the + /// grants alone. /// /// # Errors /// - /// [`Error::Denied`] when the held task's definition does not declare the tool. + /// [`Error::Denied`] when the definition does not declare the tool or is no longer + /// in force; a ledger or store error when the task or the definition cannot be + /// read, since a check that could not look has not passed. pub(crate) async fn definition_allows(&self, tool: &str) -> Result<(), Error> { let Some(held) = self.held.get() else { return Ok(()); }; - let Ok(task) = self.ledger.lock().await.get(held).await else { - return Ok(()); - }; + let task = self.ledger.lock().await.get(held).await?; let Some(named) = task.spec.definition else { return Ok(()); }; - let name = definition_name(&named); - let declared = self - .task_definition(name) - .await? - .is_some_and(|d| d.tools.iter().any(|t| t == &format!("tool:{tool}"))); - if declared { + let Some(def) = self.pinned_definition(&named).await? else { + return Err(Error::Denied(format!( + "the task definition {named} is no longer in force, so its task reaches no tool" + ))); + }; + if def.tools.iter().any(|t| t == &format!("tool:{tool}")) { Ok(()) } else { Err(Error::Denied(format!( @@ -370,6 +444,27 @@ impl Memory { ))) } } + + /// What this session can do, as the ledger matches it to a task's requirements: the + /// manifest it was given, and each tool the grants give it. + #[must_use] + pub(crate) fn capabilities(&self) -> Vec { + let mut all = self.manifest.clone(); + for (name, tap) in &self.grants.taps { + let tool = format!("tool:{name}"); + if tap.tool && !all.contains(&tool) { + all.push(tool); + } + } + all + } +} + +/// The refusal for a block that is no task definition: its name, and nothing of its text. +fn unreadable(name: &str) -> Error { + Error::Denied(format!( + "the task definition {name} does not read as one: it has a key this code does not know, or a tool that is not `tool:`" + )) } #[cfg(test)] @@ -504,73 +599,85 @@ mod tests { } } + async fn verify(m: &Memory, id: &str) { + let mut e = m.store.get("dom-plat", id).await.unwrap().unwrap(); + e.front.trust_tier = TrustTier::Verified; + m.store.put(&e).await.unwrap(); + } + + /// A session of this root that holds `id`. + fn holding(dir: &std::path::Path, id: &str) -> Memory { + let session = Memory::open(dir).unwrap(); + session.set_held_task(id); + session + } + #[tokio::test] - async fn a_task_definition_is_in_force_once_verified_and_counts_its_versions() { + async fn a_task_definition_is_in_force_once_verified_and_the_newest_one_is() { let dir = tempfile::tempdir().unwrap(); let m = root(dir.path()); written(&m, REVIEW, false).await; assert_eq!(m.task_definition("review-change").await.unwrap(), None); let first = written(&m, REVIEW, true).await; let def = m.task_definition("review-change").await.unwrap().unwrap(); - assert_eq!( - (def.reference().as_str(), def.entry.as_str()), - ("review-change@1", first.as_str()) - ); + assert_eq!(def.reference(), format!("review-change@{first}")); assert_eq!(def.tools, ["tool:fetch"]); assert_eq!( def.autonomy, Autonomy::Person, "where every definition starts" ); - // a verified replacement is the next version; the one it replaced still counts + // two verified entries define it: the newer is in force, and the older is still + // what a task that named it means let wider = REVIEW.replace("[\"tool:fetch\"]", "[\"tool:fetch\", \"tool:search\"]"); - let second = m - .write(WriteRequest { - body: wider, - domain: Some("dom-plat".into()), - entry_type: dm_core::EntryType::Convention, - supersedes: Some(first), - ..WriteRequest::default() - }) - .await - .unwrap() - .id; - // the owner replaced the entry, as only the owner replaces a verified one unheld, - // and nothing is in force until the replacement is verified in its turn - assert_eq!(m.task_definition("review-change").await.unwrap(), None); - let mut e = m.store.get("dom-plat", &second).await.unwrap().unwrap(); - e.front.trust_tier = TrustTier::Verified; - m.store.put(&e).await.unwrap(); + let second = written(&m, &wider, true).await; let def = m.task_definition("review-change").await.unwrap().unwrap(); - assert_eq!(def.reference(), "review-change@2"); + assert_eq!(def.entry, second); assert_eq!(def.tools, ["tool:fetch", "tool:search"]); - // a block that is no definition is refused in words, not read as an empty one - written(&m, "```platform task broken\ntool = \"x\"\n```\n", true).await; - let broken = m.task_definition("broken").await; - assert!( - matches!(&broken, Err(Error::Denied(why)) if why.contains("broken")), - "{broken:?}" - ); + let pinned = format!("review-change@{first}"); + let old = m.pinned_definition(&pinned).await.unwrap().unwrap(); + assert_eq!(old.tools, ["tool:fetch"]); } #[tokio::test] - async fn a_task_takes_its_definition_s_version_checks_and_tools() { + async fn a_block_that_is_no_definition_is_refused_and_its_text_is_not_quoted() { let dir = tempfile::tempdir().unwrap(); let m = root(dir.path()); - written(&m, REVIEW, true).await; + for body in [ + "```platform task broken\nsecret_key_bluebird = \"TOPSECRET\"\n```\n", + "```platform task broken\ntools = [\"fetch\"]\n```\n", + ] { + let id = written(&m, body, true).await; + let refused = m.task_definition("broken").await; + assert!( + matches!(&refused, Err(Error::Denied(why)) if why.contains("broken") && !why.contains("bluebird")), + "{refused:?}" + ); + let bound = m.create_task(task("Uses it", Some("broken"))).await; + assert!(matches!(&bound, Err(Error::Denied(why)) if !why.contains("bluebird"))); + m.store.demote("dom-plat", &id).await.unwrap(); + } + } + + #[tokio::test] + async fn a_task_takes_its_definition_s_entry_checks_and_tools() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + let entry = written(&m, REVIEW, true).await; let filed = m .create_task(task("Review the importer", Some("review-change"))) .await .unwrap() .task; - assert_eq!(filed.spec.definition.as_deref(), Some("review-change@1")); + let named = format!("review-change@{entry}"); + assert_eq!(filed.spec.definition.as_deref(), Some(named.as_str())); assert_eq!( filed.spec.acceptance, ["The handoff names each decision the change touches."] ); assert_eq!(filed.spec.requirements, ["tool:fetch"]); // its own checks stand in place of the definition's - let mut own = task("Review the exporter", Some("review-change@1")); + let mut own = task("Review the exporter", Some(&named)); own.acceptance = vec!["The handoff lists the files read.".into()]; let own = m.create_task(own).await.unwrap().task; assert_eq!(own.spec.acceptance, ["The handoff lists the files read."]); @@ -590,8 +697,45 @@ mod tests { assert_eq!(all, 2); } + /// A definition above a session's handling is, to that session, one that does not + /// exist, and a task that takes its checks is classified as its entry is. #[tokio::test] - async fn where_the_owner_asks_for_it_a_task_without_a_definition_stays_proposed() { + async fn a_definition_is_read_under_the_filter_and_its_task_is_no_lower() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + let id = m + .write(WriteRequest { + body: REVIEW.into(), + domain: Some("dom-plat".into()), + entry_type: dm_core::EntryType::Convention, + classification: Some(dm_core::Label::Restricted), + ..WriteRequest::default() + }) + .await + .unwrap() + .id; + verify(&m, &id).await; + let low = Memory::open(dir.path()) + .unwrap() + .with_handling_cap(dm_core::Label::Public); + let hidden = low.create_task(task("Review", Some("review-change"))).await; + let missing = low.create_task(task("Review", Some("no-such"))).await; + let words = |r: &Result| match r { + Err(Error::Denied(why)) => why.replace("review-change", "X").replace("no-such", "X"), + other => format!("{:?}", other.as_ref().map(|c| &c.task.id)), + }; + assert_eq!(words(&hidden), words(&missing), "the same refusal for both"); + assert!(words(&hidden).contains("no task definition X is in force")); + let filed = m + .create_task(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + assert_eq!(filed.spec.classification, dm_core::Label::Restricted); + } + + #[tokio::test] + async fn where_the_owner_asks_for_it_a_task_without_a_definition_is_not_admitted_on_its_own() { let dir = tempfile::tempdir().unwrap(); root(dir.path()); let path = dir.path().join(crate::GRANTS_FILE); @@ -599,15 +743,18 @@ mod tests { g.gates.require_definition = true; std::fs::write(&path, g.render().unwrap()).unwrap(); let m = Memory::open(dir.path()).unwrap(); - written(&m, REVIEW, true).await; + let entry = written(&m, REVIEW, true).await; let bare = m.file_own(task("Tidy", None)).await.unwrap().task; assert_eq!(bare.status, dm_ledger::Status::Proposed); assert_eq!( m.admission_gap(&bare.spec).as_deref(), Some("it names no task definition") ); - let refused = m.admit(&bare.id).await; - assert!(matches!(&refused, Err(Error::Denied(why)) if why.contains("task definition"))); + // the owner is the triage, and admits it by hand + assert_eq!( + m.admit(&bare.id).await.unwrap().status, + dm_ledger::Status::Open + ); let defined = m .file_own(task("Review", Some("review-change"))) .await @@ -616,7 +763,7 @@ mod tests { assert_eq!( defined.status, dm_ledger::Status::Open, - "the owner's filing is admitted" + "admitted on its own" ); // a definition with no checks gives a task nothing to be checked by written( @@ -628,33 +775,126 @@ mod tests { let loose = m.file_own(task("Loose", Some("loose"))).await.unwrap().task; assert_eq!(loose.status, dm_ledger::Status::Proposed); assert!(m.admission_gap(&loose.spec).unwrap().contains("acceptance")); + // and nobody admits a task whose definition has left force since it was filed + let mut waits = task("Later", Some("review-change")); + waits.requirements = vec!["human".into()]; + let waits = m.create_task(waits).await.unwrap().task; + m.store.demote("dom-plat", &entry).await.unwrap(); + if waits.status == dm_ledger::Status::Proposed { + let refused = m.admit(&waits.id).await; + assert!( + matches!(&refused, Err(Error::Denied(why)) if why.contains("no longer in force")), + "{refused:?}" + ); + } + assert_eq!( + m.definition_gone(&waits.spec).await.unwrap(), + Some(format!( + "its task definition review-change@{entry} is no longer in force" + )) + ); } #[tokio::test] async fn a_session_that_holds_a_defined_task_reaches_only_the_tools_it_declares() { let dir = tempfile::tempdir().unwrap(); let m = root(dir.path()); - written(&m, REVIEW, true).await; + let entry = written(&m, REVIEW, true).await; let defined = m .file_own(task("Review", Some("review-change"))) .await .unwrap() .task; let bare = m.file_own(task("Tidy", None)).await.unwrap().task; - let holding = |id: &str| { - let session = Memory::open(dir.path()).unwrap(); - session.set_held_task(id); - session - }; - let on_defined = holding(&defined.id); - on_defined.definition_allows("fetch").await.unwrap(); - let refused = on_defined.definition_allows("search").await; + let on_defined = std::sync::Arc::new(holding(dir.path(), &defined.id)); + // through the call a tool goes through: refused for the definition before the + // grants are asked, and for the grants when the definition lets it by + let undeclared = on_defined.use_tool("search", serde_json::json!({})).await; assert!( - matches!(&refused, Err(Error::Denied(why)) if why.contains("tool:search") && why.contains("review-change@1")), - "{refused:?}" + matches!(&undeclared, Err(Error::Denied(why)) if why.contains("does not declare tool:search")), + "{undeclared:?}" + ); + let declared = on_defined.use_tool("fetch", serde_json::json!({})).await; + assert!( + matches!(&declared, Err(Error::Denied(why)) if why.contains("no tool fetch is granted")), + "{declared:?}" ); // a task under no definition is held as before, and so is no task at all - holding(&bare.id).definition_allows("search").await.unwrap(); + holding(dir.path(), &bare.id) + .definition_allows("search") + .await + .unwrap(); m.definition_allows("search").await.unwrap(); + // a wider definition verified later widens nothing for a task that named the first + let wider = REVIEW.replace("[\"tool:fetch\"]", "[\"tool:fetch\", \"tool:search\"]"); + written(&m, &wider, true).await; + assert!(on_defined.definition_allows("search").await.is_err()); + // once the entry it named is out of force, the task reaches no tool + m.store.demote("dom-plat", &entry).await.unwrap(); + let gone = on_defined.definition_allows("fetch").await; + assert!(matches!(&gone, Err(Error::Denied(why)) if why.contains("no longer in force"))); + // and a check that cannot read the task has not passed + assert!(holding(dir.path(), "task-me-nothere") + .definition_allows("fetch") + .await + .is_err()); + } + + #[tokio::test] + async fn what_a_defined_task_files_runs_under_its_definition() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + let entry = written(&m, REVIEW, true).await; + let named = format!("review-change@{entry}"); + let parent = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + let session = holding(dir.path(), &parent.id); + let own = session + .create_task(task("A follow-up", None)) + .await + .unwrap() + .task; + assert_eq!(own.spec.definition.as_deref(), Some(named.as_str())); + assert_eq!(own.spec.acceptance, parent.spec.acceptance); + let fanned = m + .fan_out(&parent.id, &[("Read one".into(), "Read the first.".into())]) + .await + .unwrap(); + let child = &fanned.children[0]; + assert_eq!(child.spec.definition.as_deref(), Some(named.as_str())); + let continuation = fanned.continuation.unwrap(); + assert_eq!( + continuation.spec.definition.as_deref(), + Some(named.as_str()) + ); + assert_eq!(continuation.spec.acceptance, parent.spec.acceptance); + } + + #[tokio::test] + async fn a_session_s_capabilities_hold_the_tools_the_grants_give_it() { + let dir = tempfile::tempdir().unwrap(); + root(dir.path()); + let path = dir.path().join(crate::GRANTS_FILE); + let text = std::fs::read_to_string(&path).unwrap(); + let with_tool = format!( + "{text}\n[taps.fetch]\ndomain = \"dom-me\"\nhosts = []\nceiling = \"public\"\ntool = true\n" + ); + std::fs::write(&path, with_tool).unwrap(); + let m = Memory::open(dir.path()) + .unwrap() + .with_manifest(vec!["human".into()]); + assert_eq!(m.capabilities(), ["human", "tool:fetch"]); + written(&m, REVIEW, true).await; + // so a task that requires what its definition declares is one this session claims + let t = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + let claimed = m.claim(&t.id, time::Duration::minutes(5)).await.unwrap(); + assert_eq!(claimed.task.id, t.id); } } diff --git a/local/README.md b/local/README.md index d583bccb..eff27ae5 100644 --- a/local/README.md +++ b/local/README.md @@ -624,23 +624,34 @@ outputs = ["handoff"] autonomy = "person" ``` -A definition is in force when its entry is active and a person verified it. Its version -is its place among the verified definitions of that name, and the first is version 1. +A definition is in force when its entry is active and a person verified it. When two +such entries define one name, the newer entry is in force. The version of a definition is +the id of its entry. An entry never changes, so a task that names a version names one +text. A task names a definition with `amem task create --definition `, or with `definition` in the memory tool `create_task`. Then: -- The task records the definition with its version, for example `answer-from-record@1`. +- The task records the definition with its version, for example + `answer-from-record@mem-platform-7k2q9x4b`. +- The task gets the classification of the entry of the definition, if that is higher. - The task takes the acceptance checks of the definition, if it gives none. Give checks for one task with `--accept`. - The task requires the tools that the definition declares. Thus only a session that has those tools can claim it. - A session that holds the task can call only the tools that the definition declares. - The memory server refuses a different tool. + The memory server refuses a different tool. It uses the definition that the task + named. If that entry is replaced, demoted or erased, the session can call no tool. +- A task that a session files while it holds the task gets the same definition and + checks. This applies to a subagent task, its continuation and a retry. + +A tool has the form `tool:`, where the name is the name of a tap that the grants +give as a tool, for example `tool:fetch` and `tool:search`. A session can claim a task +that requires a tool when the grants give it that tool. - `amem work` puts the definition and the checks in the prompt, below "Done when". A task cannot name a definition that is not in force. The memory server refuses it, and -files nothing. +files nothing. It gives the same refusal for a definition that the session cannot read. By default, triage also admits a task that names no definition. The tasks that the system files for itself name none yet. To require a definition, set this in `grants.toml`: @@ -650,12 +661,15 @@ system files for itself name none yet. To require a definition, set this in `gra require_definition = true ``` -Then a task with no definition, or with no acceptance checks, stays `proposed`. -`create_task` gives the reason in `not_admitted_because`, and `amem task admit` refuses -the task with the same reason. +Then triage does not admit a task with no definition, or with no acceptance checks. The +task stays `proposed`, and `create_task` gives the reason in `not_admitted_because`. The +owner can still admit such a task with `amem task admit`, because the tasks that the +system files name no definition. Nobody can admit a task whose definition is no longer +in force. Not built yet: the autonomy level has no effect, no suite gates a version of a -definition, and the data scope is not enforced. +definition, and the data scope is not enforced. An erasure of the entry of a definition +does not remove the checks that tasks took from it. ## The task fabric: `amem task rescore` and `stats` From 0a19cf9b29e6736c25a21564c305ff9122fb5f29 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Thu, 12 Nov 2026 08:36:56 +0100 Subject: [PATCH 3/3] fix: answer the second adversarial pass on the task definitions The pass ran on f64dacf and confirmed seven findings. - f64dacf let each session admit a task past the definition gate, where the intent was the owner. A session thus admitted its own task with no definition. Only the owner now passes that gate. - The held task of a session was set one time. A launcher that works one task after the other kept the definition of the first task. The held task is now the latest that the launcher set. - A subagent task had the definition but no requirements, so a session without the tools of the definition could claim it. A subagent task is now bound as each filing is, and requires those tools. - A retry kept the entry that the first attempt named, also after that entry left force. A retry now takes the definition by its name, as it is in force. - The read of the entry of a definition counted as a `get`. Two such filings changed the default depth of a recall. The memory server now looks at the entry under the read filter and raises the label of the session, and does no more. - A session that can read the entry got "not in force" for a block that does not parse. It now gets the refusal that says so. - Five changes to the code left all tests green. Each now has a test: a retry, `claim_next` with a granted tool, the own checks of a parent in a child and in a filing under a held task, and the admit of a task whose definition is gone, which no longer depends on a condition. `admit_per_policy` no longer asks if the definition is gone. A filing binds the definition first, so that case does not occur there. Co-Authored-By: Claude Fable 5.2 --- crates/server/src/lib.rs | 87 +++++++----- crates/server/src/platform.rs | 247 ++++++++++++++++++++++++++++------ crates/server/src/tape.rs | 4 +- local/README.md | 10 +- 4 files changed, 269 insertions(+), 79 deletions(-) diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index be25562e..c57970c7 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -291,7 +291,7 @@ pub struct Memory { /// The task this session holds, when the launcher runs it on one: what it files /// from is judged against that task's requirements. Shared by the views of a /// session ([`Memory::for_request`]), as every count below that is kept per session. - held: Arc>, + held: Arc>>, /// How many entries this session has superseded: the blast-radius gate's count. superseded: Arc, /// The claims this session made, each task with the generation the ledger gave: the @@ -315,7 +315,7 @@ pub struct Memory { last_stamp: Arc>, } -fn lock(m: &Mutex) -> MutexGuard<'_, T> { +pub(crate) fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(PoisonError::into_inner) } @@ -457,7 +457,7 @@ impl Memory { projection: None, written: (AtomicUsize::new(0), AtomicUsize::new(0)), policy: Arc::new(std::sync::RwLock::new(std::sync::Arc::new(policy))), - held: Arc::new(OnceLock::new()), + held: Arc::default(), superseded: Arc::new(AtomicUsize::new(0)), claims: Arc::default(), scope: OnceLock::new(), @@ -567,9 +567,15 @@ impl Memory { } /// The task this session runs on: a launcher sets it before the harness starts, - /// so a task the session files is judged against the one it holds. + /// so a task the session files is judged against the one it holds. A launcher that + /// works one task after another sets it for each: the task held is the latest set. pub fn set_held_task(&self, id: &str) { - let _ = self.held.set(id.to_string()); + *lock(&self.held) = Some(id.to_string()); + } + + /// The task this session runs on, when a launcher set one. + pub(crate) fn held_task(&self) -> Option { + lock(&self.held).clone() } /// The policy in force: the base, and the record's verified policy entries. @@ -613,7 +619,7 @@ impl Memory { /// deny, with the policy version that produced it, as the paper has decision logs. fn decide(&self, input: &Input) -> Result { let mut input = input.clone(); - input.held_task = self.held.get().cloned(); + input.held_task = self.held_task(); let d = self.policy().decide(&input)?; self.log_decision(&input, &d); Ok(d) @@ -641,7 +647,7 @@ impl Memory { parent: None, write: None, promote: None, - held_task: self.held.get().cloned(), + held_task: self.held_task(), spend: Some(dm_policy::decide::SpendInput { destination: destination.to_string(), session_tokens, @@ -1098,7 +1104,7 @@ impl Memory { if !supersedes { return TrustTier::Raw; } - let Some(held) = self.held.get() else { + let Some(held) = &self.held_task() else { return TrustTier::Raw; }; let curating = self.ledger.lock().await.get(held).await.is_ok_and(|t| { @@ -2078,8 +2084,8 @@ impl Memory { ))); } let mut l = self.ledger.lock().await; - let held = match self.held.get() { - Some(id) => l.get(id).await.ok(), + let held = match self.held_task() { + Some(id) => l.get(&id).await.ok(), None => None, }; let filing = if held.is_some() { @@ -2174,11 +2180,17 @@ impl Memory { let mut l = self.ledger.lock().await; let task = l.get(id).await?; self.check_task(&task)?; - // the owner is the triage here, and may admit a task that names no definition: the - // system's own filings name none. Nobody admits one whose definition is gone + // the owner is the triage here, and may admit a task that lacks a definition or + // its checks: the system's own filings name none. Nobody else passes that gap, + // and nobody admits a task whose definition is gone if let Some(gone) = self.definition_gone(&task.spec).await? { return Err(Error::Denied(format!("{id} is not admitted: {gone}"))); } + if self.author != self.grants.owner { + if let Some(gap) = self.admission_gap(&task.spec) { + return Err(Error::Denied(format!("{id} is not admitted: {gap}"))); + } + } let admitted = l.admit(id).await?; self.rank_edges(&mut l, &admitted).await?; self.observe_task(&admitted); @@ -2226,10 +2238,7 @@ impl Memory { parent: Option<&Task>, ) -> Result { // a task nobody can check stays proposed, whatever the policy says of its filer - if task.status != dm_ledger::Status::Proposed - || self.admission_gap(&task.spec).is_some() - || self.definition_gone(&task.spec).await?.is_some() - { + if task.status != dm_ledger::Status::Proposed || self.admission_gap(&task.spec).is_some() { return Ok(task); } let d = self.decide(&Input { @@ -2317,9 +2326,10 @@ impl Memory { } let mut children = Vec::new(); for (title, body, hash) in asked { - let created = l - .create(self.child_of(&parent, &title, &body, hash)) - .await?; + // bound as any filing is: the child requires the tools its definition declares + let mut spec = self.child_of(&parent, &title, &body, hash); + self.bind_definition(&mut spec).await?; + let created = l.create(spec).await?; // the ledger dedups on the parent and the hash whatever the task's status: a // task it hands back is not this call's, and one this session may not read // is withheld, never handed out @@ -2598,22 +2608,27 @@ impl Memory { )); } } - let created = l - .create(TaskSpec { - domain: root.spec.domain.clone(), - title: root.spec.title.clone(), - body, - requirements: root.spec.requirements.clone(), - depends_on: Vec::new(), - origin: Some(format!("retries:{}", root.id)), - upstream_event: None, - created_by: self.author.clone(), - classification, - priority: root.spec.priority, - definition: root.spec.definition.clone(), - acceptance: root.spec.acceptance.clone(), - }) - .await?; + let mut spec = TaskSpec { + domain: root.spec.domain.clone(), + title: root.spec.title.clone(), + body, + requirements: root.spec.requirements.clone(), + origin: Some(format!("retries:{}", root.id)), + created_by: self.author.clone(), + classification, + priority: root.spec.priority, + // the definition by its name: a retry runs under the one in force now, which + // may be a later entry than the one the first attempt named + definition: root + .spec + .definition + .as_deref() + .map(|d| platform::definition_name(d).to_string()), + acceptance: root.spec.acceptance.clone(), + ..TaskSpec::default() + }; + self.bind_definition(&mut spec).await?; + let created = l.create(spec).await?; let retry = self .admit_per_policy(&mut l, created.task, Filing::Held, Some(&root)) .await?; @@ -8663,7 +8678,7 @@ mod tests { assert!(err.to_string().contains("blast radius"), "{err}"); agent.set_held_task("task-me-held"); assert_eq!( - second.held.get().map(String::as_str), + second.held_task().as_deref(), Some("task-me-held"), "the held task is the session's, shared with its views" ); diff --git a/crates/server/src/platform.rs b/crates/server/src/platform.rs index dcd280cf..83151564 100644 --- a/crates/server/src/platform.rs +++ b/crates/server/src/platform.rs @@ -286,14 +286,9 @@ impl Memory { Ok(found) } - /// The task definition named `name` that is in force: the one in the newest active - /// entry a person verified. - /// - /// # Errors - /// - /// A store error, or [`Error::Denied`] when the block in force does not read as a - /// definition. The refusal names the definition and says nothing of its text. - pub async fn task_definition(&self, name: &str) -> Result, Error> { + /// The entry that holds the definition named `name` in force: the newest active one a + /// person verified, whether or not its block reads. + async fn definition_entry(&self, name: &str) -> Result, Error> { let mut newest: Option = None; for e in self.platform_entries().await? { let later = newest @@ -303,7 +298,34 @@ impl Memory { newest = Some(e); } } - newest + Ok(newest) + } + + /// The entry a task names as `@`, while it is still active and verified + /// and defines that name; for a bare name, the entry in force. + async fn pinned_entry(&self, named: &str) -> Result, Error> { + let name = definition_name(named); + let Some((_, entry)) = named.split_once('@') else { + return self.definition_entry(name).await; + }; + let Some(domain) = self.platform_domain() else { + return Ok(None); + }; + // an id that is no id names no entry + let found = self.store.get(&domain, entry).await.ok().flatten(); + Ok(found.filter(|e| definition_in(e, name).is_some())) + } + + /// The task definition named `name` that is in force: the one in the newest active + /// entry a person verified. + /// + /// # Errors + /// + /// A store error, or [`Error::Denied`] when the block in force does not read as a + /// definition. The refusal names the definition and says nothing of its text. + pub async fn task_definition(&self, name: &str) -> Result, Error> { + let entry = self.definition_entry(name).await?; + entry .and_then(|e| definition_in(&e, name)) .transpose() .map_err(|()| unreadable(name)) @@ -317,16 +339,9 @@ impl Memory { /// A store error, or [`Error::Denied`] when the block does not read as a definition. pub async fn pinned_definition(&self, named: &str) -> Result, Error> { let name = definition_name(named); - let Some((_, entry)) = named.split_once('@') else { - return self.task_definition(name).await; - }; - let Some(domain) = self.platform_domain() else { - return Ok(None); - }; - let Some(e) = self.store.get(&domain, entry).await? else { - return Ok(None); - }; - definition_in(&e, name) + let entry = self.pinned_entry(named).await?; + entry + .and_then(|e| definition_in(&e, name)) .transpose() .map_err(|()| unreadable(name)) } @@ -335,13 +350,14 @@ impl Memory { /// session must be one that may read its entry. The task then names it with its /// entry, takes its acceptance criteria if it gave none, is classified no lower than /// the entry, and requires what the definition declares, so only a session that has - /// those claims it. + /// those claims it. The session has then read the entry, and its label is no lower. /// /// # Errors /// - /// [`Error::Denied`] when no definition of that name is in force for this session. - /// A definition the session may not read is refused in the same words as one that - /// does not exist. + /// [`Error::Denied`] when no definition of that name is in force for this session: + /// a definition the session may not read is refused in the same words as one that + /// does not exist. A session that may read the entry is told when its block does + /// not read as a definition. pub(crate) async fn bind_definition( &self, spec: &mut dm_ledger::TaskSpec, @@ -355,16 +371,19 @@ impl Memory { "no task definition {name} is in force: a verified entry of the platform's domain defines one" )) }; - let def = match self.pinned_definition(&named).await { - Ok(Some(def)) => def, - Ok(None) | Err(Error::Denied(_)) => return Err(none()), - Err(e) => return Err(e), + let Some(entry) = self.pinned_entry(&named).await? else { + return Err(none()); }; - // read as any entry is: refused above the session's handling, and the session's - // label rises to what it read - if self.get(&def.entry).await.is_err() { + // under the filter every read is under, and before anything of the entry is told + let filter = self.session_filter()?; + let seen = self.index.lock().await.meta(&entry.front.id, &filter)?; + if seen.is_none() { return Err(none()); } + let Some(Ok(def)) = definition_in(&entry, name) else { + return Err(unreadable(name)); + }; + crate::lock(&self.session).observe(def.classification); spec.definition = Some(def.reference()); spec.classification = spec.classification.max(def.classification); if spec.acceptance.is_empty() { @@ -424,10 +443,10 @@ impl Memory { /// in force; a ledger or store error when the task or the definition cannot be /// read, since a check that could not look has not passed. pub(crate) async fn definition_allows(&self, tool: &str) -> Result<(), Error> { - let Some(held) = self.held.get() else { + let Some(held) = self.held_task() else { return Ok(()); }; - let task = self.ledger.lock().await.get(held).await?; + let task = self.ledger.lock().await.get(&held).await?; let Some(named) = task.spec.definition else { return Ok(()); }; @@ -780,13 +799,16 @@ mod tests { waits.requirements = vec!["human".into()]; let waits = m.create_task(waits).await.unwrap().task; m.store.demote("dom-plat", &entry).await.unwrap(); - if waits.status == dm_ledger::Status::Proposed { - let refused = m.admit(&waits.id).await; - assert!( - matches!(&refused, Err(Error::Denied(why)) if why.contains("no longer in force")), - "{refused:?}" - ); - } + assert_eq!( + waits.status, + dm_ledger::Status::Proposed, + "it waits for a person" + ); + let refused = m.admit(&waits.id).await; + assert!( + matches!(&refused, Err(Error::Denied(why)) if why.contains("no longer in force")), + "{refused:?}" + ); assert_eq!( m.definition_gone(&waits.spec).await.unwrap(), Some(format!( @@ -897,4 +919,151 @@ mod tests { let claimed = m.claim(&t.id, time::Duration::minutes(5)).await.unwrap(); assert_eq!(claimed.task.id, t.id); } + + /// Only the owner passes the gap by hand: a session cannot admit what triage held. + #[tokio::test] + async fn nobody_but_the_owner_admits_a_task_that_lacks_a_definition() { + let dir = tempfile::tempdir().unwrap(); + root(dir.path()); + let path = dir.path().join(crate::GRANTS_FILE); + let mut g = dm_policy::Grants::load(&path).unwrap(); + g.gates.require_definition = true; + std::fs::write(&path, g.render().unwrap()).unwrap(); + let owner = Memory::open(dir.path()).unwrap(); + let agent = Memory::open(dir.path()).unwrap().with_author("agent:test"); + let bare = agent.create_task(task("Tidy", None)).await.unwrap().task; + assert_eq!(bare.status, dm_ledger::Status::Proposed); + let refused = agent.admit(&bare.id).await; + assert!( + matches!(&refused, Err(Error::Denied(why)) if why.contains("names no task definition")), + "{refused:?}" + ); + assert_eq!( + owner.admit(&bare.id).await.unwrap().status, + dm_ledger::Status::Open + ); + } + + /// A launcher that works one task after another holds the latest. + #[tokio::test] + async fn the_tool_check_follows_the_task_the_session_holds_now() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + written(&m, REVIEW, true).await; + let bare = m.file_own(task("Tidy", None)).await.unwrap().task; + let defined = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + let session = holding(dir.path(), &bare.id); + session.definition_allows("search").await.unwrap(); + session.set_held_task(&defined.id); + assert!(session.definition_allows("search").await.is_err()); + session.set_held_task(&bare.id); + session.definition_allows("search").await.unwrap(); + } + + #[tokio::test] + async fn a_child_and_a_retry_take_the_parent_s_own_checks_and_the_definition_s_tools() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + let first = written(&m, REVIEW, true).await; + let mut parent = task("Review", Some("review-change")); + parent.acceptance = vec!["The handoff lists the files read.".into()]; + let parent = m.file_own(parent).await.unwrap().task; + let session = holding(dir.path(), &parent.id); + // a filing under the held task takes that task's own checks, not the definition's + let own = session + .create_task(task("A follow-up", None)) + .await + .unwrap() + .task; + assert_eq!(own.spec.acceptance, ["The handoff lists the files read."]); + let fanned = m + .fan_out(&parent.id, &[("Read one".into(), "Read the first.".into())]) + .await + .unwrap(); + let child = &fanned.children[0]; + assert_eq!(child.spec.acceptance, ["The handoff lists the files read."]); + assert_eq!( + child.spec.requirements, + ["tool:fetch"], + "what its definition declares" + ); + // a retry runs under the definition in force now, which is a later entry + let wider = REVIEW.replace("[\"tool:fetch\"]", "[\"tool:fetch\", \"tool:search\"]"); + let second = written(&m, &wider, true).await; + m.store.demote("dom-plat", &first).await.unwrap(); + m.invalidate(&parent.id, "obsolete", None).await.unwrap(); + let retry = m.retry_task(&parent.id).await.unwrap(); + assert_eq!( + retry.spec.definition.as_deref(), + Some(format!("review-change@{second}").as_str()) + ); + assert_eq!(retry.spec.acceptance, ["The handoff lists the files read."]); + assert!(retry.spec.requirements.contains(&"tool:search".to_string())); + } + + /// The ledger offers a task that requires a tool to a session the grants give it. + #[tokio::test] + async fn claim_next_offers_a_task_that_requires_a_granted_tool() { + let dir = tempfile::tempdir().unwrap(); + root(dir.path()); + let path = dir.path().join(crate::GRANTS_FILE); + let text = std::fs::read_to_string(&path).unwrap(); + let with_tool = format!( + "{text}\n[taps.fetch]\ndomain = \"dom-me\"\nhosts = []\nceiling = \"public\"\ntool = true\n" + ); + let m = Memory::open(dir.path()).unwrap(); + written(&m, REVIEW, true).await; + let t = m + .file_own(task("Review", Some("review-change"))) + .await + .unwrap() + .task; + let lease = time::Duration::minutes(5); + assert!(m.claim_next(Some("dom-me"), lease).await.unwrap().is_none()); + std::fs::write(&path, with_tool).unwrap(); + let granted = Memory::open(dir.path()).unwrap(); + let claimed = granted + .claim_next(Some("dom-me"), lease) + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.task.id, t.id); + } + + /// Binding reads the definition's entry: the session's label is no lower afterwards, + /// and a session that may read an entry is told when its block does not read. + #[tokio::test] + async fn binding_raises_the_label_and_tells_a_reader_of_a_block_that_does_not_read() { + let dir = tempfile::tempdir().unwrap(); + let m = root(dir.path()); + let id = m + .write(WriteRequest { + body: REVIEW.into(), + domain: Some("dom-plat".into()), + entry_type: dm_core::EntryType::Convention, + classification: Some(dm_core::Label::Restricted), + ..WriteRequest::default() + }) + .await + .unwrap() + .id; + verify(&m, &id).await; + let session = Memory::open(dir.path()).unwrap(); + assert_eq!(session.label(), dm_core::Label::Public); + session + .create_task(task("Review", Some("review-change"))) + .await + .unwrap(); + assert_eq!(session.label(), dm_core::Label::Restricted); + written(&m, "```platform task broken\nnot_a_key = 1\n```\n", true).await; + let told = m.create_task(task("Uses it", Some("broken"))).await; + assert!( + matches!(&told, Err(Error::Denied(why)) if why.contains("does not read as one")), + "{told:?}" + ); + } } diff --git a/crates/server/src/tape.rs b/crates/server/src/tape.rs index bbe32422..e5be5e2d 100644 --- a/crates/server/src/tape.rs +++ b/crates/server/src/tape.rs @@ -338,12 +338,12 @@ impl Memory { /// The domain this session's records go to: the domain of the task it holds, else /// the only granted one; none when there is neither. pub(crate) async fn recording_domain(&self) -> Option { - match self.held.get() { + match self.held_task() { Some(id) => self .ledger .lock() .await - .get(id) + .get(&id) .await .ok() .map(|t| t.spec.domain), diff --git a/local/README.md b/local/README.md index eff27ae5..3bd1a41c 100644 --- a/local/README.md +++ b/local/README.md @@ -664,8 +664,14 @@ require_definition = true Then triage does not admit a task with no definition, or with no acceptance checks. The task stays `proposed`, and `create_task` gives the reason in `not_admitted_because`. The owner can still admit such a task with `amem task admit`, because the tasks that the -system files name no definition. Nobody can admit a task whose definition is no longer -in force. +system files name no definition. No other session can. Nobody can admit a task whose +definition is no longer in force. + +When the entry of a definition is replaced, demoted or erased, the tasks that named it +keep that entry. Their holders can call no tool, and a session that holds one cannot file +a task under it. To continue the work, verify a new entry for the name, invalidate the +task, and file it again with `amem task retry`. The retry runs under the definition that +is then in force. Not built yet: the autonomy level has no effect, no suite gates a version of a definition, and the data scope is not enforced. An erasure of the entry of a definition