feat(skills): deliver on every tier, record what agents receive, let them self-author

Three phases of the approved plan, plus a correction to what the last one
claimed.

CORRECTION: skills reached ONE tier, not all of them

The previous commit said "skills can now reach a mission agent". That was
true only for the container/ZeroClaw tier — the fall-through that queues a
topology_runs row for topology_worker, which drives the executor that was
patched. compose_turn_prompt/pinned_skills_text had exactly one production
caller, and phase_runner's three other paths (composed microVM, solo
microVM, direct session) never called it. CAPABILITY-REVIEW.md said the
broad thing too; both are corrected.

Those three tiers share one task string and have no per-turn alias, so
their skills resolve per PHASE from the mission's crew and are appended
there. The container tier deliberately still injects per turn, with the
running node's own role — appending in both places would put every crew
member's skills in every turn twice.

The behavioural tests prove phase_skills_text and compose_turn_prompt work.
They cannot prove the three launch_* calls pass the composed string, and
that substitution is a one-word edit that would silently return all three
tiers to delivering nothing with every test still green. So there is also a
source-level assertion on the call sites, following the precedent in
mission_events::the_cap_is_enforced_in_one_statement. Its negative control
names the exact tier.

PROVENANCE: what an agent received, and what it said it did

Both were unanswerable. The prompt was never stored anywhere on any tier —
re-deriving it later re-runs the skill lookup against a catalogue that has
since changed, and once agents author their own skills it certainly will
have. The reasoning rows were durably write-only: pushed live once, then
never read from the database again by anything except the GC that deletes
them.

  - prompt.composed records the exact bytes, on all four tiers
  - the session tier writes its checkpoint record and a reasoning row,
    instead of eprintln! and nothing — the same defect the solo microVM
    path was fixed for, in the last tier that still had it
  - narrative_for_mission reads both back

Found while doing it: the 400-event per-phase cap counted EVERY kind, so a
busy phase could push out its own phase.completed and its own provenance.
The cap now counts only the two unbounded kinds it was written for.
Negative control confirms the old behaviour dropped the prompt.

Retention is now a per-mission hold (0080) rather than a raised global —
with a test asserting unheld missions are still reaped, because an
exemption that applies to everything is not an exemption.

SELF-AUTHORING: agents apply their own skill drafts, no human click

By operator decision. level_up has generated complete drafts from a model
since it shipped; only a checkbox stood between propose and apply.

What replaces the gate is not another gate but four properties, each held
by a test:

  - workspace-scoped, so a hand-authored skill can never be modified
  - a draft cannot take a hand-authored skill's name. Ids are scoped and
    bindings resolve by skill_id, so it could not overwrite or shadow one
    anyway — but two procedures under one name means nobody reading a
    transcript can tell which the agent followed, and that ambiguity is
    fatal in a system where the skill is the standard being graded against
  - every revision appends a skill_versions row, so it can be reverted and
    a past run can be read against the text it was actually judged under
  - approved_by = NULL. An agent's decision is never attributed to a person
    who did not make it

Only skill_candidate applies autonomously. identity_refinement and
brain_consolidation still wait for a human: they change what an agent IS
rather than adding a procedure it can consult. State is announced at boot,
because a safety gate that changes silently is one nobody notices changed.
CLAWMATES_SKILL_SELF_AUTHORING=0 restores it.

Also: the test Postgres ran out of /dev/shm mid-suite (Docker's 64MB
default) and surfaced it during MIGRATIONS, which reads like a schema fault
and is not one. --shm-size=1g, and a pointer to the `clean` subcommand that
already existed for the 779 leaked test databases.

