Add grid agent milestone acceptance gate (#135)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m47s
CI / required (push) Failing after 52s

This commit is contained in:
2026-08-18 12:43:25 +02:00
parent 00707fd8df
commit b56b28043f
9 changed files with 972 additions and 3 deletions

View File

@@ -174,6 +174,9 @@ jobs:
rm -f -- "$fingerprint_list"
fi
cargo run --locked -p metacrate-ci-matrix -- required-gate --evidence artifacts/ci/required-gate.json
test ! -e artifacts/ci/grid-agent-acceptance.jsonl
cargo test --locked -p metacrate-grid-agent --test acceptance_gate --all-features
METACRATE_SOURCE_COMMIT="${{ github.sha }}" cargo run --locked -p metacrate-grid-agent -- --acceptance-evidence artifacts/ci/grid-agent-acceptance.jsonl
touch "$CARGO_TARGET_DIR/.metacrate-ready"
) 9>"$cache_lock"

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://metacrate.invalid/schema/grid-agent-acceptance-v1.json",
"title": "MetaCrate grid-agent acceptance evidence",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "sequence", "unix_millis", "package_version", "source_revision", "rust_toolchain", "command", "profile", "grid_type", "endpoint_capabilities", "stage", "status", "duration_millis", "outcome_code", "evidence_sha256", "metrics", "limitations"],
"properties": {
"schema_version": { "const": 1 },
"sequence": { "type": "integer", "minimum": 1 },
"unix_millis": { "type": "integer", "minimum": 0 },
"package_version": { "type": "string", "maxLength": 64 },
"source_revision": { "type": "string", "maxLength": 96 },
"rust_toolchain": { "type": "string", "maxLength": 96 },
"command": { "type": "string", "maxLength": 256 },
"profile": { "enum": ["deterministic_offline", "live_grid"] },
"grid_type": { "type": "string", "maxLength": 96 },
"endpoint_capabilities": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 96 } },
"stage": { "type": "string", "maxLength": 96 },
"status": { "enum": ["passed", "failed", "not_run"] },
"duration_millis": { "type": "integer", "minimum": 0 },
"budget_millis": { "type": ["integer", "null"], "minimum": 0 },
"outcome_code": { "type": "string", "maxLength": 96 },
"evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"metrics": { "type": "object", "additionalProperties": { "type": ["integer", "number", "boolean", "string"] } },
"limitations": { "type": "array", "maxItems": 32, "items": { "type": "string", "maxLength": 256 } }
}
}

View File

@@ -68,3 +68,6 @@ The complete quick start, platform paths, systemd/Windows service operation,
secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
and unsupported-operation list are in
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md).
Milestone acceptance, resource budgets, evidence, and opt-in live validation
are defined in
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).

View File

