Continue the conversation on refusal, and give the claude lane its descriptions

The first SDK-backed seeded runs. Run 1 refused both seed sessions: the
sidecar's schema conversion dropped every field description, `due` was an
undescribed integer against a clock the model never saw, and the subject
tool did not say a grant is required. Run 2 seeded six people in one round
and ticked three times.

A refusal used to reopen the session with an empty draft and a prompt that
named sites from a draft the model could no longer see. It now continues
the same conversation: `Refusal { after_round, mismatches }` on both
authoring checkpoints (controller_work.v12), the refusal as the next user
turn, and the draft rebuilt by one DraftFold shared by evaluator and oracle
from the calls after it. The harness takes the connector only when its
address is set, so an all-claude run needs none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-10 23:50:28 +02:00
commit 936aa71b8d
12 changed files with 546 additions and 235 deletions

View file

@ -2470,9 +2470,9 @@ mod tests {
/// A real CodexConnector behind the fixture, read from the environment
/// the production runtime reads. Only the ignored live smoke uses it.
struct LiveController {
endpoint: SocketAddr,
credential: PathBuf,
runtime_id: String,
/// Absent when every lane routes to the sidecar; `open_inference`
/// refuses a model neither transport can carry.
connector: Option<ConnectorBinding>,
/// Present only when the smoke is run against the Claude SDK sidecar
/// instead of, or alongside, the connector.
sdk: Option<SdkBinding>,
@ -2503,9 +2503,11 @@ mod tests {
let controller_key = directory.path().join("controller.key");
std::fs::write(&controller_key, "runtime-test-controller-key").unwrap();
let live = live.unwrap_or_else(|| LiveController {
endpoint: "127.0.0.1:9".parse().unwrap(),
credential: controller_key.clone(),
runtime_id: "ghostlight-runtime-test".into(),
connector: Some(ConnectorBinding {
endpoint: "127.0.0.1:9".parse().unwrap(),
key_path: controller_key.clone(),
caller_runtime_id: "ghostlight-runtime-test".into(),
}),
sdk: None,
models: ControllerModels {
projector: "gpt-5.6-luna".into(),
@ -2515,16 +2517,7 @@ mod tests {
elaborator: "gpt-5.6-terra".into(),
},
});
let inference = open_inference(
Some(ConnectorBinding {
endpoint: live.endpoint,
key_path: live.credential.clone(),
caller_runtime_id: live.runtime_id.clone(),
}),
live.sdk,
&live.models,
)
.unwrap();
let inference = open_inference(live.connector, live.sdk, &live.models).unwrap();
let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap();
let controllers =
ControllerRunner::open(world.clone(), inference, work, live.models).unwrap();
@ -3625,9 +3618,13 @@ mod tests {
let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is required"));
let runtime_id = env("GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID");
let live = LiveController {
endpoint: env("GHOSTLIGHT_CONTROLLER_CONNECTOR").parse().unwrap(),
credential: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")),
runtime_id: runtime_id.clone(),
connector: std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR")
.ok()
.map(|endpoint| ConnectorBinding {
endpoint: endpoint.parse().unwrap(),
key_path: PathBuf::from(env("GHOSTLIGHT_CONTROLLER_CREDENTIAL")),
caller_runtime_id: runtime_id.clone(),
}),
sdk: std::env::var_os("GHOSTLIGHT_SDK_SIDECAR")
.map(PathBuf::from)
.map(|sidecar_entry| SdkBinding {

View file

@ -72,8 +72,8 @@ pub(super) const CELL_TOOL_STEP_BUDGET: usize = 2;
/// carried by tool identity, never by a model-written argument.
const HANDLE_SEPARATOR: &str = "__";
const PERSONA_WORD_BUDGET: usize = 180;
const CONTROLLER_WORK_ROW: &str = "controller_work.v11";
const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v11";
const CONTROLLER_WORK_ROW: &str = "controller_work.v12";
const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v12";
/// The Interpreter's byte-span capture tool. It is not the generated `speak`
/// affordance tool: one captures an utterance out of preserved prose, the other
@ -704,9 +704,9 @@ impl ControllerWork {
}
Self::Elaboration(ElaborationCheckpoint::ElaboratorInFlight {
completed,
last_mismatches,
refusals,
..
}) => completed.is_empty() && last_mismatches.is_empty(),
}) => completed.is_empty() && refusals.is_empty(),
Self::Seed(checkpoint) => checkpoint.is_initial(),
_ => false,
}
@ -8741,32 +8741,41 @@ mod tests {
// The kernel's complete set is what the checkpoint carries, under the
// same command id, and it is not empty.
let (command_id, mismatches, resumed_prompt) = {
// The refusal keeps the round that earned it and follows it as the
// next user turn, so the sites it names are calls the model can see.
let (command_id, mismatches, resumed_input) = {
let stored = store.work.lock().unwrap();
assert_eq!(stored.len(), 1, "one session, one row");
let ControllerWork::Elaboration(ElaborationCheckpoint::ElaboratorInFlight {
command_id,
last_mismatches,
agent_prompt,
refusals,
completed,
invocation,
..
}) = stored.values().next().unwrap().clone()
else {
panic!("the rejection did not reopen the session for repair");
};
assert!(!last_mismatches.is_empty(), "the repair set is empty");
assert!(completed.is_empty(), "a rejected round kept its evidence");
(command_id, last_mismatches, agent_prompt)
assert_eq!(completed.len(), 1, "the refused round was not kept");
assert_eq!(refusals.len(), 1, "one refusal reopens the session");
assert_eq!(refusals[0].after_round, 1);
assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty");
(
command_id,
refusals[0].mismatches.clone(),
serde_json::to_string(&invocation.invocation.request.input).unwrap(),
)
};
assert!(
resumed_prompt.contains("Your previous patch was refused"),
"{resumed_prompt}"
resumed_input.contains("The world refused the patch you submitted"),
"{resumed_input}"
);
for mismatch in &mismatches {
let rendered = serde_json::to_string(mismatch).unwrap();
let rendered = serde_json::to_string(&serde_json::to_string(mismatch).unwrap()).unwrap();
let rendered = rendered.trim_matches('"');
assert!(
resumed_prompt.contains(&rendered),
"the round-two prompt dropped {rendered}"
resumed_input.contains(rendered),
"the round-two conversation dropped {rendered}"
);
}
@ -8801,11 +8810,12 @@ mod tests {
);
// The wire agrees with the checkpoint: the second invocation carried
// the repaired prompt.
// the refused round and the refusal turn after it.
let seen = script.seen.lock().unwrap();
assert_eq!(seen.len(), 2, "two rounds, two invocations");
let second_wire = serde_json::to_string(&seen[1]).unwrap();
assert!(second_wire.contains("previous patch was refused"));
assert!(second_wire.contains("The world refused the patch you submitted"));
assert!(second_wire.contains("The Roadside Shed"), "the refused round left the wire");
drop(second);
drop(mailbox);
@ -10458,26 +10468,32 @@ mod tests {
assert_eq!(first.step().await.unwrap(), SeedOutcome::Rejected);
drop(first);
let (command_id, prompt) = {
let (command_id, prompt, input) = {
let stored = store.work.lock().unwrap();
assert_eq!(stored.len(), 1, "one session, one row");
let ControllerWork::Seed(SeedCheckpoint::SeedInFlight {
command_id,
last_mismatches,
refusals,
agent_prompt,
completed,
invocation,
..
}) = stored.values().next().unwrap().clone()
else {
panic!("the rejection did not reopen the session for repair");
};
assert!(!last_mismatches.is_empty(), "the repair set is empty");
assert!(completed.is_empty(), "a rejected round kept its evidence");
(command_id, agent_prompt)
assert_eq!(refusals.len(), 1, "one refusal reopens the session");
assert!(!refusals[0].mismatches.is_empty(), "the repair set is empty");
assert_eq!(completed.len(), 1, "the refused round was not kept");
(
command_id,
agent_prompt,
serde_json::to_string(&invocation.invocation.request.input).unwrap(),
)
};
assert!(
prompt.contains("Your previous patch was refused"),
"{prompt}"
input.contains("The world refused the patch you submitted"),
"{input}"
);
assert!(prompt.contains("You are seeding a world before it opens"));
assert!(prompt.contains("holds a goal commitment"));
@ -11408,28 +11424,28 @@ mod tests {
command_id,
session,
agent_prompt,
last_mismatches,
refusals,
completed,
invocation,
} = repair.clone()
else {
panic!("a rejection must reopen the session in flight");
};
assert!(!last_mismatches.is_empty());
assert!(completed.is_empty());
assert_eq!(refusals.len(), 1);
assert_eq!(completed.len(), 1);
let ready = SeedCheckpoint::ReadyToSubmit {
command_id,
session: session.clone(),
agent_prompt: agent_prompt.clone(),
last_mismatches: last_mismatches.clone(),
refusals: refusals.clone(),
completed: completed.clone(),
};
let unrepaired = SeedCheckpoint::SeedInFlight {
command_id,
session: session.clone(),
agent_prompt: agent_prompt.clone(),
last_mismatches: Vec::new(),
refusals: Vec::new(),
completed: completed.clone(),
invocation: invocation.clone(),
};
@ -11437,6 +11453,7 @@ mod tests {
command_id,
session: session.clone(),
agent_prompt: agent_prompt.clone(),
refusals: refusals.clone(),
completed: completed.clone(),
gaps: Vec::new(),
};
@ -11448,7 +11465,7 @@ mod tests {
moved
},
agent_prompt,
last_mismatches,
refusals,
completed,
invocation,
};

File diff suppressed because it is too large Load diff

View file

@ -1162,7 +1162,7 @@ pub(crate) enum PatchAnswer {
Deficit(JurisdictionKey),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct WorldPatch {
pub(crate) declarations: Vec<Declaration>,
@ -5429,7 +5429,7 @@ pub(crate) const SUBMIT_PATCH_TOOL: &str = "submit";
pub(crate) const PATCH_TOOLS: &[PatchTool] = &[
PatchTool {
name: "declare_subject",
description: "Declare a new subject: a person, an institution, or a population.",
description: "Declare a new subject: a person, an institution, or a population. Every subject needs at least one affordance grant, by the bracketed id of a standing affordance or the handle of one declared in this patch; a subject with none cannot act and is refused.",
fields: &[
field("handle", PatchFieldKind::Handle),
field("label", PatchFieldKind::Label),
@ -5977,7 +5977,10 @@ fn field_schema(kind: PatchFieldKind) -> Value {
PatchFieldKind::Quantity => tool_schema::bounded_integer(1, u64::MAX),
PatchFieldKind::Cost => tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST)),
PatchFieldKind::Magnitude => tool_schema::bounded_integer(1, u64::from(u32::MAX)),
PatchFieldKind::Minutes => tool_schema::bounded_integer(0, u64::MAX),
PatchFieldKind::Minutes => tool_schema::described(
tool_schema::bounded_integer(0, u64::MAX),
"a reading of the world clock in fictional minutes; the prompt states the current reading, and a due must be later than it",
),
PatchFieldKind::OptionalPeriod => {
tool_schema::nullable(tool_schema::bounded_integer(1, u64::from(MAX_ROUTE_COST)))
}

View file

@ -995,7 +995,7 @@ mod tests {
let prepared = seed_prepared(&port, prompt);
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let output = port.infer(prepared).await.expect("the seed query returns");
@ -1036,7 +1036,7 @@ mod tests {
let prepared = seed_prepared(&port, "Author the shortfall.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let fault = port
.infer(prepared)
@ -1137,7 +1137,7 @@ mod tests {
let prepared = seed_prepared(&port, "Author the shortfall.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let fault = port
.infer(prepared)
@ -1168,7 +1168,7 @@ mod tests {
let prepared = seed_prepared(&port, "Author the shortfall.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let output = port
.infer(prepared)
@ -1510,7 +1510,7 @@ mod tests {
let prepared = prose_prepared(&port, "Say something true.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let fault = port
.infer(prepared)
@ -1528,7 +1528,7 @@ mod tests {
.expect("the stale invocation builds");
port.lend_tool_results(
&stale,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
assert!(port.infer(stale.clone()).await.is_err());
assert!(
@ -1540,7 +1540,7 @@ mod tests {
let other = SdkInferencePort::new(ScriptedLink::new(Vec::new()), "ghostlight-other");
other.lend_tool_results(
&stale,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let fault = other
.infer(stale)
@ -1562,7 +1562,7 @@ mod tests {
let prepared = seed_prepared(&port, "Author the shortfall.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
port.infer(prepared).await.expect("the empty query returns");
assert!(port.oracles.lock().unwrap().is_empty());
@ -1656,7 +1656,7 @@ mod tests {
let prepared = seed_prepared(&port, "Author the shortfall.");
port.lend_tool_results(
&prepared,
Box::new(ElaborationOracle::new(&[], SEED_ROUND_BUDGET)),
Box::new(ElaborationOracle::new(&[], &[], SEED_ROUND_BUDGET)),
);
let fault = port
.infer(prepared)

View file

@ -47,6 +47,15 @@ pub(super) fn bounded_integer(min: u64, max: u64) -> Value {
json!({"type": "integer", "minimum": min, "maximum": max})
}
/// The same schema with the text the model reads beside it. Meaning that lives
/// only in a prompt paragraph is lost by the time the model fills the field.
pub(super) fn described(mut schema: Value, description: &str) -> Value {
if let Value::Object(fields) = &mut schema {
fields.insert("description".into(), Value::String(description.into()));
}
schema
}
pub(super) fn name_enum(values: &[&str]) -> Value {
json!({"type": "string", "enum": values})
}

View file

@ -186,8 +186,12 @@ tool surface is `PATCH_TOOLS`: one tool per non-genesis `Declaration` and
`world/tool_schema.rs` from one schema spelling per type.
`world/elaboration.rs` owns the repair loop: a session keyed by a
deterministic command id (sha256 over world, jurisdiction, answer digest)
submits a draft, persists the resolver's complete mismatch set under that id,
and re-prompts with it, bounded by `ELABORATION_ROUND_BUDGET` and the
submits a draft, persists the resolver's complete mismatch set under that id
as a `Refusal` placed after the rounds that earned it, and continues the same
conversation: the refusal is the next user turn, the model's own calls stay in
view above it so every mismatch site names something it can see, and the draft
is rebuilt from the calls after the refusal. Both authoring lanes share this
fold, bounded by `ELABORATION_ROUND_BUDGET` and the
`MAX_DRAFT_*` size caps. Evidence a model cites must come from the round's
`EvidenceSource` receipts (`filter_evidence`); with `NullEvidenceSource` no
canonical fact and no `Admit` can land from the elaborator lane.
@ -282,7 +286,7 @@ knowledge row, and never another subject's snapshot. The re-lowering's
provider request is a distinct round (`interpreter_round`) with its own
content-addressed request id, so it cannot collide with the first lowering's
request. State schema is `ghostlight.world_state.consumer.v2`, commit schema
`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v11`,
`ghostlight.world_commit.consumer.v2`, controller work `controller_work.v12`,
Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and
earlier rows are refused. Ghostlight owns a conserved narrative ledger;
Delvehold owns the economy (`delvehold-forced-ontology-integration.md`).

View file

@ -610,6 +610,23 @@ pass-through; the decoder reports it as missing. The result-subtype mapping
(`error_max_turns` is an output; every error subtype a named fault) is
tested in the sidecar without a credential.
Road follow-ups, 2026-09-10, from the first two SDK-backed seeded runs
(`notes/local-live-smoke.md`, "Claude SDK route"): the sidecar's schema
conversion now carries every field `description` through to the advertised
tool schema (it dropped all of them, so the `claude` lane saw bare types);
`due` describes itself as a clock reading and both authoring prompts print
the clock, because the model wrote twelve commitments due at or before a
clock it was never shown; `declare_subject` states the grant rule at the
point of use; `apply_tool_call` logs each raw tool call at debug so a refused
patch is diagnosed from what the model sent. A kernel refusal now continues
the same conversation instead of opening a fresh draft: `Refusal { after_round,
mismatches }` rows on both authoring checkpoints (`controller_work.v12`;
prior rows refused, not migrated), the refusal rendered as the next user turn
after the rounds that earned it, and the draft rebuilt by one `DraftFold`
shared by the evaluator and the oracle from the calls after it. The harness
takes the connector binding only when `GHOSTLIGHT_CONTROLLER_CONNECTOR` is
set, so an all-`claude` run needs no connector.
## Subtraction budget
Prefer deletion, collapse, or reuse before adding surfaces. The replacement

View file

@ -194,10 +194,27 @@ vault plus either a running CodexConnector or a built Claude SDK sidecar
with an ambient Claude Code login; see `GHOSTLIGHT_SMOKE_*` and
`GHOSTLIGHT_SDK_*` environment."
This route has not been run. This machine has neither the Claude Code CLI on
PATH nor a stored credential, so nothing beyond the scripted-link unit tests
in `world/sdk_inference.rs` and the checked-in schema/frame fixtures has
exercised it.
### SDK runs, 2026-09-10
Every lane on `claude-sonnet-5`, Kalsa `Public`, Low Sere, target six, two
seed sessions, three ticks. The operator installed the CLI and logged in
interactively; the sidecar inherited the login. No connector variables were
set.
| Run | Seed | Ticks | Result |
|---|---|---|---|
| 1 | both sessions refused: subjects with no grant, twelve commitments due at or before the clock | 3 committed, 2 cells each, Persona spoke | harness failed on the empty seed; the transport itself worked end to end |
| 2 | one session, one round, 89 s: six qualified persons, deficit 6 to 0 | 456 s, 395 s, 326 s; 8 singleton cells each; revision 3 to 24 | passed; tick 3 quarantined one cell |
Rules run 1 imposed are in the implementation plan under step 11 (descriptions
carried through the sidecar, `due` described and the clock printed, the grant
rule on the subject tool, raw tool calls logged, refusal continues the
conversation). Run 2's two findings: three of eight speech captures were cut
mid-word ("just t", "a?\" I") because the Interpreter cited byte spans the
model counted wrong, and the tick-3 quarantine was a mis-spanned utterance
failing the canonical-text rule at invocation, which is a translation gap
wearing an infrastructure fault's coat. Ticks are slow because eight singleton
cells run in sequence at roughly 50 s per cell on this lane.
## Interrupted cell

View file

@ -34,7 +34,19 @@ function keep(schema: z.ZodTypeAny): z.ZodTypeAny {
return schema.catch(undefined as never);
}
/**
* The text the emitter put beside a field is the model's only account of what
* the field means; the first SDK road run lost every description here and the
* model filled `due` blind.
*/
function convert(node: unknown, at: string): z.ZodTypeAny {
const converted = convertShape(node, at);
return isObject(node) && typeof node.description === "string"
? converted.describe(node.description)
: converted;
}
function convertShape(node: unknown, at: string): z.ZodTypeAny {
if (!isObject(node)) {
throw new Error(`${at}: schema node is not an object`);
}

View file

@ -7,6 +7,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { z } from "zod";
import { toolInputSchema } from "../src/schema.ts";
const emitted: { name: string; parameters_json: string }[] = JSON.parse(
@ -24,6 +25,26 @@ test("every emitted tool schema converts", () => {
}
});
test("a property's description reaches the advertised schema", () => {
const schema = toolInputSchema(
JSON.stringify({
type: "object",
additionalProperties: false,
required: ["due", "kind"],
properties: {
due: { type: "integer", minimum: 0, maximum: 9, description: "a clock reading" },
kind: { type: "string", enum: ["a"], description: "which shape" },
},
}),
"described",
);
const advertised = z.toJSONSchema(schema, { io: "input" }) as {
properties: Record<string, { description?: string }>;
};
assert.equal(advertised.properties.due?.description, "a clock reading");
assert.equal(advertised.properties.kind?.description, "which shape");
});
test("a converted schema carries every property's own type", () => {
const schema = toolInputSchema(
JSON.stringify({

File diff suppressed because one or more lines are too long