blob: 811c8dedf11c4b9d1703b58f679abdcbb5fbe4a8 [file]
//! What a tap reads and records on the way back out: the domain's completed tasks that
//! came from upstream, with their handoffs, and where each handoff was posted. The one
//! read a tap has is bounded by its ceiling here, so a handoff above it is never handed
//! out and the tap's label stays where its grant allows it to send.
use crate::{Error, Memory};
use dm_core::{Entry, Label};
use dm_ledger::Task;
impl Memory {
/// Completed tasks in a granted domain that came from upstream and whose handoff has
/// not been posted back, oldest first, each with its handoff. A task above `ceiling`
/// or the session's handling, or one whose handoff is, is left out rather than read;
/// so is a task whose handoff is gone. Each task and handoff handed out is a read,
/// and raises the label; each handoff is logged and counted like any other.
///
/// # Errors
///
/// An ungranted domain, or a ledger, index or store error.
pub async fn unposted(
&self,
domain: &str,
ceiling: Label,
) -> Result<Vec<(Task, Entry)>, Error> {
let domain = self.resolve_domain(Some(domain))?;
let tasks = self.ledger.lock().await.unposted(&domain).await?;
let mut filter = self.session_filter()?;
filter.handling = self.handling()?.min(ceiling);
let mut out = Vec::new();
for task in tasks {
if task.spec.classification > filter.handling {
continue;
}
let Some(handoff) = task.handoff.as_deref() else {
continue;
};
// the index says whether the handoff is under the ceiling before the record
// serves it, so nothing above the ceiling raises the session's label
let under = self.index.lock().await.meta(handoff, &filter)?;
if under.is_none() {
continue;
}
let entry = match self.get(handoff).await {
Ok(e) => e,
Err(Error::NotFound(_)) => continue,
Err(e) => return Err(e),
};
// the record's word, should the index be behind it
if entry.front.classification > ceiling {
continue;
}
out.push((task, entry));
}
out.iter().for_each(|(task, _)| self.observe_task(task));
Ok(out)
}
/// Record where a completed task's handoff was posted back, so it is posted once.
/// Only a task of `domain` at or under `ceiling`, the tap's, since a tap may say
/// where it posted what it was handed and nothing about the rest; `to` is one line.
///
/// # Errors
///
/// [`Error::Denied`] for a task of another domain, above the ceiling, or a `to`
/// that is not one line of at most 2048 characters; a ledger error, including a
/// task that is not completed or was posted already, and a task outside the grants
/// or above the session's handling, which is not found.
pub async fn post(
&self,
id: &str,
to: &str,
domain: &str,
ceiling: Label,
) -> Result<Task, Error> {
if to.is_empty() || to.chars().count() > 2048 || to.chars().any(char::is_control) {
return Err(Error::Denied(
"where it was posted is one line of at most 2048 characters".into(),
));
}
let mut l = self.ledger.lock().await;
let task = l.get(id).await?;
self.check_task(&task)?;
if task.spec.domain != domain {
return Err(Error::Denied(format!("{id} is not a task of {domain}")));
}
if task.spec.classification > ceiling {
return Err(Error::Denied(format!(
"{id} is {} which is above the ceiling {ceiling}",
task.spec.classification
)));
}
let posted = l.post(id, to).await?;
self.observe_task(&posted);
Ok(posted)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WriteRequest;
use dm_core::EntryType;
use dm_ledger::TaskSpec;
use std::path::Path;
async fn done(m: &Memory, origin: Option<&str>, label: Label, text: Option<&str>) -> String {
let task = m
.create_task(TaskSpec {
domain: "dom-me".into(),
title: "A task".into(),
body: "Do it.\n".into(),
requirements: vec![],
depends_on: vec![],
origin: origin.map(str::to_string),
upstream_event: origin.map(|_| "open:2026-10-01T09:00:00Z".to_string()),
created_by: String::new(),
classification: label,
priority: 0,
})
.await
.unwrap()
.task;
m.admit(&task.id).await.unwrap();
m.claim(&task.id, time::Duration::minutes(5)).await.unwrap();
if let Some(text) = text {
let h = m
.write(WriteRequest {
entry_type: EntryType::Handoff,
body: text.into(),
classification: Some(label),
..WriteRequest::default()
})
.await
.unwrap();
m.complete(&task.id, &h.id).await.unwrap();
}
task.id
}
fn mem(dir: &Path) -> Memory {
Memory::init(dir, "human:test:me", "dom-me")
.unwrap()
.without_read_log()
}
#[tokio::test]
async fn unposted_hands_out_handoffs_under_the_ceiling_and_post_records_once() {
let dir = tempfile::tempdir().unwrap();
let m = mem(dir.path());
let internal = done(
&m,
Some("github:acme/x#1"),
Label::Internal,
Some("Done, internally.\n"),
)
.await;
// claimed, not done; before the restricted one, since a claim raises the
// session's label and a later task would be filed at it
let open = done(&m, Some("github:acme/x#3"), Label::Internal, None).await;
let restricted = done(
&m,
Some("github:acme/x#2"),
Label::Restricted,
Some("Done, and the key is in the vault.\n"),
)
.await;
let local = done(&m, None, Label::Internal, Some("Done here.\n")).await;
let tap = Memory::open(dir.path())
.unwrap()
.with_author("tap:comments")
.without_read_log();
let due = tap.unposted("dom-me", Label::Internal).await.unwrap();
let ids: Vec<&str> = due.iter().map(|(t, _)| t.id.as_str()).collect();
assert_eq!(ids, vec![internal.as_str()], "{ids:?}");
assert_eq!(due[0].1.body, "Done, internally.\n");
assert_eq!(
tap.label(),
Label::Internal,
"reading what is under the ceiling leaves the label under it"
);
let all = tap.unposted("dom-me", Label::Restricted).await.unwrap();
assert_eq!(all.len(), 2, "at a restricted ceiling both come out");
assert!(all.iter().any(|(t, _)| t.id == restricted));
assert!(tap.unposted("dom-else", Label::Restricted).await.is_err());
assert!(matches!(
tap.post(&open, "https://x/1", "dom-me", Label::Internal)
.await,
Err(Error::Ledger(_))
));
let posted = tap
.post(&internal, "https://x/1", "dom-me", Label::Internal)
.await
.unwrap();
assert_eq!(posted.posted_to.as_deref(), Some("https://x/1"));
assert!(tap
.unposted("dom-me", Label::Internal)
.await
.unwrap()
.is_empty());
assert!(matches!(
tap.post(&internal, "https://x/2", "dom-me", Label::Internal)
.await,
Err(Error::Ledger(_))
));
assert_eq!(m.get_task(&local).await.unwrap().posted_to, None);
}
/// A second domain granted, and a task from upstream completed in it by the owner;
/// the owner's session and the task's id.
async fn done_elsewhere(root: &Path) -> (Memory, String) {
let grants = root.join(crate::GRANTS_FILE);
let mut text = std::fs::read_to_string(&grants).unwrap();
text.push_str("\n[domains.dom-other]\ndefault_classification = \"internal\"\n");
std::fs::write(&grants, text).unwrap();
let owner = Memory::open(root).unwrap().without_read_log();
let other = owner
.create_task(dm_ledger::TaskSpec {
domain: "dom-other".into(),
title: "Elsewhere".into(),
body: "Do it.\n".into(),
requirements: vec![],
depends_on: vec![],
origin: Some("github:acme/x#3".into()),
upstream_event: Some("open:2026-10-01T09:00:00Z".into()),
created_by: String::new(),
classification: Label::Internal,
priority: 0,
})
.await
.unwrap()
.task
.id;
owner.admit(&other).await.unwrap();
owner
.claim(&other, time::Duration::minutes(5))
.await
.unwrap();
let h = owner
.write(WriteRequest {
domain: Some("dom-other".into()),
entry_type: EntryType::Handoff,
body: "Done elsewhere.\n".into(),
..WriteRequest::default()
})
.await
.unwrap();
owner.complete(&other, &h.id).await.unwrap();
(owner, other)
}
#[tokio::test]
async fn post_refuses_a_task_outside_the_tap_s_domain_or_above_its_ceiling() {
let dir = tempfile::tempdir().unwrap();
let m = mem(dir.path());
let restricted = done(
&m,
Some("github:acme/x#2"),
Label::Restricted,
Some("The key is in the vault.\n"),
)
.await;
let (owner, other) = done_elsewhere(dir.path()).await;
let tap = Memory::open(dir.path())
.unwrap()
.with_author("tap:comments")
.without_read_log();
assert!(tap
.unposted("dom-me", Label::Internal)
.await
.unwrap()
.is_empty());
for (id, why) in [
(&restricted, "above the ceiling"),
(&other, "another domain"),
] {
assert!(
matches!(
tap.post(id, "https://x/1", "dom-me", Label::Internal).await,
Err(Error::Denied(_))
),
"{why}"
);
assert_eq!(owner.get_task(id).await.unwrap().posted_to, None, "{why}");
}
assert!(matches!(
tap.post(&other, "https://x/\u{7}", "dom-other", Label::Internal)
.await,
Err(Error::Denied(_))
));
assert!(matches!(
tap.post(&other, &"x".repeat(3000), "dom-other", Label::Internal)
.await,
Err(Error::Denied(_))
));
assert!(tap
.post(&other, "https://x/1", "dom-other", Label::Internal)
.await
.is_ok());
}
#[tokio::test]
async fn a_tap_s_handling_bounds_the_tasks_it_is_handed_and_may_post() {
let dir = tempfile::tempdir().unwrap();
let m = mem(dir.path());
// a restricted task whose handoff is internal: only the task is above the tap
let h = m
.write(WriteRequest {
entry_type: EntryType::Handoff,
body: "Done.\n".into(),
classification: Some(Label::Internal),
..WriteRequest::default()
})
.await
.unwrap();
let id = {
let mut l = m.ledger.lock().await;
let t = l
.create(TaskSpec {
domain: "dom-me".into(),
title: "Rotate the signing key".into(),
body: "Do it.\n".into(),
requirements: vec![],
depends_on: vec![],
origin: Some("github:acme/x#9".into()),
upstream_event: Some("open:2026-10-01T09:00:00Z".into()),
created_by: String::new(),
classification: Label::Restricted,
priority: 0,
})
.await
.unwrap()
.task;
l.admit(&t.id).await.unwrap();
l.claim(
&t.id,
"w",
time::Duration::minutes(5),
&[],
Label::Restricted,
)
.await
.unwrap();
l.complete(&t.id, "w", &h.id).await.unwrap();
t.id
};
let tap = Memory::open(dir.path())
.unwrap()
.with_author("tap:comments")
.with_handling_cap(Label::Internal)
.without_read_log();
let due = tap.unposted("dom-me", Label::Restricted).await.unwrap();
assert!(
due.iter().all(|(t, _)| t.id != id),
"a task above the tap's handling is handed out: {due:?}"
);
assert!(tap
.post(&id, "https://x/9", "dom-me", Label::Restricted)
.await
.is_err());
assert_eq!(m.get_task(&id).await.unwrap().posted_to, None);
}
#[tokio::test]
async fn a_task_handed_to_a_tap_raises_its_label_as_its_handoff_does() {
let dir = tempfile::tempdir().unwrap();
let m = mem(dir.path());
// an internal task whose handoff is public: only the task raises the label
let h = m
.write(WriteRequest {
entry_type: EntryType::Handoff,
body: "Done.\n".into(),
classification: Some(Label::Public),
..WriteRequest::default()
})
.await
.unwrap();
let id = {
let mut l = m.ledger.lock().await;
let t = l
.create(TaskSpec {
domain: "dom-me".into(),
title: "Internal work".into(),
body: "Do it.\n".into(),
requirements: vec![],
depends_on: vec![],
origin: Some("github:acme/x#4".into()),
upstream_event: Some("open:2026-10-01T09:00:00Z".into()),
created_by: String::new(),
classification: Label::Internal,
priority: 0,
})
.await
.unwrap()
.task;
l.admit(&t.id).await.unwrap();
l.claim(
&t.id,
"w",
time::Duration::minutes(5),
&[],
Label::Restricted,
)
.await
.unwrap();
l.complete(&t.id, "w", &h.id).await.unwrap();
t.id
};
let tap = Memory::open(dir.path())
.unwrap()
.with_author("tap:comments")
.without_read_log();
let due = tap.unposted("dom-me", Label::Internal).await.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].0.id, id);
assert_eq!(
tap.label(),
Label::Internal,
"the task handed out is read, not only its handoff"
);
}
}