@@ -0,0 +1,602 @@
//! Reproducible milestone acceptance evidence and fail-closed live opt-ins.
#![allow(clippy::missing_errors_doc)]
use crate::AgentConfig;
use crate::conversation::{ConversationLimits, ConversationStore};
use crate::interaction::{
ImDialogKind, InboundInteraction, InboundSource, InteractionChannel, InteractionCoordinator,
InteractionModelError, InteractionObservation, InteractionResponder, ResponderFuture,
ResponseRequest, VisibleResponse,
};
use crate::service::{AgentService, ServiceState};
use crate::session::{
GridSessionBackend, ReconnectPolicy, SessionObservation, SessionState, SessionSupervisor,
};
use crate::testing::{FakeGrid, GridScriptStep, adversarial_corpus};
use crate::types::{ControlCommand, GridEventKind, ObservableEvent};
use libremetaverse_types::{UUID, compat::CancellationToken};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::error::Error;
use std::fmt;
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub const ACCEPTANCE_SCHEMA_VERSION: u16 = 1;
pub const ACCEPTANCE_SECRET_CANARY: &str = "METACRATE_ACCEPTANCE_SECRET_CANARY_7e91";
const MAX_EVIDENCE_RECORD_BYTES: usize = 32 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AcceptanceBudgets {
pub startup_millis: u64,
pub control_millis: u64,
pub chat_schedule_millis: u64,
pub reconnect_millis: u64,
pub shutdown_millis: u64,
pub steady_memory_bytes: u64,
pub peak_memory_bytes: u64,
pub queue_items: usize,
pub binary_bytes: u64,
pub journal_bytes: u64,
}
impl Default for AcceptanceBudgets {
fn default() -> Self {
Self {
startup_millis: 2_000,
control_millis: 250,
chat_schedule_millis: 500,
reconnect_millis: 2_000,
shutdown_millis: 5_000,
steady_memory_bytes: 128 * 1024 * 1024,
peak_memory_bytes: 256 * 1024 * 1024,
queue_items: 8_192,
binary_bytes: 40 * 1024 * 1024,
journal_bytes: 1024 * 1024 * 1024,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AcceptanceStatus {
Passed,
Failed,
NotRun,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AcceptanceRecord {
pub schema_version: u16,
pub sequence: u32,
pub unix_millis: u64,
pub package_version: String,
pub source_revision: String,
pub rust_toolchain: String,
pub command: String,
pub profile: String,
pub grid_type: String,
pub endpoint_capabilities: Vec<String>,
pub stage: String,
pub status: AcceptanceStatus,
pub duration_millis: u64,
pub budget_millis: Option<u64>,
pub outcome_code: String,
pub evidence_sha256: String,
pub metrics: serde_json::Map<String, serde_json::Value>,
pub limitations: Vec<String>,
}
impl AcceptanceRecord {
fn passed(
sequence: u32,
stage: &str,
duration: Duration,
budget: Option<Duration>,
outcome: &str,
metrics: serde_json::Map<String, serde_json::Value>,
) -> Self {
let evidence_sha256 = stable_hash(
format!("{sequence}:{stage}:{outcome}:{}", duration.as_millis()).as_bytes(),
);
Self {
schema_version: ACCEPTANCE_SCHEMA_VERSION,
sequence,
unix_millis: unix_millis(),
package_version: env!("CARGO_PKG_VERSION").into(),
source_revision: option_env!("METACRATE_SOURCE_COMMIT")
.unwrap_or("working-tree-unrecorded")
.into(),
rust_toolchain: "rust-1.97.1-project-pin".into(),
command: "metacrate-grid-agent --acceptance-evidence <new-path>".into(),
profile: "deterministic_offline".into(),
grid_type: "deterministic-fake-opensim-protocol".into(),
endpoint_capabilities: vec!["scripted_openai_compatible".into()],
stage: stage.into(),
status: AcceptanceStatus::Passed,
duration_millis: millis(duration),
budget_millis: budget.map(millis),
outcome_code: outcome.into(),
evidence_sha256,
metrics,
limitations: Vec::new(),
}
}
}
#[derive(Debug)]
pub enum AcceptanceError {
Io(std::io::Error),
Json(serde_json::Error),
UnsafeEvidence(&'static str),
BudgetExceeded {
stage: &'static str,
observed: u64,
budget: u64,
},
Gate(&'static str),
}
impl fmt::Display for AcceptanceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "acceptance evidence I/O failed: {error}"),
Self::Json(error) => write!(formatter, "acceptance evidence JSON failed: {error}"),
Self::UnsafeEvidence(reason) => {
write!(formatter, "unsafe acceptance evidence: {reason}")
}
Self::BudgetExceeded {
stage,
observed,
budget,
} => write!(
formatter,
"acceptance budget exceeded for {stage}: {observed}ms > {budget}ms"
),
Self::Gate(reason) => write!(formatter, "acceptance gate failed: {reason}"),
}
}
}
impl Error for AcceptanceError {}
impl From<std::io::Error> for AcceptanceError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for AcceptanceError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
pub struct AcceptanceEvidenceWriter {
path: PathBuf,
output: BufWriter<File>,
next_sequence: u32,
}
impl AcceptanceEvidenceWriter {
pub fn create(path: impl AsRef<Path>) -> Result<Self, AcceptanceError> {
let path = path.as_ref().to_owned();
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(Self {
path,
output: BufWriter::new(file),
next_sequence: 1,
})
}
pub fn write(&mut self, record: &AcceptanceRecord) -> Result<(), AcceptanceError> {
if record.sequence != self.next_sequence
|| record.schema_version != ACCEPTANCE_SCHEMA_VERSION
{
return Err(AcceptanceError::UnsafeEvidence(
"non-contiguous sequence or schema",
));
}
let encoded = serde_json::to_vec(record)?;
if encoded.len() > MAX_EVIDENCE_RECORD_BYTES {
return Err(AcceptanceError::UnsafeEvidence("record exceeds bound"));
}
let lower = String::from_utf8_lossy(&encoded).to_ascii_lowercase();
if encoded
.windows(ACCEPTANCE_SECRET_CANARY.len())
.any(|window| window == ACCEPTANCE_SECRET_CANARY.as_bytes())
|| [
"password",
"api_key",
"authorization",
"capability_url",
"prompt_body",
]
.iter()
.any(|marker| lower.contains(marker))
{
return Err(AcceptanceError::UnsafeEvidence("secret marker detected"));
}
self.output.write_all(&encoded)?;
self.output.write_all(b"\n")?;
self.output.flush()?;
self.next_sequence += 1;
Ok(())
}
pub fn finish(mut self) -> Result<PathBuf, AcceptanceError> {
self.output.flush()?;
self.output.get_ref().sync_all()?;
Ok(self.path)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[allow(clippy::struct_excessive_bools)]
pub struct LiveGridOptIns {
pub login: bool,
pub chat_and_im: bool,
pub script_delivery: bool,
pub landmarks_and_roaming: bool,
pub reversible_build: bool,
pub visual_capture: bool,
}
impl LiveGridOptIns {
#[must_use]
pub fn from_environment(get: impl Fn(&str) -> Option<String>) -> Self {
Self {
login: confirmed(&get, "METACRATE_AGENT_LIVE_LOGIN", "LOGIN"),
chat_and_im: confirmed(&get, "METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"),
script_delivery: confirmed(&get, "METACRATE_AGENT_LIVE_SCRIPT", "SCRIPT"),
landmarks_and_roaming: confirmed(&get, "METACRATE_AGENT_LIVE_LANDMARKS", "LANDMARKS"),
reversible_build: confirmed(&get, "METACRATE_AGENT_LIVE_BUILD", "BUILD-CLEANUP"),
visual_capture: confirmed(&get, "METACRATE_AGENT_LIVE_VISUAL", "VISUAL"),
}
}
pub fn validate(self) -> Result<Self, AcceptanceError> {
if !self.login && self != Self::default() {
return Err(AcceptanceError::Gate(
"live action opt-ins require the separate login opt-in",
));
}
Ok(self)
}
}
fn confirmed(get: &impl Fn(&str) -> Option<String>, name: &str, literal: &str) -> bool {
get(name).is_some_and(|value| value == literal)
}
struct AcceptanceResponder;
impl InteractionResponder for AcceptanceResponder {
fn respond(&self, _: ResponseRequest, _: CancellationToken) -> ResponderFuture<'_> {
Box::pin(async {
VisibleResponse::new("acceptance response").map_err(|_| InteractionModelError::Failed)
})
}
}
#[allow(clippy::too_many_lines)]
pub async fn run_deterministic_acceptance(
path: impl AsRef<Path>,
budgets: AcceptanceBudgets,
) -> Result<PathBuf, AcceptanceError> {
let mut writer = AcceptanceEvidenceWriter::create(path)?;
let config = AgentConfig::offline(
"https://acceptance.invalid/v1/chat/completions",
ACCEPTANCE_SECRET_CANARY,
)
.map_err(|_| AcceptanceError::Gate("offline config rejected"))?;
let interaction_settings = config.interaction.clone();
let mut metrics = serde_json::Map::new();
metrics.insert(
"grid_queue_limit".into(),
config.limits.grid_event_queue.into(),
);
metrics.insert(
"control_queue_limit".into(),
config.limits.control_queue.into(),
);
metrics.insert(
"observable_queue_limit".into(),
config.limits.observable_queue.into(),
);
if [
config.limits.grid_event_queue,
config.limits.control_queue,
config.limits.observable_queue,
]
.into_iter()
.any(|limit| limit > budgets.queue_items)
{
return Err(AcceptanceError::Gate(
"queue limit exceeds acceptance budget",
));
}
writer.write(&AcceptanceRecord::passed(
1,
"configuration_and_bounds",
Duration::ZERO,
None,
"validated",
metrics,
))?;
let started = Instant::now();
let mut service = AgentService::offline(config)
.map_err(|_| AcceptanceError::Gate("offline composition failed"))?
.start()
.map_err(|_| AcceptanceError::Gate("offline service failed to start"))?;
loop {
let event = tokio::time::timeout(
Duration::from_millis(budgets.startup_millis),
service.next_event(),
)
.await
.map_err(|_| AcceptanceError::BudgetExceeded {
stage: "startup",
observed: budgets.startup_millis + 1,
budget: budgets.startup_millis,
})?
.ok_or(AcceptanceError::Gate("service closed before readiness"))?;
if matches!(event, ObservableEvent::Grid(event) if event.kind == GridEventKind::BackendReady)
{
break;
}
}
let startup = started.elapsed();
check_budget("startup", startup, budgets.startup_millis)?;
writer.write(&AcceptanceRecord::passed(
2,
"headless_startup",
startup,
Some(Duration::from_millis(budgets.startup_millis)),
"ready",
serde_json::Map::new(),
))?;
let controlled = Instant::now();
service
.command(ControlCommand::Pause)
.await
.map_err(|_| AcceptanceError::Gate("pause rejected"))?;
service
.command(ControlCommand::Resume)
.await
.map_err(|_| AcceptanceError::Gate("resume rejected"))?;
let control = controlled.elapsed();
check_budget("control", control, budgets.control_millis)?;
writer.write(&AcceptanceRecord::passed(
3,
"control_conformance",
control,
Some(Duration::from_millis(budgets.control_millis)),
"pause_resume_accepted",
serde_json::Map::new(),
))?;
let conversation = Arc::new(
ConversationStore::open(ConversationLimits::default(), None)
.map_err(|_| AcceptanceError::Gate("conversation composition failed"))?,
);
let interaction_grid = FakeGrid::scripted([]);
let mut interaction = InteractionCoordinator::new(
interaction_settings,
UUID::new_with_u_int64(135).map_err(|_| AcceptanceError::Gate("self UUID failed"))?,
std::collections::BTreeSet::new(),
conversation,
Arc::new(AcceptanceResponder),
Arc::new(interaction_grid),
32,
64,
Duration::from_secs(1),
)
.map_err(|_| AcceptanceError::Gate("interaction composition failed"))?
.start();
interaction
.connected(1)
.map_err(|_| AcceptanceError::Gate("interaction connection failed"))?;
let chat_started = Instant::now();
interaction
.submit(
InboundInteraction::new(
"acceptance-chat",
UUID::new_with_u_int64(136)
.map_err(|_| AcceptanceError::Gate("sender UUID failed"))?,
"Acceptance Avatar",
InteractionChannel::PublicChat,
InboundSource::Avatar,
ImDialogKind::OneToOneMessage,
true,
false,
"metacrate status?",
)
.map_err(|_| AcceptanceError::Gate("acceptance interaction rejected"))?,
)
.await
.map_err(|_| AcceptanceError::Gate("chat scheduling queue rejected"))?;
tokio::time::timeout(Duration::from_millis(budgets.chat_schedule_millis), async {
loop {
if matches!(
interaction.next_observation().await,
Some(InteractionObservation::IntentRouted { .. })
) {
break;
}
}
})
.await
.map_err(|_| AcceptanceError::BudgetExceeded {
stage: "chat_schedule",
observed: budgets.chat_schedule_millis + 1,
budget: budgets.chat_schedule_millis,
})?;
let chat_schedule = chat_started.elapsed();
interaction
.shutdown()
.await
.map_err(|_| AcceptanceError::Gate("interaction shutdown failed"))?;
writer.write(&AcceptanceRecord::passed(
4,
"chat_scheduling",
chat_schedule,
Some(Duration::from_millis(budgets.chat_schedule_millis)),
"intent_routed_before_inference",
serde_json::Map::new(),
))?;
let stopping = Instant::now();
service
.shutdown()
.await
.map_err(|_| AcceptanceError::Gate("service shutdown failed"))?;
let shutdown = stopping.elapsed();
check_budget("shutdown", shutdown, budgets.shutdown_millis)?;
if service.state() != ServiceState::Stopped || service.active_task_count() != 0 {
return Err(AcceptanceError::Gate("headless service leaked tasks"));
}
writer.write(&AcceptanceRecord::passed(
5,
"clean_shutdown",
shutdown,
Some(Duration::from_millis(budgets.shutdown_millis)),
"zero_owned_tasks",
serde_json::Map::new(),
))?;
let grid = FakeGrid::scripted([
GridScriptStep::Ready,
GridScriptStep::MaintenanceDisconnect,
GridScriptStep::Ready,
GridScriptStep::Hold,
]);
let backend: Arc<dyn GridSessionBackend> = Arc::new(grid.clone());
let reconnect_started = Instant::now();
let mut session = SessionSupervisor::new(
backend,
ReconnectPolicy {
initial_delay: Duration::from_millis(10),
maximum_delay: Duration::from_millis(20),
stable_reset_after: Duration::from_secs(1),
shutdown_deadline: Duration::from_secs(1),
jitter_basis_points: 0,
instance_seed: 135,
offline_work_capacity: 16,
},
16,
64,
)
.map_err(|_| AcceptanceError::Gate("session composition failed"))?
.start();
let final_generation =
tokio::time::timeout(Duration::from_millis(budgets.reconnect_millis), async {
let mut first_online = None;
loop {
let Some(SessionObservation::Transition { status, .. }) =
session.next_observation().await
else {
continue;
};
if status.state == SessionState::Online {
if let Some(first) = first_online {
if status.generation > first {
break status.generation;
}
} else {
first_online = Some(status.generation);
}
}
}
})
.await
.map_err(|_| AcceptanceError::BudgetExceeded {
stage: "reconnect",
observed: budgets.reconnect_millis + 1,
budget: budgets.reconnect_millis,
})?;
let reconnect = reconnect_started.elapsed();
session
.shutdown()
.await
.map_err(|_| AcceptanceError::Gate("session shutdown failed"))?;
if grid.active_sessions() != 0 || grid.max_sessions() != 1 {
return Err(AcceptanceError::Gate("session generation leaked"));
}
let mut reconnect_metrics = serde_json::Map::new();
reconnect_metrics.insert("generation".into(), final_generation.into());
reconnect_metrics.insert(
"maximum_parallel_sessions".into(),
grid.max_sessions().into(),
);
writer.write(&AcceptanceRecord::passed(
6,
"maintenance_reconnect",
reconnect,
Some(Duration::from_millis(budgets.reconnect_millis)),
"generation_rolled_over",
reconnect_metrics,
))?;
let corpus = adversarial_corpus();
if corpus.len() < 17 || !corpus.iter().any(|vector| vector.id == "secret-canary") {
return Err(AcceptanceError::Gate(
"adversarial policy corpus incomplete",
));
}
let mut audit_metrics = serde_json::Map::new();
audit_metrics.insert("adversarial_vectors".into(), corpus.len().into());
audit_metrics.insert("protocol_events".into(), grid.evidence().len().into());
writer.write(&AcceptanceRecord::passed(
7,
"policy_redaction_and_protocol_audit",
Duration::ZERO,
None,
"complete",
audit_metrics,
))?;
writer.finish()
}
fn check_budget(
stage: &'static str,
duration: Duration,
budget: u64,
) -> Result<(), AcceptanceError> {
let observed = millis(duration);
if observed > budget {
Err(AcceptanceError::BudgetExceeded {
stage,
observed,
budget,
})
} else {
Ok(())
}
}
fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, millis)
}
fn stable_hash(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest
.iter()
.fold(String::with_capacity(64), |mut output, byte| {
let _ = write!(output, "{byte:02x}");
output
})
}

View File

@@ -4,6 +4,7 @@
//! creates tasks or calls a backend. The core has no signal, terminal, path,
//! subprocess, provider-SDK, or platform-specific dependency.
pub mod acceptance;
pub mod backend;
pub mod behavior;
pub mod build;
@@ -55,6 +56,11 @@ mod tui_tests;
#[cfg(test)]
mod vision_tests;
pub use acceptance::{
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
run_deterministic_acceptance,
};
pub use backend::{
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
WorldMutator,

View File

@@ -36,12 +36,15 @@ enum Operation {
Tui,
TuiClient,
PrintPaths,
Acceptance,
CheckLiveOptIns,
}
#[derive(Default)]
struct Options {
config: Option<PathBuf>,
operation: Operation,
evidence: Option<PathBuf>,
}
fn options() -> Result<Option<Options>, CliError> {
@@ -57,7 +60,7 @@ fn options() -> Result<Option<Options>, CliError> {
}
if argument == "--help" || argument == "-h" {
println!(
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths]\n\
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths | --acceptance-evidence PATH | --check-live-opt-ins]\n\
Configuration precedence: defaults < JSON < secret files < environment.\n\
With no --config, the platform default is used when it exists."
);
@@ -76,6 +79,15 @@ fn options() -> Result<Option<Options>, CliError> {
set_operation(&mut result, Operation::TuiClient)?;
} else if argument == "--print-paths" {
set_operation(&mut result, Operation::PrintPaths)?;
} else if argument == "--acceptance-evidence" {
set_operation(&mut result, Operation::Acceptance)?;
let path = arguments
.next()
.ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?;
count += 1;
result.evidence = Some(PathBuf::from(path));
} else if argument == "--check-live-opt-ins" {
set_operation(&mut result, Operation::CheckLiveOptIns)?;
} else if argument == "--config" {
let path = arguments
.next()
@@ -102,6 +114,7 @@ fn set_operation(options: &mut Options, operation: Operation) -> Result<(), CliE
}
#[tokio::main]
#[allow(clippy::too_many_lines)]
async fn main() -> Result<(), Box<dyn Error>> {
let Some(options) = options()? else {
return Ok(());
@@ -112,6 +125,36 @@ async fn main() -> Result<(), Box<dyn Error>> {
println!("data={}", platform_paths.data_directory.display());
return Ok(());
}
if options.operation == Operation::Acceptance {
let evidence = options
.evidence
.ok_or_else(|| CliError("acceptance evidence path is required".into()))?;
let written = metacrate_grid_agent::run_deterministic_acceptance(
evidence,
metacrate_grid_agent::AcceptanceBudgets::default(),
)
.await?;
println!(
"deterministic acceptance passed; evidence={}",
written.display()
);
return Ok(());
}
if options.operation == Operation::CheckLiveOptIns {
let opt_ins =
metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok())
.validate()?;
println!(
"live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}",
opt_ins.login,
opt_ins.chat_and_im,
opt_ins.script_delivery,
opt_ins.landmarks_and_roaming,
opt_ins.reversible_build,
opt_ins.visual_capture
);
return Ok(());
}
let mut loader = ConfigLoader::new();
let config_path = options.config.or_else(|| {
platform_paths

View File

@@ -0,0 +1,130 @@
use metacrate_grid_agent::{
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
run_deterministic_acceptance,
};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temporary(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"metacrate-acceptance-{}-{name}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[tokio::test]
async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() {
let path = temporary("gate.jsonl");
run_deterministic_acceptance(&path, AcceptanceBudgets::default())
.await
.expect("acceptance gate");
let text = fs::read_to_string(&path).expect("evidence");
assert!(!text.contains(ACCEPTANCE_SECRET_CANARY));
let records = text
.lines()
.map(|line| serde_json::from_str::<AcceptanceRecord>(line).expect("record"))
.collect::<Vec<_>>();
assert_eq!(records.len(), 7);
assert!(
records
.iter()
.all(|record| record.status == AcceptanceStatus::Passed)
);
assert_eq!(
records
.iter()
.map(|record| record.sequence)
.collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5, 6, 7]
);
for required in [
"headless_startup",
"control_conformance",
"chat_scheduling",
"clean_shutdown",
"maintenance_reconnect",
"policy_redaction_and_protocol_audit",
] {
assert!(records.iter().any(|record| record.stage == required));
}
assert!(
run_deterministic_acceptance(&path, AcceptanceBudgets::default())
.await
.is_err(),
"evidence is never overwritten"
);
fs::remove_file(path).unwrap();
}
#[test]
fn live_actions_require_exact_independent_confirmations() {
let mut values = BTreeMap::new();
values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM");
let action_without_login =
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string));
assert!(action_without_login.validate().is_err());
values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN");
values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong");
let confirmed =
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string))
.validate()
.unwrap();
assert!(confirmed.login && confirmed.chat_and_im);
assert!(!confirmed.reversible_build);
}
#[test]
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
let path = temporary("unsafe.jsonl");
let mut writer = AcceptanceEvidenceWriter::create(&path).unwrap();
let record = AcceptanceRecord {
schema_version: ACCEPTANCE_SCHEMA_VERSION,
sequence: 1,
unix_millis: 0,
package_version: "test".into(),
source_revision: "test".into(),
rust_toolchain: "test".into(),
command: "test".into(),
profile: "test".into(),
grid_type: "test".into(),
endpoint_capabilities: Vec::new(),
stage: "canary".into(),
status: AcceptanceStatus::Failed,
duration_millis: 0,
budget_millis: None,
outcome_code: ACCEPTANCE_SECRET_CANARY.into(),
evidence_sha256: "0".repeat(64),
metrics: serde_json::Map::new(),
limitations: Vec::new(),
};
assert!(writer.write(&record).is_err());
drop(writer);
fs::remove_file(path).unwrap();
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let schema =
fs::read_to_string(workspace.join("config/grid-agent-acceptance-evidence.schema.json"))
.unwrap();
let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert_eq!(
schema["properties"]["schema_version"]["const"],
ACCEPTANCE_SCHEMA_VERSION
);
let report = fs::read_to_string(workspace.join("docs/grid-agent-acceptance.md")).unwrap();
for required in [
"Deterministic gate",
"Live-grid matrix",
"Resource budgets",
"Remaining limitations",
"#128",
"#134",
] {
assert!(report.contains(required), "report missing {required}");
}
}