Full workspace suite green: 106 binaries, no failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 10:24:35 -07:00
co-authored by Claude Opus 5
parent e3247fee4b
commit 769e002bb3
15 changed files with 1202 additions and 21 deletions
+174
View File
@@ -0,0 +1,174 @@
//! What an agent received, and what it said it did, must survive the phase.
//!
//! `docs/PROVENANCE-ASSESSMENT.md` records "why did the agent say X?" as
//! unanswerable. The first two things it needs are the prompt and the
//! narrative. Before this, the prompt was never stored at all, and the
//! narrative was stored and then read by nothing — both database readers in
//! `routes/world.rs` filter to `tool.call`/`file.touch`, and the only other
//! statement touching the table is the GC that deletes it.
use cm_api::mission_events::{self, MissionEvent, PER_PHASE_CAP, PROMPT_COMPOSED, REASONING, TOOL_CALL};
use cm_domain::{Workspace, WorkspaceId};
use uuid::Uuid;
async fn seed_phase(pool: &sqlx::PgPool) -> (Uuid, Uuid) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Provenance Test".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let mission = Uuid::now_v7();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, status)
VALUES ($1, $2, 'provenance', 'research_only', 'running')",
)
.bind(mission)
.bind(ws.id.as_uuid())
.execute(pool)
.await
.unwrap();
let phase = Uuid::now_v7();
sqlx::query(
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status)
VALUES ($1, $2, 'research', 0, 'running')",
)
.bind(phase)
.bind(mission)
.execute(pool)
.await
.unwrap();
(mission, phase)
}
#[tokio::test]
async fn the_prompt_and_the_narrative_are_both_readable_after_the_fact() {
let pool = cm_testkit::test_pool().await;
let (mission, phase) = seed_phase(&pool).await;
let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED);
prompt.phase_id = Some(phase);
prompt.target = Some("researcher".into());
prompt.detail = serde_json::json!({ "text": "Task: read the papers\n## arxiv-daily\nDo not re-search." });
mission_events::record(&pool, prompt).await;
let mut said = MissionEvent::new(mission, REASONING);
said.phase_id = Some(phase);
said.detail = serde_json::json!({ "text": "I read the manifest and wrote analysis.md." });
mission_events::record(&pool, said).await;
let narrative = mission_events::narrative_for_mission(&pool, mission)
.await
.unwrap();
let prompt_text = narrative
.iter()
.find(|(kind, ..)| kind == PROMPT_COMPOSED)
.map(|(.., text)| text.clone())
.expect("the prompt must be recoverable — re-deriving it later re-runs \
the skill lookup against a catalogue that will have changed");
assert!(prompt_text.contains("Do not re-search."));
assert!(
prompt_text.contains("Task: read the papers"),
"the whole composed prompt, not just the skills half"
);
assert!(
narrative
.iter()
.any(|(kind, .., text)| kind == REASONING && text.contains("analysis.md")),
"the agent's own account must come back out of the database"
);
}
#[tokio::test]
async fn a_busy_phase_cannot_push_out_its_own_provenance() {
let pool = cm_testkit::test_pool().await;
let (mission, phase) = seed_phase(&pool).await;
// Fill the phase past the cap with the kind the cap exists to bound.
for i in 0..(PER_PHASE_CAP + 20) {
let mut ev = MissionEvent::new(mission, TOOL_CALL);
ev.phase_id = Some(phase);
ev.target = Some(format!("tool_{i}"));
mission_events::record(&pool, ev).await;
}
let tool_rows: i64 = sqlx::query_scalar(
"SELECT count(*) FROM mission_events WHERE phase_id = $1 AND kind = $2",
)
.bind(phase)
.bind(TOOL_CALL)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
tool_rows, PER_PHASE_CAP,
"the cap must still bound the kind it was written for"
);
// The prompt arrives AFTER the flood, which is the real ordering: a coding
// phase calls its tools and then the next turn is composed.
let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED);
prompt.phase_id = Some(phase);
prompt.detail = serde_json::json!({ "text": "the next turn's prompt" });
mission_events::record(&pool, prompt).await;
let narrative = mission_events::narrative_for_mission(&pool, mission)
.await
.unwrap();
assert!(
narrative.iter().any(|(.., text)| text == "the next turn's prompt"),
"a phase that called {} tools dropped its own prompt — the cap counted \
provenance against a budget meant for the two unbounded kinds, so the \
busier the phase, the less of it is explainable",
PER_PHASE_CAP + 20
);
}
#[tokio::test]
async fn a_mission_under_measurement_keeps_its_events_past_the_window() {
let pool = cm_testkit::test_pool().await;
let (kept, kept_phase) = seed_phase(&pool).await;
let (reaped, reaped_phase) = seed_phase(&pool).await;
// Only one of them is held.
sqlx::query("UPDATE missions SET retain_events_until = now() + interval '30 days' WHERE id = $1")
.bind(kept)
.execute(&pool)
.await
.unwrap();
for (mission, phase) in [(kept, kept_phase), (reaped, reaped_phase)] {
let mut ev = MissionEvent::new(mission, PROMPT_COMPOSED);
ev.phase_id = Some(phase);
ev.detail = serde_json::json!({ "text": "the prompt" });
mission_events::record(&pool, ev).await;
}
// Age both beyond the global window.
sqlx::query("UPDATE mission_events SET created_at = now() - interval '90 days'")
.execute(&pool)
.await
.unwrap();
let mut out = cm_api::mission_gc::Reclaimed::default();
cm_api::mission_gc::reap_mission_events(&pool, &mut out).await;
assert!(
!mission_events::narrative_for_mission(&pool, kept)
.await
.unwrap()
.is_empty(),
"a mission held for measurement lost its events — the evidence expires \
while the question is still open, and 'no events' reads exactly like \
'nothing happened'"
);
assert!(
mission_events::narrative_for_mission(&pool, reaped)
.await
.unwrap()
.is_empty(),
"an unheld mission must still be reaped — an exemption that applies to \
everything is not an exemption, it is a raised global bound"
);
}