diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 7acad83..4ecfdb4 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -2995,6 +2995,37 @@ fn apply_operation( state.positions.remove(subject); state.affordance_grants.remove(&scope); } + ResolvedOp::GrantAffordance { subject, affordance } + | ResolvedOp::RevokeAffordance { subject, affordance } => { + let granting = matches!(operation, ResolvedOp::GrantAffordance { .. }); + let scope = DecisionScope { + subject_id: *subject, + }; + let assignment = state.controller_assignments.get(&scope).ok_or_else(unknown)?; + if assignment.mode().is_none() || !state.affordance_catalog.contains_key(affordance) { + return Err(unknown()); + } + let granted = state + .affordance_grants + .get_mut(&scope) + .ok_or_else(unknown)?; + let held = granted.contains(affordance); + if held == granting { + return Err(KernelError::Invariant( + "affordance grant operation changes nothing".into(), + )); + } + if granting { + granted.insert(*affordance); + } else { + if granted.len() == 1 { + return Err(KernelError::Invariant( + "revoke would leave a controlled subject with no affordance".into(), + )); + } + granted.remove(affordance); + } + } } Ok(()) } @@ -4625,12 +4656,17 @@ fn confine_to_ground( } for (index, operation) in resolved.operations.iter().enumerate() { let site = Site::Operation(index); - // Retirement is authority over the subject's standing, not structure - // over ground: a confined author is refused whatever place the - // subject stands under, exactly as `require_ruler` refuses a `Ruled` - // fact or a `Mint` outright. The owner is unconfined and never - // reaches this clause. - if matches!(operation, ResolvedOp::Retire { .. }) { + // Retirement and an affordance grant are authority over the subject's + // standing, not structure over ground: a confined author is refused + // whatever place the subject stands under, exactly as `require_ruler` + // refuses a `Ruled` fact or a `Mint` outright. The owner is unconfined + // and never reaches this clause. + if matches!( + operation, + ResolvedOp::Retire { .. } + | ResolvedOp::GrantAffordance { .. } + | ResolvedOp::RevokeAffordance { .. } + ) { mismatches.push(Mismatch::OutsideJurisdiction { site: site.clone() }); continue; } @@ -4851,12 +4887,14 @@ fn operation_ground( } (subjects, places(named), routes) } - // Confinement never reaches this through the ordinary ground check: - // `confine_to_ground` refuses `Retire` unconditionally for every - // confined author before this function is called for it. The - // subject is named here so the match stays total and correct if - // that clause is ever bypassed. - ResolvedOp::Retire { subject } => (vec![*subject], Vec::new(), Vec::new()), + // Confinement never reaches these through the ordinary ground check: + // `confine_to_ground` refuses `Retire`, `GrantAffordance`, and + // `RevokeAffordance` unconditionally for every confined author before + // this function is called for them. The subject is named here so the + // match stays total and correct if that clause is ever bypassed. + ResolvedOp::Retire { subject } + | ResolvedOp::GrantAffordance { subject, .. } + | ResolvedOp::RevokeAffordance { subject, .. } => (vec![*subject], Vec::new(), Vec::new()), } } @@ -16734,4 +16772,244 @@ mod clock_tests { .retired ); } + + // ---- Cut 5: granting and revoking affordances --------------------- + + fn wave_affordance() -> Declaration { + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new("wave"), + kind: AffordanceKindName("wave".into()), + roles: vec![RoleSpec { + role: Role("target".into()), + kind: RefKind::Subject(None), + }], + preconditions: Vec::new(), + effect_slots: vec![EffectSlot { + op_kind: ComponentOpKind::AdvancePressure { + by: PressureMagnitude(1), + }, + roles: vec![Role("target".into())], + bounds: Bounds::None, + }], + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: vec![0], + }], + carries_speech: false, + }) + } + + /// P5.1: the seed lane is the owner in Draft, and it grants the player a + /// verb the same way any owner-authored `GrantAffordance` does. + #[test] + fn the_seed_grants_the_player_a_verb_in_draft() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "SeedGrantsDraft"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let before = kernel.snapshot().unwrap(); + let commons = before.places[0].id; + let speak = super::tests::speak_entry(&kernel); + submit_owner( + &mut kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: vec![ + wave_affordance(), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("player"), + label: "The Player".into(), + kind: SubjectKind::Person, + controller: NewController::NarrativePersona, + affordances: BTreeSet::from([speak]), + position: Some(Ref::Existing(commons)), + }), + ], + operations: vec![ComponentOp::GrantAffordance { + subject: Ref::Draft(DraftHandle::new("player")), + affordance: Ref::Draft(DraftHandle::new("wave")), + }], + evidence: Vec::new(), + }, + }, + ); + let snapshot = kernel.snapshot().unwrap(); + let player = snapshot + .subjects + .iter() + .find(|subject| subject.label == "The Player") + .expect("the declared player"); + assert_eq!(player.affordances.len(), 2); + } + + /// P5.1, and the scope digest half of Q10's design: a grant and a revoke + /// in Active both move the scope digest, so a stale opportunity is + /// `ScopeChanged` rather than silently honoured. + #[test] + fn the_play_authority_grants_and_revokes_in_active() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, active) = + clock_kernel(directory.path(), "PlayGrantRevoke"); + let stale = opportunity_for(&active, clockwork.reeve); + submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: vec![wave_affordance()], + operations: vec![ComponentOp::GrantAffordance { + subject: Ref::Existing(clockwork.reeve), + affordance: Ref::Draft(DraftHandle::new("wave")), + }], + evidence: Vec::new(), + }, + }, + ) + .expect("the play authority grants a new verb"); + let after_grant = kernel.snapshot().unwrap(); + assert_eq!( + after_grant + .subjects + .iter() + .find(|subject| subject.id == clockwork.reeve) + .unwrap() + .affordances + .len(), + 4 + ); + let error = submit_as( + &mut kernel, + CallerId::Controller(stale.controller_id), + CommandBody::DeclineDecision { + opportunity: stale.clone(), + }, + ) + .unwrap_err(); + assert!(matches!(error, KernelError::ScopeChanged { .. }), "{error:?}"); + + submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::RevokeAffordance { + subject: Ref::Existing(clockwork.reeve), + affordance: Ref::Existing(clockwork.threaten), + }], + evidence: Vec::new(), + }, + }, + ) + .expect("the play authority revokes a held verb"); + let after_revoke = kernel.snapshot().unwrap(); + let reeve = after_revoke + .subjects + .iter() + .find(|subject| subject.id == clockwork.reeve) + .unwrap(); + assert_eq!(reeve.affordances.len(), 3); + assert!(!reeve.affordances.contains(&clockwork.threaten)); + } + + /// P5.2: `derive_opportunities` requires at least one grant; a revoke + /// that would leave the reeve mute is refused before it ever reaches + /// that invariant. + #[test] + fn a_revoke_cannot_leave_a_subject_mute() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RevokeMute"); + let speak = super::tests::speak_entry(&kernel); + for affordance in [clockwork.deliver, clockwork.threaten] { + submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::RevokeAffordance { + subject: Ref::Existing(clockwork.reeve), + affordance: Ref::Existing(affordance), + }], + evidence: Vec::new(), + }, + }, + ) + .expect("the play authority revokes down toward one verb"); + } + let Ref::Existing(speak_id) = speak else { + panic!("speak is a standing affordance"); + }; + let error = submit_as( + &mut kernel, + play_caller(), + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + declarations: Vec::new(), + operations: vec![ComponentOp::RevokeAffordance { + subject: Ref::Existing(clockwork.reeve), + affordance: Ref::Existing(speak_id), + }], + evidence: Vec::new(), + }, + }, + ) + .unwrap_err(); + assert!( + matches!(&error, KernelError::PatchRejected(set) + if set.contains(&Mismatch::WouldLeaveSubjectMute { operation: 0 })), + "{error:?}" + ); + } + + /// P5.1: `confine_to_ground` refuses `GrantAffordance` for a confined + /// author unconditionally, even for a subject this same patch declares + /// and places inside the elaborator's own jurisdiction. + #[test] + fn an_elaborator_cannot_grant() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "GrantElaborator"); + let speak = super::tests::speak_entry(&kernel); + let answered = dead_end_boundary(&kernel); + let error = submit_as( + &mut kernel, + elaborator(JurisdictionKey::PlaceSubtree(clockwork.dead_end)), + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Boundary(answered)), + patch: WorldPatch { + declarations: vec![Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("stray"), + label: "The Roadside Stray".into(), + kind: SubjectKind::Person, + controller: NewController::NarrativePersona, + affordances: BTreeSet::from([speak]), + position: Some(Ref::Existing(clockwork.dead_end)), + })], + operations: vec![ComponentOp::GrantAffordance { + subject: Ref::Draft(DraftHandle::new("stray")), + affordance: Ref::Existing(clockwork.threaten), + }], + evidence: Vec::new(), + }, + }, + ) + .unwrap_err(); + assert!( + matches!(&error, KernelError::PatchRejected(set) + if set.contains(&Mismatch::OutsideJurisdiction { + site: Site::Operation(0) + })), + "{error:?}" + ); + } } diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 84366aa..b0ca996 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -1207,6 +1207,20 @@ pub(crate) enum ComponentOp { Retire { subject: Ref, }, + /// Grants one more standing catalog entry to an existing subject. Only the + /// owner (including the seed lane, the owner in Draft) and `Play` may + /// write it: a grant is authority, not structure, so a confined author is + /// refused whatever ground the subject stands on. + GrantAffordance { + subject: Ref, + affordance: Ref, + }, + /// Revokes a grant. Refused if it would leave a controlled subject with + /// none: `derive_opportunities` requires at least one. + RevokeAffordance { + subject: Ref, + affordance: Ref, + }, } /// What an Active `AdmitPatch` answers. Draft answers nothing; Active must @@ -1586,6 +1600,18 @@ pub enum Mismatch { RetiresAMirror { operation: usize, }, + /// `GrantAffordance` or `RevokeAffordance` named a retired or an + /// `ExternallyControlled` subject: neither holds a grant this patch may + /// touch. + GrantsOutsideControl { + operation: usize, + }, + /// A `RevokeAffordance` that would leave a controlled subject with no + /// affordance at all. `derive_opportunities` requires at least one; a + /// subject is never left mute. + WouldLeaveSubjectMute { + operation: usize, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1883,6 +1909,14 @@ pub(crate) enum ResolvedOp { Retire { subject: SubjectId, }, + GrantAffordance { + subject: SubjectId, + affordance: AffordanceId, + }, + RevokeAffordance { + subject: SubjectId, + affordance: AffordanceId, + }, } /// One resource's movement across a whole patch. Accumulators are `u128` so an @@ -4209,6 +4243,61 @@ pub(super) fn resolve_patch( } } } + ComponentOp::GrantAffordance { subject, affordance } + | ComponentOp::RevokeAffordance { subject, affordance } => { + let granting = matches!(operation, ComponentOp::GrantAffordance { .. }); + let subject_key = resolve_subject( + Site::Operation(position), + subject, + &index, + &state.subjects, + &mut mismatches, + ); + let affordance_key = resolve_affordance( + Site::Operation(position), + affordance, + &index, + &state.affordance_catalog, + &mut mismatches, + ); + let (Some(subject_key), Some(affordance_key)) = (subject_key, affordance_key) + else { + continue; + }; + // A grant is authority over the catalog, not structure over + // ground: retirement and a mirror both hold no grant a patch + // may touch, whatever confinement later decides. + if retired.contains(&subject_key) || mirrors.contains(&subject_key) { + mismatches.push(Mismatch::GrantsOutsideControl { + operation: position, + }); + continue; + } + let held = affordance_holdings + .get(&subject_key) + .is_some_and(|granted| granted.contains(&affordance_key)); + if held == granting { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + continue; + } + if granting { + affordance_holdings + .entry(subject_key) + .or_default() + .insert(affordance_key); + } else { + let granted = affordance_holdings.entry(subject_key).or_default(); + if granted.len() == 1 { + mismatches.push(Mismatch::WouldLeaveSubjectMute { + operation: position, + }); + continue; + } + granted.remove(&affordance_key); + } + } ComponentOp::OpenOffice { institution, office, @@ -5239,6 +5328,20 @@ pub(super) fn resolve_patch( ComponentOp::Retire { subject } => ResolvedOp::Retire { subject: subject_id_of(&key_of(subject)), }, + ComponentOp::GrantAffordance { + subject, + affordance, + } => ResolvedOp::GrantAffordance { + subject: subject_id_of(&key_of(subject)), + affordance: affordance_id_of(&key_of(affordance)), + }, + ComponentOp::RevokeAffordance { + subject, + affordance, + } => ResolvedOp::RevokeAffordance { + subject: subject_id_of(&key_of(subject)), + affordance: affordance_id_of(&key_of(affordance)), + }, ComponentOp::Relocate { subject, via } => ResolvedOp::Relocate { subject_id: subject_id_of(&key_of(subject)), edge_id: edge_id_of(&key_of(via)), @@ -6282,6 +6385,28 @@ pub(crate) const PATCH_TOOLS: &[PatchTool] = &[ fields: &[field("subject", PatchFieldKind::Reference("subject"))], shape: PatchToolShape::Operate { variant: "retire" }, }, + PatchTool { + name: "grant_affordance", + description: "Grant a subject one more affordance from the standing catalog. Only the owner and the play table may grant.", + fields: &[ + field("subject", PatchFieldKind::Reference("subject")), + field("affordance", PatchFieldKind::Reference("affordance")), + ], + shape: PatchToolShape::Operate { + variant: "grant_affordance", + }, + }, + PatchTool { + name: "revoke_affordance", + description: "Revoke one of a subject's affordances. Refused if it would leave the subject with none. Only the owner and the play table may revoke.", + fields: &[ + field("subject", PatchFieldKind::Reference("subject")), + field("affordance", PatchFieldKind::Reference("affordance")), + ], + shape: PatchToolShape::Operate { + variant: "revoke_affordance", + }, + }, PatchTool { name: RECORD_GAP_PATCH_TOOL, description: "Record something the world needs that this vocabulary cannot say. It changes nothing.", @@ -7023,7 +7148,7 @@ mod catalog_tests { .iter() .filter(|entry| matches!(entry.shape, PatchToolShape::Operate { .. })) .count(); - assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 32, 41)); + assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 34, 43)); // Every declaration variant the vocabulary owns is reachable, and the // two payload-carrying entity kinds are not exposed as an `Entity` @@ -7119,6 +7244,8 @@ mod catalog_tests { ComponentOp::ReducePressure { .. } => "reduce_pressure", ComponentOp::ResolvePressure { .. } => "resolve_pressure", ComponentOp::Retire { .. } => "retire", + ComponentOp::GrantAffordance { .. } => "grant_affordance", + ComponentOp::RevokeAffordance { .. } => "revoke_affordance", } } PATCH_TOOLS @@ -7170,7 +7297,7 @@ mod catalog_tests { .count(); assert_eq!( (declare_tools, operate_tools, PATCH_TOOLS.len()), - (7, 32, 41) + (7, 34, 43) ); let declarations = every_declaration(); diff --git a/sidecar/claude-sdk/test/schemas.json b/sidecar/claude-sdk/test/schemas.json index c3b955c..5de98dc 100644 --- a/sidecar/claude-sdk/test/schemas.json +++ b/sidecar/claude-sdk/test/schemas.json @@ -95,6 +95,10 @@ "name": "forget", "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"fact\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing fact\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"fact\"],\"type\":\"object\"}" }, + { + "name": "grant_affordance", + "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"affordance\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing affordance\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"affordance\"],\"type\":\"object\"}" + }, { "name": "grant_authority", "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"grant\":{\"additionalProperties\":false,\"properties\":{\"kind\":{\"description\":\"the authority kind\",\"type\":\"string\"},\"over\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"subject\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"place_subtree\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"}]}},\"required\":[\"kind\",\"over\"],\"type\":\"object\"},\"holder\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"holder\",\"grant\"],\"type\":\"object\"}" @@ -147,6 +151,10 @@ "name": "retire", "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\"],\"type\":\"object\"}" }, + { + "name": "revoke_affordance", + "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"affordance\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing affordance\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"affordance\"],\"type\":\"object\"}" + }, { "name": "revoke_authority", "parameters_json": "{\"additionalProperties\":false,\"properties\":{\"grant\":{\"additionalProperties\":false,\"properties\":{\"kind\":{\"description\":\"the authority kind\",\"type\":\"string\"},\"over\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"subject\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"place_subtree\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"}]}},\"required\":[\"kind\",\"over\"],\"type\":\"object\"},\"holder\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"holder\",\"grant\"],\"type\":\"object\"}"