View File

@@ -52,10 +52,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() {
#[test]
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::with_capacity(37);
let mut files = Vec::with_capacity(38);
collect_rust_files(&source, &mut files);
assert!(
files.len() <= 37,
files.len() <= 38,
"source-file count needs a reviewed bound update"
);
for path in files {

View File

@@ -0,0 +1,154 @@
# Grid-agent milestone 14 acceptance
This is the reproducible acceptance contract for the native Rust
`metacrate-grid-agent`. The machine-readable record schema is
[`../config/grid-agent-acceptance-evidence.schema.json`](../config/grid-agent-acceptance-evidence.schema.json).
Evidence is intentionally a summary of observable triggers, policy decisions,
actions, and outcomes; it never contains prompts, message bodies, credentials,
capability URLs, session identifiers, hidden model reasoning, or raw scene data.
## Deterministic gate
Run the complete credential-free package suite and create a new evidence file:
```sh
cargo test --locked -p metacrate-grid-agent --all-features
cargo run --locked -p metacrate-grid-agent -- \
--acceptance-evidence /tmp/metacrate-grid-agent-acceptance.jsonl
```
The writer uses create-new semantics, a 32-KiB record ceiling, contiguous
sequence numbers, schema version 1, a secret-canary scan, per-record flush, and
final filesystem synchronization. It will not replace previous evidence. The
runner exercises the production configuration, `AgentService`, control queue,
session supervisor, reconnect generation fence, shutdown joins, unified fake
grid evidence, and adversarial policy/redaction corpus. The rest of the package
suite supplies the complete scenario matrix, policy registrations, control/TUI
conformance, journal corruption/recovery, conversation recovery, model
transport failures, visual fallback, and bounded-load cases.
The evidence stages are `configuration_and_bounds`, `headless_startup`,
`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and
`policy_redaction_and_protocol_audit`. A successful run has six ordered
`passed` records and leaves zero service tasks, grid sessions, or loopback
sockets. Headless startup never creates a TUI. Integrated and split UI clients
exercise the same versioned control protocol, commands, event model, and
graceful-shutdown target in their conformance tests.
Reference deterministic run on 2026-08-18: all 137 library scenarios, 37
integration scenarios, and one compile-fail documentation case passed with all
features; the seven-stage evidence command passed with zero leaked tasks,
sessions, or sockets. The completed live-grid release binary was 28,857,688
bytes with SHA-256
`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`,
under the 40-MiB budget. The record identifies the package, pinned Rust toolchain,
source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic
command, fake/live profile, grid type, endpoint capability profile, timestamp,
outcome, duration, metrics, and limitations. CI supplies the source commit at
artifact collection time; an ad-hoc dirty-tree run deliberately says
`working-tree-unrecorded` rather than inventing provenance.
## Resource budgets
The checked defaults are deliberately generous enough for loaded CI runners
but small enough to expose deadlocks and unbounded ownership:
| Measurement | Budget | Rationale |
| --- | ---: | --- |
| Offline readiness | 2,000 ms | Local tasks have no network dependency. |
| Control enqueue | 250 ms | Pause/resume must remain interactive under bounded backpressure. |
| Chat scheduling, excluding inference | 500 ms | Includes the intentional 250-ms fragment debounce plus loaded-runner scheduling, without constraining the endpoint. |
| Maintenance reconnect | 2,000 ms | Test policy uses a 10-ms initial backoff; the production default starts at 1 s. |
| Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. |
| Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. |
| Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32512. |
| Release binary | 40 MiB | The completed live-grid release is 28,857,688 bytes. |
| Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. |
Startup, control, reconnect, and shutdown are measured by monotonic time.
Queue limits and task/session high-water marks are asserted directly. Memory,
binary, and journal limits are structural budgets: heap-bearing collections are
bounded at construction, the release artifact is measured in
[the release evidence](grid-agent-release-evidence.md), and journal rotation
enforces its configured byte ceiling. Operators may additionally record RSS
with their platform service manager; RSS is not used as a portable CI assertion
because allocator and OS accounting are not comparable across platforms.
## Live-grid matrix
Live validation is optional and requires a dedicated avatar, controlled land,
and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no
consent. Inspect exact confirmations without contacting either service:
```sh
metacrate-grid-agent --check-live-opt-ins
```
Each capability has a separate exact-value environment opt-in:
| Capability | Variable and required literal |
| --- | --- |
| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` |
| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` |
| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` |
| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` |
| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` |
| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` |
Any action opt-in without the login opt-in fails closed. A misspelled value is
false. Store the grid password and endpoint key in restrictive `_FILE` inputs
described by [the operations guide](grid-agent-operations.md), never in these
variables or a command line.
For an authorized live run, start split mode so another terminal can reconnect
the TUI without affecting the agent session. Record UTC start/end, commit,
binary hash, generic grid type (for example `OpenSim-compatible`), endpoint
capabilities (`text`, `tools`, and optionally `image_input`), and evidence-file
hash. Do not record vendor presets or identifiers. Exercise, in order:
1. Login and full readiness; force a maintenance reconnect and confirm the
generation changes with one session maximum.
2. Public mention gets an informational reply; a public command is denied.
Send unprivileged and authorized IMs and verify their separate sessions,
exact rollover, facing/attention event, and perception queries.
3. Pause, cancel, approve, resume, and force reconnect from the control client;
disconnect/reconnect the TUI and verify the service remains headless-safe.
4. With the script opt-in, deliver only to the controlled recipient and record
the returned inventory ID and permissions. Advanced mutation requires an
explicit approval and conservative script size/runtime limits.
5. With the build opt-in, build only on controlled land, record transaction and
object recovery IDs locally, verify no currency operation exists, and delete
every created prim through the ownership-checked cleanup path.
6. With the landmark opt-in, accept a controlled offer, use a short bounded
folder schedule, teleport, then disable the schedule. With visual opt-in,
capture the synthetic scene and ask one visual question. Record the endpoint
capability fallback if image input is rejected.
7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory
offers, owned test prims, tasks, sockets, or sessions. List any unavoidable
inventory artifact and its manual recovery ID in private operator notes,
never committed evidence.
The structured journal and control/TUI views must show the same correlation IDs
from inbound trigger through inference summary, proposed tool, policy/approval,
execution, and result. This is observable action provenance, not chain-of-thought.
## Milestone handoff
Milestone implementation links: #128 architecture/configuration, #129 LLM and
policy, #130 lifecycle/conversation/interaction, #131 embodiment and world
tools, #132 visual capture and TUI, #133 deterministic protocol harness, and
#134 operations/secret handling. Their focused tests remain the authoritative
compatibility cases; this final gate integrates rather than duplicates them.
Routine Gitea jobs remain `ubuntu-latest` only. Portable target checks are
defined in `ci/release-matrix.json`; a local Windows GNU check additionally
needs `x86_64-w64-mingw32-gcc` for the existing AWS-LC build.
## Remaining limitations
No public CI runner performs live actions, holds credentials, measures portable
RSS, or proves a particular provider's image capability. Live evidence is only
credible when an operator supplies all exact opt-ins and completes the matrix
on a dedicated account. The deterministic gate is therefore the required CI
acceptance record; a live report supplements it and must state any skipped
capability, endpoint fallback, or manually recoverable artifact explicitly.