From 53a27e4909bf7f1d6699de81ae2a09e95bb7affc Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 19:30:40 +0100 Subject: [PATCH] Native source enforcement: forced writes carry debt, publication converges, audit mode Owned-set revisions carry a source policy (enforce off|audit|enforce, publisher, namespaces, unmanaged-allow). One gate covers apply, create in a claimed namespace, stop, start, mission publish/retire, hold, suspend, resume and retire. --force --reason records source debt in the write's transaction; the set's next publication closes every debt and reverts forced holds, suspensions and mission declarations. Audit mode records would-be refusals. --- .../Sources/FractalClient/Models.swift | 3 + .../fractal-client/Models.generated.ts | 1 + .../fractal-client/ReadSchema.generated.ts | 1 + .../fractal-client/Schema.generated.ts | 3 +- .../src/api/client_v0.rs | 4 +- crates/fractal-api-core/src/api.rs | 108 +- crates/fractal-api-core/src/api/end_reason.rs | 2 +- crates/fractal-api-core/src/api/launches.rs | 2 +- crates/fractal-api-core/src/api/owned_sets.rs | 18 + .../src/api/published_lists_tests.rs | 2 +- crates/fractal-api-core/src/api/tests.rs | 9 +- crates/fractal-api-core/src/delivery_hold.rs | 4 + crates/fractal-api-mailbox/src/api/mailbox.rs | 4 +- .../templates/Models.swift.in | 3 + .../templates/generated.rs.in | 1 + .../templates/lib.rs.in | 1 + crates/fractal-client/src/generated.rs | 1 + crates/fractal-client/src/lib.rs | 1 + .../fractal-evidence/src/store/owned_sets.rs | 1 + .../src/store/owned_sets_tests.rs | 214 ++++ crates/fractal-kernel/src/model.rs | 47 + crates/fractal-missions/src/store/missions.rs | 56 +- crates/fractal-missions/src/store/tests.rs | 10 +- .../reconcile/tests/ownership_guard_tests.rs | 1 + .../src/reconcile/tests/rollout_tests.rs | 2 + .../reconcile/tests/seat_admission_queue.rs | 2 +- crates/fractal-schema/retention.toml | 3 + crates/fractal-schema/src/lib.rs | 58 ++ crates/fractal-seats/src/store/mission_ivm.rs | 2 +- .../src/store/ordered_membership_tests.rs | 4 +- crates/fractal-seats/src/store/retirements.rs | 5 + crates/fractal-store/src/store.rs | 151 ++- crates/fractal-store/src/store/owned_sets.rs | 958 +++++++++++++++++- crates/fractal-store/src/store/publication.rs | 2 + crates/fractal/src/agents_cli.rs | 48 +- crates/fractal/src/cli_help.rs | 1 + crates/fractal/src/driver_cli.rs | 1 + crates/fractal/src/launch_cli.rs | 1 + crates/fractal/src/main.rs | 118 ++- crates/fractal/src/main_tests.rs | 8 + crates/fractal/src/onboarding.rs | 1 + crates/fractal/tests/action_coverage.rs | 1 + crates/fractal/tests/agents_restart.rs | 3 +- crates/fractal/tests/daemon_cost.rs | 1 + crates/fractal/tests/owned_sets.rs | 388 ++++++- docs/st3/action-coverage.json | 8 + docs/st3/actor-contract.json | 9 + .../client-v0/schemas/client-v0.schema.json | 1 + docs/st3/large-value-sites.json | 7 +- docs/st3/large-values.md | 2 + docs/st3/operational-state/cli-commands.json | 1 + docs/st3/owned-sets.md | 76 +- docs/st3/schema.md | 3 + fixtures/convergence/claims-v18.json | 186 ++++ release-notes/source-enforcement.json | 11 + 55 files changed, 2463 insertions(+), 96 deletions(-) create mode 100644 release-notes/source-enforcement.json diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 4b82447846e..c4afd45a1b2 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -147,6 +147,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case retireIncarnationMismatch case invalidCustodySuccessor case custodyPending + case setManagedSubject case notFound case forbidden case unsupportedCapability @@ -237,6 +238,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "retire-incarnation-mismatch": .retireIncarnationMismatch case "invalid-custody-successor": .invalidCustodySuccessor case "custody-pending": .custodyPending + case "set-managed-subject": .setManagedSubject case "agents-publication-row-too-large": .agentsPublicationRowTooLarge case "attention-migrated": .attentionMigrated case "not-found": .notFound @@ -329,6 +331,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .retireIncarnationMismatch: "retire-incarnation-mismatch" case .invalidCustodySuccessor: "invalid-custody-successor" case .custodyPending: "custody-pending" + case .setManagedSubject: "set-managed-subject" case .agentsPublicationRowTooLarge: "agents-publication-row-too-large" case .attentionMigrated: "attention-migrated" case .notFound: "not-found" diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 19a491ae870..eb6abec7438 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1855,6 +1855,7 @@ export type ErrorCode = ( | "retire-incarnation-mismatch" | "invalid-custody-successor" | "custody-pending" + | "set-managed-subject" | "attention-migrated" | "arrangement-exists" | "arrangement-folder-exists" diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index b6b6a73dc95..37f7495520d 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1716,6 +1716,7 @@ export const ErrorCode = /*#__PURE__*/ (() => openEnum([ "retire-incarnation-mismatch", "invalid-custody-successor", "custody-pending", + "set-managed-subject", "attention-migrated", "arrangement-exists", "arrangement-folder-exists", diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index bcae26143dc..c889efe55ff 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -971,6 +971,7 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("retire-incarnation-mismatch"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("invalid-custody-successor"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("custody-pending"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("set-managed-subject"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("attention-migrated"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("arrangement-exists"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("arrangement-folder-exists"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), @@ -1046,7 +1047,7 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("invalid-answer"), "details": HarnessInvalidAnswerDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("not-answerable"), "details": HarnessNotAnswerableDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("internal"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","set-managed-subject","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) ], { mode: "oneOf" }).annotate({ identifier: "ErrorEnvelope" }))() export type ErrorEnvelope = typeof ErrorEnvelope.Type export type ErrorEnvelopeEncoded = typeof ErrorEnvelope.Encoded diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index 881813bb75f..4fbf05caf09 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -7770,6 +7770,7 @@ async fn apply_runtime_control_intent( expected_subjects: preview.subject_tokens, idempotency_key: format!("{}:runtime-intent", request.idempotency_key), actor: Some(actor.to_owned()), + force_reason: None, }), ) .await? @@ -9019,6 +9020,7 @@ async fn dispatch_action( idempotency_key: request.idempotency_key.clone(), reason, host: if action == "agent.resume" { p.get("host").and_then(Value::as_str).map(str::to_owned) } else { None }, + force: false, }; let fence = super::SuspensionFence { incarnation, @@ -16840,7 +16842,7 @@ mission "example/looped" state="ready" { .unwrap(); state .store - .retire_mission("example/looped", "person/operator", "retire-looped") + .retire_mission("example/looped", "person/operator", "retire-looped", None) .unwrap(); let current = mission_resources(&state.store, state.store.index().unwrap(), false, None).unwrap(); diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 3168e636c4d..ae8f6c9f57c 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -734,6 +734,7 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary, .route("/v1/gate-checks/{id}", get(runs::read_gate_check)) .route("/v1/sets/preview", post(owned_sets::preview)) .route("/v1/sets/apply", post(owned_sets::apply)) + .route("/v1/source/status", get(owned_sets::source_status)) .route("/v1/intent/apply", post(launches::apply_with_bound)) .route("/v1/agents/rename", post(agents::rename_agent)) .route("/v1/persons/rename", post(agents::rename_person)) @@ -2022,6 +2023,7 @@ pub fn client_error_code(code: Option<&str>) -> String { | "retire-incarnation-mismatch" | "invalid-custody-successor" | "custody-pending" + | "set-managed-subject" | "not-found" | "forbidden" | "pairing-origin-required" @@ -8342,7 +8344,7 @@ async fn health(State(state): State) -> Result, ApiError> "isolation": isolation_name(fractal_runtime::isolation_mode()), "store_index": state.store.index().map_err(ApiError::internal)?, "security": "trusted-network-no-tls-no-acls", - "features": {"owned_sets":1,"owned_set_tasks":1,"seat_rollout":1,"seat_rollout_manual":1,"bounded_legacy_events":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"github_plugin":2}, + "features": {"owned_sets":1,"owned_set_tasks":1,"source_enforcement":1,"seat_rollout":1,"seat_rollout_manual":1,"bounded_legacy_events":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"github_plugin":2}, }); // How far this member's messages stream has come. Absent when it holds no shard, so a // client that waits on both indexes knows there is only the graph to wait on. @@ -10855,6 +10857,10 @@ pub struct AgentSuspensionRequest { pub idempotency_key: String, #[serde(default)] pub reason: Option, + /// Suspend or resume a member of an `enforce` owned set anyway, recording source debt with + /// `reason`. + #[serde(default)] + pub force: bool, } /// The seat a suspend or resume names: a declared service seat with no conflicting declaration. @@ -10996,6 +11002,11 @@ pub fn request_suspend( "the seat has a rollout; inspect it or retry with st agents rollout", ))); } + let force_reason = request.force.then_some(request.reason.as_deref().unwrap_or_default()); + state + .store + .source_gate(&subject, crate::store::owned_sets::Verb::Suspend, force_reason) + .map_err(ApiError::bad)?; let (current, member, token) = suspension_target(state, &subject)?; if let Some(suspension) = crate::suspension::current(&state.store, &subject).map_err(ApiError::internal)? @@ -11052,20 +11063,25 @@ pub fn request_suspend( ), ("incarnation_id".into(), Value::String(incarnation.into())), ]); - if let Some(reason) = request.reason.filter(|reason| !reason.trim().is_empty()) { - fields.insert("reason".into(), Value::String(reason)); + if let Some(reason) = request.reason.as_ref().filter(|reason| !reason.trim().is_empty()) { + fields.insert("reason".into(), Value::String(reason.clone())); } - let claim = state + let (claim, _) = state .store - .append_claim(&ClaimInput { - subject, - kind: "runtime.action.requested".into(), - actor: Some(actor), - fields, - evidence: vec![token], - expected_subject: None, - idempotency_key: Some(key), - }) + .append_source_gated( + &subject, + &ClaimInput { + subject: subject.clone(), + kind: "runtime.action.requested".into(), + actor: Some(actor), + fields, + evidence: vec![token], + expected_subject: None, + idempotency_key: Some(key), + }, + crate::store::owned_sets::Verb::Suspend, + force_reason, + ) .map_err(ApiError::bad)?; signal_changed(state); Ok(claim) @@ -11087,6 +11103,11 @@ pub fn request_resume( { return Ok(prior); } + let force_reason = request.force.then_some(request.reason.as_deref().unwrap_or_default()); + state + .store + .source_gate(&subject, crate::store::owned_sets::Verb::Resume, force_reason) + .map_err(ApiError::bad)?; let (_, member, token) = suspension_target(state, &subject)?; check_suspension_fence(fence.as_ref(), None, &token)?; let suspension = crate::suspension::current(&state.store, &subject) @@ -11148,17 +11169,22 @@ pub fn request_resume( fields.insert("host".into(), Value::String(host)); fields.insert("source_host".into(), Value::String(member.host.clone())); } - let claim = state + let (claim, _) = state .store - .append_claim(&ClaimInput { - subject, - kind: "runtime.action.requested".into(), - actor: Some(actor), - fields, - evidence: vec![launch_token, suspend], - expected_subject: None, - idempotency_key: Some(key), - }) + .append_source_gated( + &subject, + &ClaimInput { + subject: subject.clone(), + kind: "runtime.action.requested".into(), + actor: Some(actor), + fields, + evidence: vec![launch_token, suspend], + expected_subject: None, + idempotency_key: Some(key), + }, + crate::store::owned_sets::Verb::Resume, + force_reason, + ) .map_err(ApiError::bad)?; signal_changed(state); Ok(claim) @@ -11213,15 +11239,23 @@ async fn apply_with_authority( if let Some(error) = publication_refusals(&state, &intent).await?.error() { return Err(ApiError::bad(error)); } - let mut response = state - .store - .apply_as( + let mut response = match request.force_reason.as_deref().map(str::trim) { + None => state.store.apply_as( &intent, &request.expected_subjects, &request.idempotency_key, Some(actor), - ) - .map_err(ApiError::bad)?; + ), + Some(reason) if !reason.is_empty() => state.store.apply_forced( + &intent, + &request.expected_subjects, + &request.idempotency_key, + Some(actor), + reason, + ), + Some(_) => Err(FractalError::new("source-force-refused", "--force needs a nonempty --reason")), + } + .map_err(ApiError::bad)?; response.resolved_kdl = request.intent.kdl; signal_changed(&state); Ok(Json(response)) @@ -11270,13 +11304,19 @@ async fn post_delivery_hold( State(state): State, Json(request): Json, ) -> Result, ApiError> { + let force = request.force; let input = crate::delivery_hold::input(request, client_now_ms() as u64).map_err(ApiError::bad)?; let store = state.store.clone(); let (claim, appended) = blocking_action(move || { - if input.fields.get("held") == Some(&Value::Bool(true)) - && input.fields.get("legacy_adoption") != Some(&Value::Bool(true)) - { + use crate::store::owned_sets::Verb; + let holding = input.fields.get("held") == Some(&Value::Bool(true)) + && input.fields.get("legacy_adoption") != Some(&Value::Bool(true)); + let reason = input.fields.get("reason").and_then(Value::as_str).map(str::to_owned); + let force_reason = force.then_some(reason.as_deref()).flatten(); + if holding { + // An owned set's source policy refuses, audits or records an ad hoc hold as debt. + store.source_gate(&input.subject, Verb::Hold, force_reason)?; let subjects = store .desired_subjects_named(std::slice::from_ref(&input.subject)) .map_err(|error| FractalError::new("internal", error.to_string()))?; @@ -11291,7 +11331,11 @@ async fn post_delivery_hold( )); } } - store.append_claim_outcome(&input) + if holding { + store.append_source_gated(&input.subject, &input, Verb::Hold, force_reason) + } else { + store.append_claim_outcome(&input) + } }) .await?; if appended { diff --git a/crates/fractal-api-core/src/api/end_reason.rs b/crates/fractal-api-core/src/api/end_reason.rs index 8ed3feba4a6..c070e0a3a35 100644 --- a/crates/fractal-api-core/src/api/end_reason.rs +++ b/crates/fractal-api-core/src/api/end_reason.rs @@ -276,7 +276,7 @@ mod tests { let mut options = Options { set: "garden".into(), source: Source { repository: "example/garden".into(), r#ref: "refs/heads/main".into(), sha: "1".repeat(40), sequence: 1 }, expected_set: "absent".into(), rollout: None, adopt: Default::default(), - allow_empty: false, confirm_retire: None, expected_subjects: Default::default(), + allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; options.expected_subjects = store.owned_set_preview(&intent, &options).unwrap().expected_subjects; store.apply_owned_set(&intent, &options, "owned", "person/operator").unwrap(); diff --git a/crates/fractal-api-core/src/api/launches.rs b/crates/fractal-api-core/src/api/launches.rs index 207ebf0afb6..53cf12a673f 100644 --- a/crates/fractal-api-core/src/api/launches.rs +++ b/crates/fractal-api-core/src/api/launches.rs @@ -1483,7 +1483,7 @@ pub async fn retire_mission( let actor = person_or_agent_actor(&request.actor, "mission-retire-authority-denied")?; let store = state.store.clone(); let retired = - blocking_action(move || store.retire_mission(&id, &actor, &request.idempotency_key)) + blocking_action(move || store.retire_mission(&id, &actor, &request.idempotency_key, request.force_reason.as_deref())) .await?; signal_changed(&state); let mut retired = retired; diff --git a/crates/fractal-api-core/src/api/owned_sets.rs b/crates/fractal-api-core/src/api/owned_sets.rs index f80dc1d4a89..b724196dfea 100644 --- a/crates/fractal-api-core/src/api/owned_sets.rs +++ b/crates/fractal-api-core/src/api/owned_sets.rs @@ -68,6 +68,24 @@ pub async fn apply( )) } +#[derive(serde::Deserialize)] +pub struct SourceStatusQuery { + #[serde(default)] + audit: bool, +} + +/// `fractal source status`: each set's policy, open source debt, the debt each set's current +/// revision closed and, with `?audit=true`, the newest audit records. +pub async fn source_status( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> Result, ApiError> { + let store = state.store.clone(); + blocking_store(move || store.source_status(query.audit).map_err(anyhow::Error::new)) + .await + .map(Json) +} + pub fn resource(state: &AppState, view: sets::View) -> anyhow::Result { let mut value = serde_json::to_value(&view)?; let mut statuses = Vec::new(); diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index 4dbd18529ab..82c25051cfc 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -130,7 +130,7 @@ fn published_missions_match_the_direct_fold_through_work_leases_failure_and_reti // A new mission, a retired one, and a rejected write that changes nothing. mission(&store, "garden/delta"); - store.retire_mission("mission/garden/gamma", "person/operator", "retire-gamma").unwrap(); + store.retire_mission("mission/garden/gamma", "person/operator", "retire-gamma", None).unwrap(); assert!(store .work_action(&plant, "progress", &WorkRequest { wait_ms: None, diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index 769b22d4ef4..508084d2ea2 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -4254,6 +4254,7 @@ fn apply_request(state: &AppState, source: &str, actor: &str, key: &str) -> Appl expected_subjects: preview.subject_tokens, idempotency_key: key.into(), actor: Some(actor.into()), + force_reason: None, } } @@ -6691,6 +6692,7 @@ async fn preview_and_apply(app: Router, kdl: &str) -> (Value, StatusCode, Value) .unwrap(), idempotency_key: format!("apply-{}", hex::encode(Sha256::digest(kdl))), actor: Some("person/alex".into()), + force_reason: None, }) .unwrap(), ) @@ -6938,6 +6940,7 @@ mission "invalid-message" state="ready" { expected_subjects: BTreeMap::new(), idempotency_key: "reject-invalid-nested-message".into(), actor: Some("person/alex".into()), + force_reason: None, }) .unwrap(), ) @@ -9381,7 +9384,7 @@ fn roster_fixture_draining_seats(fixture: &RosterClaimsFixture) -> Vec { expected_set: store.owned_sets().unwrap().first() .map_or("absent".into(), |set| set.revision.clone()), rollout: None, adopt: Default::default(), allow_empty: false, - confirm_retire: None, expected_subjects: Default::default(), + confirm_retire: None, policy: None, expected_subjects: Default::default(), }; let preview = store.owned_set_preview(&input, &options).unwrap(); assert!(preview.blockers.is_empty(), "{:?}", preview.blockers); @@ -12307,6 +12310,7 @@ version 2 serde_json::to_value(MissionRetireRequest { actor, idempotency_key: key.into(), + force_reason: None, }) .unwrap() }; @@ -12575,6 +12579,7 @@ agent "example/other" { serde_json::to_value(MissionRetireRequest { actor: agent.into(), idempotency_key: "free-retire".into(), + force_reason: None, }) .unwrap(), ) @@ -14936,7 +14941,7 @@ async fn renamed_agent_rollout_aliases_share_launch_receipt_and_retry_identity() sha: format!("{:040x}", 1), sequence: 1, }, expected_set: "absent".into(), rollout: None, adopt: Default::default(), - allow_empty: false, confirm_retire: None, expected_subjects: Default::default(), + allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; let preview = state.store.owned_set_preview(&intent, &options).unwrap(); assert!(preview.blockers.is_empty(), "{:?}", preview.blockers); diff --git a/crates/fractal-api-core/src/delivery_hold.rs b/crates/fractal-api-core/src/delivery_hold.rs index cf11de236ff..93184aa15aa 100644 --- a/crates/fractal-api-core/src/delivery_hold.rs +++ b/crates/fractal-api-core/src/delivery_hold.rs @@ -19,6 +19,9 @@ pub struct HoldRequest { /// Used only when a replacement adopts an older provider's unexpired DND. #[serde(default)] pub legacy_adoption: bool, + /// Hold a member of an `enforce` owned set anyway, recording source debt with `reason`. + #[serde(default)] + pub force: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -109,6 +112,7 @@ mod tests { reason: "quiet interval".into(), idempotency_key: key.into(), legacy_adoption: false, + force: false, } } diff --git a/crates/fractal-api-mailbox/src/api/mailbox.rs b/crates/fractal-api-mailbox/src/api/mailbox.rs index a621f2822fb..08232658a67 100644 --- a/crates/fractal-api-mailbox/src/api/mailbox.rs +++ b/crates/fractal-api-mailbox/src/api/mailbox.rs @@ -3923,7 +3923,7 @@ mod tests { set: name.into(), source: Source { repository: "fixture/report".into(), r#ref: "refs/heads/main".into(), sha: "1".repeat(40), sequence: 1 }, expected_set: "absent".into(), rollout: Some(policy.clone()), - adopt: Default::default(), allow_empty: false, confirm_retire: None, + adopt: Default::default(), allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; let preview = state.store.owned_set_preview(&input, &options).unwrap(); @@ -4898,7 +4898,7 @@ mod tests { set: name.into(), source: Source { repository: "fixture/stream".into(), r#ref: "refs/heads/main".into(), sha: "1".repeat(40), sequence: 1 }, expected_set: "absent".into(), rollout: Some(policy.clone()), - adopt: Default::default(), allow_empty: false, confirm_retire: None, + adopt: Default::default(), allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; let preview = store.owned_set_preview(&input, &options).unwrap(); diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index f67d5ffb1f3..1d427c7b0df 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -147,6 +147,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case retireIncarnationMismatch case invalidCustodySuccessor case custodyPending + case setManagedSubject case notFound case forbidden case unsupportedCapability @@ -237,6 +238,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "retire-incarnation-mismatch": .retireIncarnationMismatch case "invalid-custody-successor": .invalidCustodySuccessor case "custody-pending": .custodyPending + case "set-managed-subject": .setManagedSubject case "agents-publication-row-too-large": .agentsPublicationRowTooLarge case "attention-migrated": .attentionMigrated case "not-found": .notFound @@ -329,6 +331,7 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .retireIncarnationMismatch: "retire-incarnation-mismatch" case .invalidCustodySuccessor: "invalid-custody-successor" case .custodyPending: "custody-pending" + case .setManagedSubject: "set-managed-subject" case .agentsPublicationRowTooLarge: "agents-publication-row-too-large" case .attentionMigrated: "attention-migrated" case .notFound: "not-found" diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index a2a87f21f7d..bc426a3a608 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -103,6 +103,7 @@ pub enum ErrorCode { RetireIncarnationMismatch, InvalidCustodySuccessor, CustodyPending, + SetManagedSubject, AttentionMigrated, ArrangementExists, ArrangementFolderExists, diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index cb69c4c2fae..f59f9a86239 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -1232,6 +1232,7 @@ fn render_message(code: Option<&ErrorCode>, message: &str, human: bool) -> Strin | ErrorCode::RetireIncarnationMismatch | ErrorCode::InvalidCustodySuccessor | ErrorCode::CustodyPending + | ErrorCode::SetManagedSubject | ErrorCode::MessageSelectorsNotReady | ErrorCode::Unknown => message.to_owned(), } diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index c26061f4283..252f7760c57 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -103,6 +103,7 @@ pub enum ErrorCode { RetireIncarnationMismatch, InvalidCustodySuccessor, CustodyPending, + SetManagedSubject, AttentionMigrated, ArrangementExists, ArrangementFolderExists, diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index acacfab4677..8e1ebc45230 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -1318,6 +1318,7 @@ fn render_message(code: Option<&ErrorCode>, message: &str, human: bool) -> Strin | ErrorCode::RetireIncarnationMismatch | ErrorCode::InvalidCustodySuccessor | ErrorCode::CustodyPending + | ErrorCode::SetManagedSubject | ErrorCode::MessageSelectorsNotReady | ErrorCode::Unknown => message.to_owned(), } diff --git a/crates/fractal-evidence/src/store/owned_sets.rs b/crates/fractal-evidence/src/store/owned_sets.rs index f823bffbaf6..d7dd09968cd 100644 --- a/crates/fractal-evidence/src/store/owned_sets.rs +++ b/crates/fractal-evidence/src/store/owned_sets.rs @@ -222,6 +222,7 @@ impl OwnedSetsStore for Store { publication::CommitChecks { owned: Some(options), guard: None, + force: None, }, ) } diff --git a/crates/fractal-evidence/src/store/owned_sets_tests.rs b/crates/fractal-evidence/src/store/owned_sets_tests.rs index f5278c6c7b0..75d6c7fa679 100644 --- a/crates/fractal-evidence/src/store/owned_sets_tests.rs +++ b/crates/fractal-evidence/src/store/owned_sets_tests.rs @@ -32,6 +32,7 @@ fn options(store: &Store, sequence: u64) -> Options { adopt: Default::default(), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: Default::default(), } } @@ -1706,3 +1707,216 @@ fn set_status_reads_validate_each_member_once_per_snapshot() { }); assert!(per_member[1] <= per_member[0] + 2, "statements per member: {per_member:?}"); } + +fn enforce_policy(publisher: Option<&str>) -> owned_sets::PolicyRequest { + owned_sets::PolicyRequest { + enforce: Some(owned_sets::Enforce::Enforce), + publisher: publisher.map(|actor| vec![actor.to_owned()]), + ..Default::default() + } +} + +fn publish_as( + store: &Store, + input: &NormalizedIntent, + sequence: u64, + actor: &str, + policy: Option, +) -> (owned_sets::Preview, ApplyResponse) { + let mut opts = options(store, sequence); + opts.policy = policy; + let preview = store.owned_set_preview(input, &opts).unwrap(); + assert!(preview.blockers.is_empty(), "{:?}", preview.blockers); + opts.expected_subjects = preview.expected_subjects.clone(); + let applied = store + .apply_owned_set(input, &opts, &format!("set-{sequence}"), actor) + .unwrap(); + (preview, applied) +} + +fn runtime_write(subject: &str, kind: &str, fields: Value, evidence: Vec, key: &str) -> ClaimInput { + ClaimInput { + subject: subject.into(), + kind: kind.into(), + actor: Some("person/operator".into()), + fields: serde_json::from_value(fields).unwrap(), + evidence, + expected_subject: None, + idempotency_key: Some(key.into()), + } +} + +/// A forced hold or suspension commits with its debt, or not at all; the set's next publication, +/// by an agent publisher, releases the hold and resumes the seat in its own transaction, and +/// replicated admission accepts all of it. +#[test] +fn forced_runtime_writes_carry_their_debt_and_publication_reverts_them_on_every_replica() { + use owned_sets::Verb; + let fleet = signed_fleet(); + for store in &fleet { + store + .append_claim(&ClaimInput { + subject: format!("daemon/{}", store.origin), + kind: "daemon.started".into(), + actor: None, + fields: serde_json::from_value(json!({"status": "running", + "features": {"owned_sets": 1, "source_enforcement": 1}})) + .unwrap(), + evidence: vec![], + expected_subject: None, + idempotency_key: None, + }) + .unwrap(); + } + for from in &fleet { + for to in &fleet { + if from.origin != to.origin { + signed_share(from, to); + } + } + } + let (amber, cobalt) = (&fleet[0], &fleet[1]); + let publisher = "agent/dotfiles/factory-publisher"; + let declared = bundle("sleep 100", false); + publish_as(amber, &declared, 10, publisher, Some(enforce_policy(Some(publisher)))); + let subject = "agent/garden/orchard"; + let token = amber.selected_desired_token(subject).unwrap().unwrap(); + let runtime_id = amber.desired_subjects_named(&[subject.into()]).unwrap().remove(0) + .member.unwrap().runtime_id; + let suspend = runtime_write(subject, "runtime.action.requested", + json!({"action": "suspend", "runtime_id": runtime_id, "incarnation_id": "garden-1"}), + vec![token.clone()], "suspend-orchard"); + let until = u64::try_from(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()).unwrap() + 900_000; + let hold = runtime_write(subject, "delivery.hold", + json!({"held": true, "until_unix_ms": until, "reason": "quiet", "legacy_adoption": false}), + vec![], "hold-orchard"); + + // Unforced, the write is refused and nothing is written: the request rolls back with it. The + // first write as `person/operator` on a signed store mints that person's keys outside the + // write, so the check reads the member's own claims rather than the store index. + let before = amber.claims_for(subject, None).unwrap().len(); + for (input, verb) in [(&suspend, Verb::Suspend), (&hold, Verb::Hold)] { + let refused = amber.append_source_gated(subject, input, verb, None).unwrap_err(); + assert_eq!(refused.code, "set-managed-subject", "{refused:?}"); + } + assert_eq!(amber.claims_for(subject, None).unwrap().len(), before); + + // Forced, each request and its debt commit together; a replay records no second debt. + let mut requests = BTreeMap::new(); + for (input, verb) in [(&suspend, Verb::Suspend), (&hold, Verb::Hold)] { + let (request, appended) = amber.append_source_gated(subject, input, verb, Some("memory pressure")).unwrap(); + assert!(appended); + assert!(!amber.append_source_gated(subject, input, verb, Some("memory pressure")).unwrap().1); + requests.insert(verb.as_str(), request.id); + } + let debts = amber.claims_for(subject, Some(owned_sets::DEBT_KIND)).unwrap(); + assert_eq!( + debts.iter().map(|d| (d.body["fields"]["verb"].as_str().unwrap(), d.body["fields"]["request"].as_str().unwrap())) + .collect::>(), + requests.iter().map(|(verb, id)| (*verb, id.as_str())).collect::>() + ); + signed_share(amber, cobalt); + + // The publisher's next publication reverts both. + let (preview, _) = publish_as(amber, &declared, 11, publisher, None); + assert_eq!( + preview.reverted_debts.iter().map(|d| (d.verb.as_str(), d.outcome.as_deref().unwrap())).collect::>(), + BTreeMap::from([("hold", "reverted"), ("suspend", "reverted")]) + ); + signed_share(amber, cobalt); + for store in [amber, cobalt] { + let resume = store.latest_claim(subject, Some("runtime.action.requested")).unwrap().unwrap(); + assert_eq!(resume.actor.as_deref(), Some(publisher)); + assert_eq!(resume.body["fields"]["action"], "resume"); + assert_eq!(resume.body["fields"]["runtime_id"], runtime_id); + assert_eq!(resume.body["evidence"], json!([token, requests["suspend"]])); + let released = store.latest_claim(subject, Some("delivery.hold")).unwrap().unwrap(); + assert_eq!((released.actor.as_deref(), &released.body["fields"]["held"]), (Some(publisher), &json!(false))); + let closed = store.claims_for(subject, Some(owned_sets::DEBT_CLOSED_KIND)).unwrap(); + assert_eq!(closed.len(), 2); + assert!(closed.iter().all(|c| c.body["fields"]["outcome"] == "reverted" && c.actor.as_deref() == Some(publisher))); + } + + // A forced resume of a seat the source never suspended is closed, not reverted. + let resume = runtime_write(subject, "runtime.action.requested", + json!({"action": "resume", "runtime_id": runtime_id}), + vec![token.clone(), requests["suspend"].clone()], "resume-orchard"); + amber.append_source_gated(subject, &resume, Verb::Resume, Some("needed it back")).unwrap(); + let (preview, _) = publish_as(amber, &declared, 12, publisher, None); + assert_eq!(preview.reverted_debts.len(), 1); + assert_eq!(preview.reverted_debts[0].outcome.as_deref(), Some("closed")); + assert_eq!(amber.latest_claim(subject, Some("runtime.action.requested")).unwrap().unwrap().body["fields"]["action"], "resume"); + assert_eq!(amber.claims_for(subject, Some("runtime.action.requested")).unwrap().len(), 3); +} + +/// `missions retire --force` takes effect with its debt in one transaction; the set's next +/// publication restores the source mission. +#[test] +fn a_forced_mission_retire_holds_until_the_set_publishes_again() { + let store = Store::open_memory("amber").unwrap(); + let declared = parse_intent( + "version 2\nagent \"garden/orchard\" { command \"sleep 100\" }\nmission \"garden/harvest\" state=\"ready\" { goal \"Harvest\"; step \"work\" { assigned-to \"agent/garden/orchard\" } }\n", + "amber", + ) + .unwrap(); + publish_as(&store, &declared, 10, "person/operator", Some(enforce_policy(None))); + let source = store.mission_spec("garden/harvest", None).unwrap().unwrap(); + let index = store.index().unwrap(); + let refused = store.retire_mission("garden/harvest", "person/operator", "retire", None).unwrap_err(); + assert_eq!(refused.code, "set-managed-subject"); + assert!(refused.message.contains("ad hoc mission retire is refused"), "{}", refused.message); + let blank = store.retire_mission("garden/harvest", "person/operator", "retire-blank", Some(" ")).unwrap_err(); + assert_eq!(blank.code, "source-force-refused"); + assert_eq!(store.index().unwrap(), index); + + let retired = store + .retire_mission("garden/harvest", "person/operator", "retire-forced", Some("frost tonight")) + .unwrap(); + assert_ne!(retired.revision, source.revision); + assert_eq!(store.mission_spec("garden/harvest", None).unwrap().unwrap().revision, retired.revision); + let debt = store.latest_claim("mission/garden/harvest", Some(owned_sets::DEBT_KIND)).unwrap().unwrap(); + assert_eq!(debt.body["fields"]["verb"], "mission retire"); + assert_eq!(debt.body["fields"]["revision"], retired.revision); + assert_eq!(debt.body["fields"]["reason"], "frost tonight"); + + let (preview, _) = publish_as(&store, &declared, 11, "person/operator", None); + assert_eq!(preview.reverted_debts.len(), 1); + assert_eq!(preview.reverted_debts[0].outcome.as_deref(), Some("reverted")); + assert_eq!(store.mission_spec("garden/harvest", None).unwrap().unwrap().revision, source.revision); +} + +/// A claimed namespace refuses an ad hoc create, but a mission run still materializes the +/// declarations it owns there: source does not route run starts. +#[test] +fn a_claimed_namespace_still_admits_run_owned_declarations() { + let store = Store::open_memory("amber").unwrap(); + let declared = parse_intent("version 2\nagent \"garden/orchard\" { command \"sleep 100\" }\n", "amber").unwrap(); + let policy = owned_sets::PolicyRequest { + namespaces: Some(vec!["agent/garden/*".into()]), + ..enforce_policy(None) + }; + publish_as(&store, &declared, 10, "person/operator", Some(policy)); + let mut helper = parse_intent("version 2\nagent \"garden/helper\" { command \"sleep 100\" }\n", "amber").unwrap(); + let expected = BTreeMap::from([("agent/garden/helper".to_owned(), Vec::new())]); + let index = store.index().unwrap(); + let refused = store.apply(&helper, &expected, "ad-hoc-helper").unwrap_err(); + assert_eq!(refused.code, "set-managed-subject"); + assert_eq!(store.index().unwrap(), index); + + helper.subjects.get_mut("agent/garden/helper").unwrap().owner_run = Some("run/harvest-1".into()); + let applied = store.apply(&helper, &expected, "run-helper").unwrap(); + assert!(applied.changed); + assert!(applied.source_debts_recorded.is_empty()); + + let mut orchard = parse_intent("version 2\nagent \"garden/orchard\" { command \"sleep 1\" }\n", "amber").unwrap(); + orchard.subjects.get_mut("agent/garden/orchard").unwrap().owner_run = Some("run/harvest-1".into()); + let token = store.selected_desired_token("agent/garden/orchard").unwrap().unwrap(); + let index = store.index().unwrap(); + let member = store + .apply(&orchard, &BTreeMap::from([("agent/garden/orchard".to_owned(), vec![token])]), "run-orchard") + .unwrap_err(); + assert_eq!(member.code, "set-managed-subject"); + assert_eq!(store.index().unwrap(), index); + assert!(store.latest_claim("agent/garden/helper", Some(owned_sets::DEBT_KIND)).unwrap().is_none()); +} diff --git a/crates/fractal-kernel/src/model.rs b/crates/fractal-kernel/src/model.rs index 1fb3089a4d9..d804b7a5570 100644 --- a/crates/fractal-kernel/src/model.rs +++ b/crates/fractal-kernel/src/model.rs @@ -1571,6 +1571,41 @@ pub struct DeclarationDiff { pub fields: BTreeSet, } +/// An ad hoc change to a member of an owned set, accepted only with `--force --reason` +/// (`store::owned_sets`). It stays open while the set revision it diverged from is selected; +/// the next publication of that set closes it and reports it here as reverted. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SourceDebt { + /// The `source.debt.recorded` claim. + pub id: String, + pub subject: String, + /// `apply` for a forced declaration, otherwise the lifecycle verb (`hold`). + pub verb: String, + pub set: String, + /// `owned-set/NAME@REVISION` the change diverged from. + pub set_revision: String, + pub repository: String, + pub r#ref: String, + pub sha: String, + /// The forced declaration claim, for `apply`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claim: Option, + pub actor: Option, + pub reason: String, + /// Changed JSON pointers of the forced declaration against the set's. + #[serde(default)] + pub fields: BTreeSet, + pub recorded_at: String, + /// In a publication report: `reverted` when the new source differs from the forced + /// declaration, `adopted` when the source now declares it, `closed` for runtime verbs + /// (`hold`, `suspend`, `resume`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + /// The `source.debt.closed` claim and the set revision that wrote it, once closed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub closed_by: Option, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct MissionResponse { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -1782,6 +1817,9 @@ pub struct ApplyRequest { pub idempotency_key: String, #[serde(default)] pub actor: Option, + /// Accept an ad hoc change to an owned-set member and record it as source debt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_reason: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1796,6 +1834,12 @@ pub struct ApplyResponse { pub resolved_kdl: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub operations: Vec, + /// Source debt this forced publication recorded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_debts_recorded: Vec, + /// Open source debt this set publication closed, with each change it reverted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_debts_reverted: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -3628,6 +3672,9 @@ pub struct MissionRunReportView { pub struct MissionRetireRequest { pub actor: String, pub idempotency_key: String, + /// Retire a member of an `enforce` owned set ad hoc, recording source debt with this reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_reason: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 996bd3b09ea..2db3483b464 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -1782,12 +1782,14 @@ pub trait MissionsStore { /// Retire a published mission: publish its current definition again as `retired`, so it /// leaves the mission lists and cannot start, while every revision and run stays in its /// history. The publication names who retired it. Publishing a ready revision brings the - /// mission back. + /// mission back. A member of an `enforce` owned set retires only with `force_reason`, which + /// records source debt in the same transaction. fn retire_mission( &self, mission: &str, actor: &str, idempotency_key: &str, + force_reason: Option<&str>, ) -> Result; #[allow(clippy::too_many_arguments)] fn adopt_mission_revision_inner( @@ -4390,6 +4392,7 @@ impl MissionsStore for Store { mission: &str, actor: &str, idempotency_key: &str, + force_reason: Option<&str>, ) -> Result { if let Some(response) = self .cached_idempotency_response(idempotency_key) @@ -4422,7 +4425,8 @@ impl MissionsStore for Store { if let Some(response) = smallclaims::store::idempotency::cached_response(&transaction,idempotency_key)? { return serde_json::from_str(&response).map_err(internal); } - owned_sets::refuse_unmanaged(&transaction, &format!("mission/{mission_id}"))?; + // A set member retires through its set, or forced as source debt. + let gate = owned_sets::gate(&transaction, &format!("mission/{mission_id}"), owned_sets::Verb::MissionRetire, force_reason)?; let active = transaction .query_row( "SELECT id FROM mission_runs @@ -4444,18 +4448,44 @@ impl MissionsStore for Store { let predecessors = mission_definition_token_tx(&transaction, mission_id).map_err(internal)?; let body = serde_json::to_value(&retired).map_err(internal)?; - let claim = append_receipt_claim_tx( - &transaction, - &self.origin, - &retired.subject, - "mission.published", - Some(&actor), - &body, - &predecessors, - None, - idempotency_key, - ) + let claim = if matches!(gate, owned_sets::Gate::Force(_)) { + fractal_store::store::append_source_forced_receipt_claim_tx( + &transaction, + &self.origin, + &retired.subject, + "mission.published", + Some(&actor), + &body, + &predecessors, + idempotency_key, + ) + } else { + append_receipt_claim_tx( + &transaction, + &self.origin, + &retired.subject, + "mission.published", + Some(&actor), + &body, + &predecessors, + None, + idempotency_key, + ) + } .map_err(internal)?; + match gate { + owned_sets::Gate::Allow => {} + owned_sets::Gate::Force(view) => { + owned_sets::record_debt_tx(&transaction, &self.origin, &view, &retired.subject, + owned_sets::Verb::MissionRetire, + owned_sets::Forced::Declaration { claim: &claim.id, revision: &retired.revision }, + None, Some(&actor), force_reason.unwrap_or_default(), None)?; + } + owned_sets::Gate::Audit(view, refused) => { + owned_sets::record_audit_tx(&transaction, &self.origin, &view, &retired.subject, + owned_sets::Verb::MissionRetire, Some(&actor), &refused, None)?; + } + } transaction .execute( /* sqlite-large-value: allow-for-now lv-h778d64bc900e owner=fractal_missions::store::missions TODO=bound-or-reference-values-and-reclamation */ "INSERT OR IGNORE INTO mission_revisions(mission_id, revision, state, body, claim_id, created_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index 7843e3b2ee3..5cec4d60098 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -7806,7 +7806,7 @@ fn a_retired_mission_leaves_the_current_view_at_once_after_a_failed_run() { let store = Store::open_memory("node").unwrap(); let failed = failed_takeover_run(&store, &["deploy-check"]); store - .retire_mission(&failed.mission, "person/operator", "retire-failed") + .retire_mission(&failed.mission, "person/operator", "retire-failed", None) .unwrap(); let current = store.mission_collection_ids(false, 0, 50).unwrap(); assert!(!current.contains(&failed.mission), "{current:?}"); @@ -8518,7 +8518,7 @@ fn a_retired_mission_leaves_the_lists_keeps_its_history_and_returns_when_publish assert!(listed(&store, false)); let retired = store - .retire_mission("mission/takeover", "person/operator", "retire-takeover") + .retire_mission("mission/takeover", "person/operator", "retire-takeover", None) .unwrap(); assert_eq!(retired.state, MissionState::Retired); assert_ne!(retired.revision, ready.revision); @@ -8556,14 +8556,14 @@ fn a_retired_mission_leaves_the_lists_keeps_its_history_and_returns_when_publish ); assert_eq!( store - .retire_mission("mission/takeover", "person/operator", "retire-takeover") + .retire_mission("mission/takeover", "person/operator", "retire-takeover", None) .unwrap(), retired, "the same request is answered from its first result" ); assert_eq!( store - .retire_mission("takeover", "person/operator", "retire-again") + .retire_mission("takeover", "person/operator", "retire-again", None) .unwrap_err() .code, "mission-already-retired" @@ -8611,7 +8611,7 @@ fn a_mission_with_an_active_run_is_not_retired() { }) .unwrap(); let refused = store - .retire_mission("takeover", "person/operator", "retire-active") + .retire_mission("takeover", "person/operator", "retire-active", None) .unwrap_err(); assert_eq!(refused.code, "mission-has-active-run"); assert!(refused.message.contains(&run.subject)); diff --git a/crates/fractal-reconcile/src/reconcile/tests/ownership_guard_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/ownership_guard_tests.rs index fb5d59b7afe..135c84ca343 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/ownership_guard_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/ownership_guard_tests.rs @@ -728,6 +728,7 @@ fn publish_owned_intent(store: &Store, intent: &crate::NormalizedIntent, sequenc adopt: BTreeSet::new(), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: BTreeMap::new(), }; options.expected_subjects = store diff --git a/crates/fractal-reconcile/src/reconcile/tests/rollout_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/rollout_tests.rs index a362a1a71f5..2e069165a29 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/rollout_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/rollout_tests.rs @@ -230,6 +230,7 @@ impl Seat { adopt: Default::default(), allow_empty: retire, confirm_retire: None, + policy: None, expected_subjects: Default::default(), }; let preview = self.store.owned_set_preview(&input, &options).unwrap(); @@ -535,6 +536,7 @@ fn rollout_provider_switch_is_rejected_before_draining_the_incumbent() { adopt: Default::default(), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: Default::default(), }; let preview = seat.store.owned_set_preview(&input, &options).unwrap(); diff --git a/crates/fractal-reconcile/src/reconcile/tests/seat_admission_queue.rs b/crates/fractal-reconcile/src/reconcile/tests/seat_admission_queue.rs index fc68067e97d..4cf049ed775 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/seat_admission_queue.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/seat_admission_queue.rs @@ -189,7 +189,7 @@ fn rollout_replacement_uses_liveness_when_operation_was_requested() { let mut options = crate::store::owned_sets::Options { set: "workers".into(), expected_set: "absent".into(), source: crate::store::owned_sets::Source { repository: "test/workers".into(), r#ref: "refs/heads/main".into(), sha: format!("{:040x}", 1), sequence: 1 }, - rollout: None, adopt: Default::default(), allow_empty: false, confirm_retire: None, expected_subjects: Default::default(), + rollout: None, adopt: Default::default(), allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; let preview = store.owned_set_preview(&intent, &options).unwrap(); options.confirm_retire = Some(preview.digest); options.expected_subjects = preview.expected_subjects; diff --git a/crates/fractal-schema/retention.toml b/crates/fractal-schema/retention.toml index 0b9e35c7229..f076b841c46 100644 --- a/crates/fractal-schema/retention.toml +++ b/crates/fractal-schema/retention.toml @@ -193,6 +193,9 @@ "sekret.changed" = { class = "transition", window = "7d", log = "local" } "sekret.exited" = { class = "transition", window = "7d", log = "local" } "sekret.refused" = { class = "transition", window = "7d", log = "local" } +"source.audited" = { class = "durable" } +"source.debt.closed" = { class = "durable" } +"source.debt.recorded" = { class = "durable" } "step-run.carried" = { class = "durable" } "step-run.retried" = { class = "durable" } "step-run.state" = { class = "durable" } diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index a3121e225a5..36a52c823f8 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -1677,6 +1677,33 @@ fn claim_specs() -> BTreeMap { true, &[], ), + ( + "source.debt.recorded", + &["agent", "schedule", "subscription", "mission", "exec", "pty"], + WritePolicy::SystemOnly, + Cardinality::Append, + Some("owned-sets"), + false, + &[], + ), + ( + "source.debt.closed", + &["agent", "schedule", "subscription", "mission", "exec", "pty"], + WritePolicy::SystemOnly, + Cardinality::Append, + Some("owned-sets"), + false, + &[], + ), + ( + "source.audited", + &["agent", "schedule", "subscription", "mission", "exec", "pty"], + WritePolicy::SystemOnly, + Cardinality::Append, + Some("owned-sets"), + false, + &[], + ), ( "mission.published", &["mission"], @@ -3237,6 +3264,34 @@ fn claim_fields(kind: &str) -> BTreeMap { ("revision", required_string()), ("body", required_object()), ], + "source.debt.recorded" => &[ + ("set", required_string()), + ("set_revision", required_string()), + ("repository", required_string()), + ("ref", required_string()), + ("sha", required_string()), + ("verb", required_string()), + ("reason", required_string()), + ("claim", string()), + ("revision", string()), + ("request", string()), + ("pointers", array()), + ("before", any()), + ("after", any()), + ], + "source.debt.closed" => &[ + ("debt", required_string()), + ("set", required_string()), + ("outcome", required_string()), + ("set_revision", required_string()), + ("publisher", any()), + ], + "source.audited" => &[ + ("set", required_string()), + ("set_revision", required_string()), + ("verb", required_string()), + ("refusal", required_string()), + ], "mission.published" => &[ ("revision", string()), ("state", string()), @@ -4955,6 +5010,9 @@ mod tests { current.subjects.remove("objective").unwrap(); current.claims.get_mut("intent.desired").unwrap().source_kdl.retain(|kind| kind != "objective"); current.claims.remove("message.expired").unwrap(); + for kind in ["source.debt.recorded", "source.debt.closed", "source.audited"] { + current.claims.remove(kind).unwrap(); + } assert_eq!(current.subjects["assistant"].pattern, "assistant/{chatgpt|mcp}[/session/assistant-grant/ID]"); let mut previous = current.clone(); previous.subjects.get_mut("assistant").unwrap().pattern = diff --git a/crates/fractal-seats/src/store/mission_ivm.rs b/crates/fractal-seats/src/store/mission_ivm.rs index 586d9606fa0..c79b4d70c43 100644 --- a/crates/fractal-seats/src/store/mission_ivm.rs +++ b/crates/fractal-seats/src/store/mission_ivm.rs @@ -265,7 +265,7 @@ mod tests { } smallclaims::store::set_thread_clock(Some(deadline + 1)); store - .retire_mission("mission/orchard", "person/avery", "retire") + .retire_mission("mission/orchard", "person/avery", "retire", None) .unwrap(); parity(&store, &views, deadline + 1); assert!( diff --git a/crates/fractal-seats/src/store/ordered_membership_tests.rs b/crates/fractal-seats/src/store/ordered_membership_tests.rs index 3c983fd1f73..5cd6fa185b3 100644 --- a/crates/fractal-seats/src/store/ordered_membership_tests.rs +++ b/crates/fractal-seats/src/store/ordered_membership_tests.rs @@ -858,7 +858,7 @@ fn direct_mission_retirement_flushes_membership_visibility_in_the_same_transacti edit(&store, json!([place(member, None, "a0")])); let retained = head(&store, member); assert_eq!(members(&store), vec![member]); - store.retire_mission(member, "person/ada", "retire-membership-mission").unwrap(); + store.retire_mission(member, "person/ada", "retire-membership-mission", None).unwrap(); assert!(rows(&store).is_empty()); assert_eq!(store.ordered_membership_count(CONTAINER).unwrap(), 0); assert_eq!(head(&store, member), retained); @@ -1550,7 +1550,7 @@ fn every_lifecycle_registry_family_projects_membership_visibility_end_to_end() { assert!(rows(&store).is_empty(), "{family}"); } MissionDeclaration => { - store.retire_mission(member, "person/ada", "registry-mission-retired").unwrap(); + store.retire_mission(member, "person/ada", "registry-mission-retired", None).unwrap(); assert!(rows(&store).is_empty(), "{family}"); } Document => { diff --git a/crates/fractal-seats/src/store/retirements.rs b/crates/fractal-seats/src/store/retirements.rs index 87bc2454261..38701c0ac9c 100644 --- a/crates/fractal-seats/src/store/retirements.rs +++ b/crates/fractal-seats/src/store/retirements.rs @@ -133,6 +133,9 @@ impl RetirementsStore for Store { "retirement needs a reason", )); } + // A live member of an `enforce` set is never retired ad hoc, not even forced; an `audit` + // set records the retirement it would refuse. + self.source_gate(subject, fractal_store::store::owned_sets::Verb::Retire, None)?; let mut fields = BTreeMap::from([ ("reason".into(), json!(reason)), ("retired_by".into(), json!(actor)), @@ -234,6 +237,7 @@ impl RetirementsStore for Store { )?; validate_claim_input(&input) }; + let source = self.source_follow_up(subject, fractal_store::store::owned_sets::Verb::Retire, Some(actor), None); append_claim_with_commit_context( &self.graph, &input, @@ -246,6 +250,7 @@ impl RetirementsStore for Store { signature: None, retirement_writer: true, assistant: None, + then: Some(&source), ..Default::default() }, ) diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 56cd1b49d23..3d619a2603a 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -3325,6 +3325,31 @@ impl Store { publication::CommitChecks { owned: None, guard: Some(guard), + force: None, + }, + ) + } + + /// Publish plain declarations, accepting ad hoc changes to owned-set members as source + /// debt with `reason`. Each forced member holds until its set publishes again. + pub fn apply_forced( + &self, + intent: &NormalizedIntent, + expected: &BTreeMap>, + key: &str, + actor: Option<&str>, + reason: &str, + ) -> Result { + self.apply_as_impl( + intent, + expected, + key, + actor, + None, + publication::CommitChecks { + owned: None, + guard: None, + force: Some(reason), }, ) } @@ -3361,7 +3386,7 @@ impl Store { ) -> Result { fractal_missions_base::declared_admission::validate_publication(intent)?; crate::graph::validate_mission_runtimes(intent, &self.origin)?; - let publication::CommitChecks { owned, guard } = checks; + let publication::CommitChecks { owned, guard, force } = checks; self.connection .batched(|transaction| -> Result { declared_admission::validate_publication(transaction, &self.origin, intent)?; @@ -3404,13 +3429,17 @@ impl Store { message_subscriptions::validate(transaction, intent, current_index_tx(transaction).map_err(internal)?)?; let owned_plan = owned.map(|options| owned_sets::plan_tx(transaction, intent, options)).transpose()?; let mut one_shot_sets = BTreeMap::new(); + let mut forced = BTreeMap::::new(); + let mut audited = Vec::new(); if let (Some(plan), Some(options)) = (&owned_plan, owned) { + owned_sets::check_publisher(plan, actor)?; owned_sets::validate_apply(plan, options)?; if plan.preview.noop { return cache_local_apply_receipt_tx(transaction,ApplyResponse { changed: false, store_index: current_index_tx(transaction).map_err(internal)?, batch_id: None, claim_ids: Vec::new(), subject_tokens: BTreeMap::new(), reconcile_subjects: Vec::new(), resolved_kdl: String::new(), operations: Vec::new(), + source_debts_recorded: Vec::new(), source_debts_reverted: Vec::new(), },local_receipt,idempotency_key); } } else { @@ -3422,7 +3451,33 @@ impl Store { one_shot_sets.insert(subject.clone(), set); continue; } - owned_sets::refuse_unmanaged(transaction, subject)?; + // A mission run materializes its own declarations; source does not route run starts, + // so a claimed namespace does not gate them. Set members still refuse them. + if intent.subjects.get(subject).is_some_and(|desired| desired.owner_run.is_some()) { + owned_sets::refuse_unmanaged(transaction, subject)?; + continue; + } + let new = owned_sets::owner(transaction, subject, None)?.is_none() + && match intent.subjects.get(subject) { + Some(_) => intent_leaves_tx(transaction, subject).map_err(internal)?.is_empty(), + None => mission_definition_token_tx(transaction, subject.trim_start_matches("mission/")) + .map_err(internal)?.is_empty(), + }; + let verb = match (new, intent.subjects.get(subject)) { + (true, _) => owned_sets::Verb::Create, + (false, Some(desired)) => owned_sets::Verb::of_declaration(transaction, desired)?, + (false, None) => match intent.missions.values().find(|m| &m.subject == subject) { + Some(mission) if mission_state_name(&mission.state) == "retired" => owned_sets::Verb::MissionRetire, + _ => owned_sets::Verb::MissionPublish, + }, + }; + match owned_sets::gate(transaction, subject, verb, force)? { + owned_sets::Gate::Allow => {} + owned_sets::Gate::Force(view) => { + forced.insert(subject.clone(), (*view, verb)); + } + owned_sets::Gate::Audit(view, refused) => audited.push((subject.clone(), *view, verb, *refused)), + } } } let intent = owned_plan.as_ref().map_or(intent, |p| &p.intent); @@ -3793,6 +3848,8 @@ impl Store { reconcile_subjects: Vec::new(), resolved_kdl: String::new(), operations: Vec::new(), + source_debts_recorded: Vec::new(), + source_debts_reverted: Vec::new(), }; transaction .execute( @@ -3805,6 +3862,14 @@ impl Store { .map_err(internal)?; return cache_local_apply_receipt_tx(transaction,response,local_receipt,idempotency_key); } + let forced_diffs = if forced.is_empty() { + BTreeMap::new() + } else { + declarations::diffs(transaction, intent, + forced.keys().map(String::as_str).filter(|s| intent.subjects.contains_key(*s)), + Some(current_index_tx(transaction).map_err(internal)?))? + }; + let mut forced_claims = Vec::new(); let now = write_time(transaction, &self.origin).map_err(internal)?; let sequence = next_replica_sequence(transaction, &self.origin).map_err(internal)?; let previous_hash = previous_batch_hash(transaction, &self.origin).map_err(internal)?; @@ -3896,6 +3961,9 @@ impl Store { insert_event(transaction, store_index, "intent.desired", subject, &body) .map_err(internal)?; claim_ids.push(claim_id.clone()); + if forced.contains_key(subject) { + forced_claims.push((subject.clone(), claim_id.clone(), revision.clone())); + } tokens.insert(subject.clone(), vec![claim_id]); reconcile_subjects.push(subject.clone()); } @@ -3973,6 +4041,9 @@ impl Store { ) .map_err(internal)?; claim_ids.push(claim_id.clone()); + if forced.contains_key(&mission.subject) { + forced_claims.push((mission.subject.clone(), claim_id.clone(), mission.revision.clone())); + } tokens.insert(mission.subject.clone(), vec![claim_id]); } for declaration in intent.mission_runs.values() { @@ -4237,6 +4308,20 @@ impl Store { reason: "the replacement claim resolved the invalid record".into(), }); } + let mut source_debts_recorded = Vec::new(); + for (subject, claim_id, revision) in &forced_claims { + let (view, verb) = &forced[subject]; + let debt = owned_sets::record_debt_tx(transaction, &self.origin, view, subject, *verb, + owned_sets::Forced::Declaration { claim: claim_id, revision }, forced_diffs.get(subject), actor, + force.unwrap_or_default(), Some(&batch_id))?; + claim_ids.push(debt.id.clone()); + source_debts_recorded.push(debt); + } + for (subject, view, verb, refused) in &audited { + let record = owned_sets::record_audit_tx(transaction, &self.origin, view, subject, *verb, + actor, refused, Some(&batch_id))?; + claim_ids.push(record.id); + } if let Some(plan) = &owned_plan { let receipt = owned_sets::commit_tx(transaction, &self.origin, plan, actor, &batch_id)?; tokens.insert(plan.preview.set.clone(), vec![receipt.id.clone()]); @@ -4256,6 +4341,9 @@ impl Store { reconcile_subjects, resolved_kdl: String::new(), operations: operation_receipts, + source_debts_recorded, + source_debts_reverted: owned_plan.as_ref() + .map(|plan| plan.preview.reverted_debts.clone()).unwrap_or_default(), }; transaction .execute( @@ -11493,6 +11581,25 @@ pub fn append_claim_tx_with_assistant_answer( predecessors: &[String], forced_batch: Option<&str>, authorization: Option<&assistant_write::AssistantWriteAuthorization>, +) -> Result { + append_claim_tx_admitted(transaction, origin, subject, kind, actor, body, predecessors, forced_batch, + authorization, false) +} + +/// `source_forced`: the subject's owning set already admitted this declaration as a forced +/// write with source debt ([`owned_sets::gate`]), so the plain set-member refusal is skipped. +#[allow(clippy::too_many_arguments)] +fn append_claim_tx_admitted( + transaction: &Transaction<'_>, + origin: &str, + subject: &str, + kind: &str, + actor: Option<&str>, + body: &Value, + predecessors: &[String], + forced_batch: Option<&str>, + authorization: Option<&assistant_write::AssistantWriteAuthorization>, + source_forced: bool, ) -> Result { let mut assistant_body; let body = if let Some(authorization) = authorization { @@ -11552,6 +11659,7 @@ pub fn append_claim_tx_with_assistant_answer( body, predecessors, forced_batch, + source_forced, ) } @@ -11592,11 +11700,12 @@ fn replicated_work_validation_fields<'a>( body: &Value, predecessors: &[String], forced_batch: Option<&str>, + source_forced: bool, ) -> Result { fractal_missions_base::declared_admission::validate_claim(kind, subject, body).map_err(anyhow::Error::new)?; declared_admission::validate_claim(transaction, origin, kind, body).map_err(anyhow::Error::new)?; agent_charter::validate_claim(transaction, origin, kind, body).map_err(anyhow::Error::new)?; - if matches!(kind, "intent.desired" | "mission.published") { + if matches!(kind, "intent.desired" | "mission.published") && !source_forced { owned_sets::refuse_unmanaged(transaction, subject).map_err(anyhow::Error::new)?; } crate::terminal_binding::validate_claim(kind, body, actor).map_err(anyhow::Error::new)?; @@ -11701,6 +11810,28 @@ pub fn append_receipt_claim_tx( Ok(record) } +/// [`append_receipt_claim_tx`] for a declaration of a set member that [`owned_sets::gate`] +/// admitted as forced in this transaction; the caller records its source debt in the same +/// transaction. +#[allow(clippy::too_many_arguments)] +pub fn append_source_forced_receipt_claim_tx( + transaction: &Transaction<'_>, + origin: &str, + subject: &str, + kind: &str, + actor: Option<&str>, + body: &Value, + predecessors: &[String], + key: &str, +) -> Result { + let mut body = body.clone(); + smallclaims::store::idempotency::attach(&mut body, key)?; + let record = append_claim_tx_admitted(transaction, origin, subject, kind, actor, &body, predecessors, None, + None, true)?; + register_operation_tx(transaction, &record)?; + Ok(record) +} + fn mission_run_creation_time(fields: &Value, accepted_at: u128) -> u128 { fields .get("deadline_at_unix_ms") @@ -22948,6 +23079,8 @@ pub fn append_claim_with_subject_fences( } pub type ClaimAdmission<'a> = dyn Fn(&Connection) -> Result<(), FractalError> + Sync + 'a; +/// Writes that belong with a newly appended claim, in its transaction; a replay runs none. +pub type ClaimFollowUp<'a> = dyn Fn(&Transaction<'_>, &ClaimRecord) -> Result<(), FractalError> + Sync + 'a; #[derive(Default)] pub struct ClaimCommitContext<'a> { @@ -22963,6 +23096,8 @@ pub struct ClaimCommitContext<'a> { pub native_receipt: Option<&'a crate::conversation_delivery::NativeReceipt>, /// Dedicated client operations publish a native receipt in the same transaction. pub client_operation: bool, + /// Only a replicated claim (written in the writer's batch) can carry follow-up writes. + pub then: Option<&'a ClaimFollowUp<'a>>, /// Write the claim as a build from before its kind became a local observation did: a /// replicated claim. Tests use it for the claims older members still replicate. #[cfg(any(test, feature = "test-support"))] @@ -23065,12 +23200,18 @@ pub fn append_claim_with_commit_context( || local_code(input) || (input.actor.is_none() && system_local_retention(&input.kind))) { + if context.then.is_some() { + return Err(FractalError::new("internal", format!("{} is local; it cannot carry follow-up writes", input.kind))); + } let connection = graph.readers.get(); admission(&connection)?; let input = identities::normalize_claim_input(&connection, input)?; return append_local_observation_fenced(graph, &input, event_runtime); } if latest_retention(&input.kind) { + if context.then.is_some() { + return Err(FractalError::new("internal", format!("{} is local; it cannot carry follow-up writes", input.kind))); + } let connection = graph.readers.get(); admission(&connection)?; let input = identities::normalize_claim_input(&connection, input)?; @@ -23375,6 +23516,7 @@ pub fn append_claim_with_commit_context( &body, &predecessors, None, + false, ) .map_err(claim_append_error)?; if operation.is_some() { @@ -23385,6 +23527,9 @@ pub fn append_claim_with_commit_context( if context.client_operation && outcome.1 { crate::pairing::append_operation_receipt_tx(transaction, &outcome.0).map_err(internal)?; } + if outcome.1 && let Some(then) = context.then { + then(transaction, &outcome.0)?; + } if let Some((subject, revision, current)) = observer_completion { check_observer_completion(transaction, subject, revision, Some(current))?; } diff --git a/crates/fractal-store/src/store/owned_sets.rs b/crates/fractal-store/src/store/owned_sets.rs index 9a77b9b772f..410ae346136 100644 --- a/crates/fractal-store/src/store/owned_sets.rs +++ b/crates/fractal-store/src/store/owned_sets.rs @@ -99,6 +99,9 @@ pub struct Revision { pub members: BTreeMap, pub retired: BTreeMap, pub adoptions: BTreeMap>, + /// The publisher's source policy; absent is `off`. A publication that omits it keeps it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -117,6 +120,9 @@ pub struct Options { pub confirm_retire: Option, #[serde(default)] pub expected_subjects: BTreeMap>, + /// Changes to the selected source policy; omitted keeps it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -146,6 +152,12 @@ pub struct Preview { pub empty: bool, pub blockers: Vec, pub noop: bool, + /// The source policy this publication selects. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Open source debt against the previous revision that this publication closes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reverted_debts: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -165,6 +177,8 @@ pub struct Plan { pub retired: BTreeMap, pub adoptions: BTreeMap>, bundle_digest: String, + /// The selected revision's policy, whose `publisher` list this publication must satisfy. + prior_policy: Option, pub materialize: BTreeSet, } @@ -284,11 +298,17 @@ fn task_parent(desired: &DesiredSubject) -> Option<&str> { } fn stop_declaration(subject: &str) -> Result { + // A schedule or subscription retires through its stopped form, keeping its kind. let source = if let Some(name) = subject.strip_prefix("schedule/") { format!( "version 2\nschedule {} {{ stop }}\n", serde_json::to_string(name).map_err(internal)? ) + } else if let Some(name) = subject.strip_prefix("subscription/") { + format!( + "version 2\nsubscription {} {{ stop }}\n", + serde_json::to_string(name).map_err(internal)? + ) } else { format!( "version 2\nstop {}\n", @@ -345,6 +365,7 @@ fn effective_members_uncached( }; members.insert(subject, (member, true, true)); } + let overrides = debt_overrides(connection, view, at)?; for (s, m) in &view.receipt.members { let retirement = if m.one_shot { one_shot_retirement(connection, &view.id, s, m, at)? @@ -361,6 +382,18 @@ fn effective_members_uncached( true, false, ) + } else if let Some((claim, revision)) = overrides.get(s) { + // A forced ad hoc declaration holds until the set publishes its next revision. + // A forced stop is not a set retirement: ownership and the source stay unchanged. + ( + Member { + claim: claim.clone(), + revision: revision.clone(), + ..m.clone() + }, + false, + false, + ) } else { (m.clone(), false, false) }; @@ -538,6 +571,10 @@ fn selected_uncached(connection: &Connection, at: Option) -> Result Result<(), Fra Ok(()) } +/// One accepted ad hoc change to an owned-set member (`--force --reason`). +pub const DEBT_KIND: &str = "source.debt.recorded"; +/// The set publication that converged one recorded debt, written in that publication. +pub const DEBT_CLOSED_KIND: &str = "source.debt.closed"; +/// A write an `enforce` set would refuse, accepted by an `audit` set. +pub const AUDITED_KIND: &str = "source.audited"; + +/// How a set routes desired-state writes to its members through its source. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Enforce { + /// Today's owned-set behaviour: plain declarations of members are refused, nothing else. + #[default] + Off, + /// Nothing is refused beyond `off`; each write `enforce` would refuse is recorded. + Audit, + /// Ad hoc writes are refused, or forced with `--force --reason` as source debt. + Enforce, +} + +impl Enforce { + pub fn as_str(self) -> &'static str { + match self { + Enforce::Off => "off", + Enforce::Audit => "audit", + Enforce::Enforce => "enforce", + } + } +} + +/// A set's source policy, declared by its publisher and carried in its revision. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct SourcePolicy { + #[serde(default)] + pub enforce: Enforce, + /// Actors allowed to publish the set; empty allows any publisher. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub publisher: Vec, + /// Globs (`*` matches any run) claiming new top-level subjects for the set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub namespaces: Vec, + /// Globs exempting subjects inside `namespaces`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unmanaged_allow: Vec, +} + +/// A publication's policy change. Each omitted field keeps the selected revision's value. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct PolicyRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespaces: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unmanaged_allow: Option>, +} + +/// The policy a publication selects: the prior one with each requested field replaced. A policy +/// equal to the default (`off`, no lists) is absent. +pub fn resolve_policy( + prior: Option<&SourcePolicy>, + requested: Option<&PolicyRequest>, +) -> Result, FractalError> { + let Some(requested) = requested else { + return Ok(prior.cloned()); + }; + let prior = prior.cloned().unwrap_or_default(); + let policy = SourcePolicy { + enforce: requested.enforce.unwrap_or(prior.enforce), + publisher: requested.publisher.clone().unwrap_or(prior.publisher), + namespaces: requested.namespaces.clone().unwrap_or(prior.namespaces), + unmanaged_allow: requested.unmanaged_allow.clone().unwrap_or(prior.unmanaged_allow), + }; + let invalid = |message: String| Err(FractalError::new("invalid-source-policy", message)); + if let Some(actor) = policy + .publisher + .iter() + .find(|a| !(a.starts_with("person/") || a.starts_with("agent/")) || a.ends_with('/')) + { + return invalid(format!("publisher {actor:?} is not a person/ or agent/ subject")); + } + if let Some(glob) = policy.namespaces.iter().chain(&policy.unmanaged_allow).find(|g| { + !NAMESPACE_KINDS.iter().any(|kind| g.starts_with(kind)) || g.len() > 512 + }) { + return invalid(format!( + "namespace glob {glob:?} must start with one of {}", + NAMESPACE_KINDS.join(", ") + )); + } + Ok((policy != SourcePolicy::default()).then_some(policy)) +} + +/// Top-level subject families a set namespace can claim. +const NAMESPACE_KINDS: [&str; 4] = ["agent/", "schedule/", "subscription/", "mission/"]; + +/// The set's source enforcement; a set without a policy is `off`. +pub fn enforcement(view: &View) -> Enforce { + view.receipt.policy.as_ref().map_or(Enforce::Off, |p| p.enforce) +} + +/// A desired-state write a set's source owns, as refusals and debt name it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verb { + /// A plain declaration of a member. + Apply, + /// A plain declaration of a new subject in a claimed namespace. + Create, + /// A stop declaration (`agents stop`, a stopped schedule or subscription). + Stop, + /// A declaration that starts a stopped member (`agents start`). + Start, + MissionPublish, + MissionRetire, + Hold, + Suspend, + Resume, + /// Terminal `agents retire`; never forced. + Retire, +} + +impl Verb { + pub fn as_str(self) -> &'static str { + match self { + Verb::Apply => "apply", + Verb::Create => "create", + Verb::Stop => "stop", + Verb::Start => "start", + Verb::MissionPublish => "mission publish", + Verb::MissionRetire => "mission retire", + Verb::Hold => "hold", + Verb::Suspend => "suspend", + Verb::Resume => "resume", + Verb::Retire => "retire", + } + } + + /// Declarations: an owned set refuses these on its members whatever its policy. + fn declares(self) -> bool { + matches!( + self, + Verb::Apply | Verb::Create | Verb::Stop | Verb::Start | Verb::MissionPublish | Verb::MissionRetire + ) + } + + /// The verb of a plain declaration of `desired`, against its current projection. + pub fn of_declaration( + connection: &Connection, + desired: &DesiredSubject, + ) -> Result { + let stopped = desired.kind == "stop" + || (desired.kind == "schedule" + && crate::graph::schedule_spec(&desired.desired, "unused").is_some_and(|s| s.stopped)) + || (desired.kind == "subscription" + && crate::graph::subscription_spec(&desired.desired).is_some_and(|s| s.stopped)); + if stopped { + return Ok(Verb::Stop); + } + let current = current_desired_row(connection, &desired.subject).map_err(internal)?; + Ok(if current.is_some_and(|row| row.kind == "stop") { + Verb::Start + } else { + Verb::Apply + }) + } +} + +/// `*` matches any run of characters, including `/`. +fn glob(pattern: &str, subject: &str) -> bool { + match pattern.split_once('*') { + None => pattern == subject, + Some((head, rest)) => { + let Some(tail) = subject.strip_prefix(head) else { return false }; + (0..=tail.len()).filter(|i| tail.is_char_boundary(*i)).any(|i| glob(rest, &tail[i..])) + } + } +} + +/// The set claiming a new top-level subject through its namespaces, unless an allow glob +/// exempts it. Only `audit` and `enforce` sets claim. +fn namespace_claimant(connection: &Connection, subject: &str) -> Result, FractalError> { + if !NAMESPACE_KINDS.iter().any(|kind| subject.starts_with(kind)) { + return Ok(None); + } + Ok(selected(connection, None)?.into_iter().find(|view| { + view.blockers.is_empty() + && enforcement(view) != Enforce::Off + && view.receipt.policy.as_ref().is_some_and(|p| { + p.namespaces.iter().any(|g| glob(g, subject)) + && !p.unmanaged_allow.iter().any(|g| glob(g, subject)) + }) + })) +} + +fn source_line(view: &View) -> String { + let source = &view.receipt.source; + format!( + "{} from {} {} at {} (source sequence {}, enforce {})", + view.id, + source.repository, + source.r#ref, + source.sha, + source.sequence, + enforcement(view).as_str() + ) +} + +fn refusal(view: &View, subject: &str, verb: Verb, message: String) -> FractalError { + FractalError::new("set-managed-subject", message) + .with_detail("subject", subject) + .with_detail("verb", verb.as_str()) + .with_detail("set", view.id.clone()) + .with_detail("set_revision", reference(view)) + .with_detail("source", json!(view.receipt.source)) + .with_detail("enforce", enforcement(view).as_str()) +} + +fn namespace_refusal(view: &View, subject: &str) -> FractalError { + refusal(view, subject, Verb::Create, format!( + "{subject} is in a namespace claimed by {}; declare it in that set's source, or accept \ + this ad hoc create as source debt with --force --reason TEXT", + source_line(view), + )) + .with_detail("escape", "--force --reason TEXT") +} + +fn retire_refusal(view: &View, member: &str) -> FractalError { + refusal(view, member, Verb::Retire, format!( + "{member} is a live member of {}; retirement is terminal and cannot be forced: omit \ + {member} from the set publication, then retire it", + source_line(view), + )) +} + +fn ad_hoc_refusal(view: &View, member: &str, verb: Verb) -> FractalError { + refusal(view, member, verb, format!( + "{member} is managed by {}; ad hoc {} is refused: publish the change through that set, \ + or accept this ad hoc {} as source debt with --force --reason TEXT", + source_line(view), + verb.as_str(), + verb.as_str(), + )) + .with_detail("escape", "--force --reason TEXT") +} + +fn force_refused(message: String) -> FractalError { + FractalError::new("source-force-refused", message) +} + +/// What a desired-state write to `subject` may do under its owning set's source policy. +pub enum Gate { + /// No source policy applies. + Allow, + /// An `audit` set accepts the write; record the refusal `enforce` would return. + Audit(Box, Box), + /// A forced write: record source debt against this set. + Force(Box), +} + +/// Decide one write to `subject`. `force` is the `--force` reason; it must be nonempty. +pub fn gate( + connection: &Connection, + subject: &str, + verb: Verb, + force: Option<&str>, +) -> Result { + let force = force.map(str::trim); + if force == Some("") { + return Err(force_refused("--force needs a nonempty --reason".into())); + } + let Some(set) = owner(connection, subject, None)? else { + if verb != Verb::Create { + return Ok(Gate::Allow); + } + // A new subject: only a claimed namespace routes it through source. + return match namespace_claimant(connection, subject)? { + None => Ok(Gate::Allow), + Some(view) if force.is_some() => Ok(Gate::Force(Box::new(view))), + Some(view) if enforcement(&view) == Enforce::Audit => { + let refused = namespace_refusal(&view, subject); + Ok(Gate::Audit(Box::new(view), Box::new(refused))) + } + Some(view) => Err(namespace_refusal(&view, subject)), + }; + }; + let view = selected(connection, None)?.into_iter().find(|v| v.id == set); + let live = match &view { + Some(view) if view.blockers.is_empty() => effective_members(connection, view, None)? + .get(subject) + .is_some_and(|(_, retired, _)| !retired), + _ => false, + }; + let (Some(view), true) = (view, live) else { + // A pending, blocked or retired membership: declarations keep today's refusal. + if verb.declares() { + if force.is_some() { + return Err(force_refused(format!( + "{subject}: {set} is pending, blocked or has retired it; publish it through the set" + ))); + } + refuse_unmanaged(connection, subject)?; + } + return Ok(Gate::Allow); + }; + let mode = enforcement(&view); + if mode == Enforce::Off { + if !verb.declares() { + return Ok(Gate::Allow); + } + if force.is_some() { + return Err(force_refused(format!( + "{subject}: {} declares no source policy, so it has no force path; publish the \ + change through that set", + view.id + ))); + } + return refuse_unmanaged(connection, subject).map(|()| Gate::Allow); + } + if verb == Verb::Retire { + let refused = retire_refusal(&view, subject); + return match mode { + Enforce::Audit => Ok(Gate::Audit(Box::new(view), Box::new(refused))), + _ => Err(refused), + }; + } + if force.is_some() { + return Ok(Gate::Force(Box::new(view))); + } + let refused = ad_hoc_refusal(&view, subject, verb); + // An unforced declaration cannot take effect over the set's own member reference. + if mode == Enforce::Enforce || verb.declares() { + return Err(refused); + } + Ok(Gate::Audit(Box::new(view), Box::new(refused))) +} + +/// What a forced write changed: a declaration the set's member reference yields to until the +/// next publication, or a runtime request (hold, suspend, resume) that publication reverts. +#[derive(Clone, Copy, Debug)] +pub enum Forced<'a> { + Declaration { claim: &'a str, revision: &'a str }, + Request(&'a str), +} + +/// Record one ad hoc change as source debt against the selected set revision. +#[allow(clippy::too_many_arguments)] +pub fn record_debt_tx( + transaction: &Transaction<'_>, + origin: &str, + view: &View, + subject: &str, + verb: Verb, + forced: Forced<'_>, + diff: Option<&declarations::DeclarationDiff>, + actor: Option<&str>, + reason: &str, + batch: Option<&str>, +) -> Result { + let reason = reason.trim(); + if reason.is_empty() { + return Err(force_refused("--force needs a nonempty --reason".into())); + } + let source = &view.receipt.source; + let mut fields = json!({ + "set": view.id, "set_revision": reference(view), "repository": source.repository, + "ref": source.r#ref, "sha": source.sha, "verb": verb.as_str(), "reason": reason, + "pointers": diff.map(|d| d.fields.iter().cloned().collect::>()).unwrap_or_default(), + }); + match forced { + Forced::Declaration { claim, revision } => { + fields["claim"] = json!(claim); + fields["revision"] = json!(revision); + } + Forced::Request(request) => fields["request"] = json!(request), + } + if let Some(diff) = diff { + fields["before"] = diff.before.clone().unwrap_or(Value::Null); + fields["after"] = diff.after.clone(); + } + let record = append_claim_tx( + transaction, + origin, + subject, + DEBT_KIND, + actor, + &json!({ "fields": fields }), + &[], + batch, + ) + .map_err(internal)?; + debt_view(&record).ok_or_else(|| FractalError::new("internal", "unreadable source debt")) +} + +/// Record a write an `enforce` set would have refused, with that refusal. +#[allow(clippy::too_many_arguments)] +pub fn record_audit_tx( + transaction: &Transaction<'_>, + origin: &str, + view: &View, + subject: &str, + verb: Verb, + actor: Option<&str>, + refused: &FractalError, + batch: Option<&str>, +) -> Result { + append_claim_tx( + transaction, + origin, + subject, + AUDITED_KIND, + actor, + &json!({"fields": {"set": view.id, "set_revision": reference(view), "verb": verb.as_str(), + "refusal": refused.message}}), + &[], + batch, + ) + .map_err(internal) +} + +fn debt_view(record: &ClaimRecord) -> Option { + let f = &record.body["fields"]; + let text = |name: &str| f[name].as_str().map(str::to_owned); + Some(crate::model::SourceDebt { + id: record.id.clone(), + subject: record.subject.clone(), + verb: text("verb")?, + set: text("set")?, + set_revision: text("set_revision")?, + repository: text("repository")?, + r#ref: text("ref")?, + sha: text("sha")?, + claim: text("claim"), + actor: record.actor.clone(), + reason: text("reason")?, + fields: f["pointers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| p.as_str().map(str::to_owned)) + .collect(), + recorded_at: chrono::DateTime::from_timestamp_millis(record.accepted_at_unix_ms as i64)? + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + outcome: None, + closed_by: None, + }) +} + +/// Every debt recorded against `set` (any set when `None`) that no `source.debt.closed` claim +/// closes, newest first. +fn open_debt_records( + connection: &Connection, + set: Option<&str>, + at: Option, +) -> Result, FractalError> { + let mut statement = connection + .prepare_cached(/* sqlite-large-value: allow-for-now lv-hb491fd4d2964 owner=fractal_store::store::owned_sets TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms + FROM claims WHERE kind='source.debt.recorded' AND store_index<=?1 + AND (?2 IS NULL OR json_extract(body,'$.fields.set')=?2) + AND NOT EXISTS (SELECT 1 FROM replica_records WHERE replica_records.claim_id=claims.id AND replica_records.state='repaired') + AND NOT EXISTS (SELECT 1 FROM claims closed WHERE closed.subject=claims.subject + AND closed.kind='source.debt.closed' AND closed.store_index<=?1 + AND json_extract(closed.body,'$.fields.debt')=claims.id) + ORDER BY CANONICAL_DESC(claims)", + )) + .map_err(internal)?; + statement + .query_map(params![at.unwrap_or(u64::MAX).min(i64::MAX as u64), set], claim_from_row) + .map_err(internal)? + .collect::, _>>() + .map_err(internal) +} + +/// The newest open forced declaration per member against exactly this selected revision. Sets +/// without an `audit` or `enforce` policy carry no debt and read nothing. +fn debt_overrides( + connection: &Connection, + view: &View, + at: Option, +) -> Result, FractalError> { + let mut overrides = BTreeMap::new(); + if enforcement(view) == Enforce::Off { + return Ok(overrides); + } + let current = reference(view); + for record in open_debt_records(connection, Some(&view.id), at)? { + let f = &record.body["fields"]; + let (Some(claim_id), Some(revision)) = (f["claim"].as_str(), f["revision"].as_str()) else { + continue; + }; + if f["set_revision"].as_str() != Some(current.as_str()) + || overrides.contains_key(&record.subject) + || !view.receipt.members.contains_key(&record.subject) + { + continue; + } + let valid = claim(connection, claim_id, at)?.is_some_and(|c| { + c.subject == record.subject + && if c.kind == "mission.published" { + c.body["revision"].as_str() == Some(revision) + } else { + c.kind == "intent.desired" + && serde_json::from_value::(c.body).is_ok_and(|d| { + desired_revision(&d) == revision + && d.owner_run.is_none() + && d.owner_step.is_none() + }) + } + }); + if valid { + overrides.insert(record.subject.clone(), (claim_id.to_owned(), revision.to_owned())); + } + } + Ok(overrides) +} + +/// Whether the runtime request a forced hold or suspension recorded is still the subject's +/// latest of its kind. The publication that closes such debt reverts it. +fn forced_request_in_place(connection: &Connection, debt: &ClaimRecord) -> Result { + let fields = &debt.body["fields"]; + let Some(request) = fields["request"].as_str() else { + return Ok(false); + }; + let latest = match fields["verb"].as_str() { + Some("hold") => connection + .query_row(/* sqlite-large-value: allow-for-now lv-ha652f7f9027c owner=fractal_store::store::owned_sets TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT id,json_extract(body,'$.fields.held') FROM claims WHERE subject=?1 AND kind='delivery.hold' + ORDER BY CANONICAL_DESC(claims) LIMIT 1"), + [&debt.subject], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional() + .map_err(internal)? + .filter(|(_, held)| *held == Some(true)) + .map(|(id, _)| id), + Some("suspend") => connection + .query_row(/* sqlite-large-value: allow-for-now lv-h19840d31732b owner=fractal_store::store::owned_sets TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT id FROM claims WHERE subject=?1 AND kind='runtime.action.requested' AND actor IS NOT NULL + AND json_extract(body,'$.fields.action') IN ('suspend','resume') + ORDER BY CANONICAL_DESC(claims) LIMIT 1"), + [&debt.subject], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(internal)?, + _ => None, + }; + Ok(latest.as_deref() == Some(request)) +} + +/// Close every open debt of the set this plan publishes: one `source.debt.closed` per debt in +/// the publication transaction, naming the revision that closed it. A forced hold still in +/// place is released and a forced suspension still in place is resumed, in the same transaction. +fn close_debts_tx( + transaction: &Transaction<'_>, + origin: &str, + plan: &Plan, + closing: &str, + actor: Option<&str>, + batch: &str, +) -> Result<(), FractalError> { + for debt in &plan.preview.reverted_debts { + let outcome = debt.outcome.as_deref().unwrap_or("closed"); + append_claim_tx( + transaction, + origin, + &debt.subject, + DEBT_CLOSED_KIND, + actor, + &json!({"fields": {"debt": debt.id, "set": plan.preview.set, "outcome": outcome, + "set_revision": closing, "publisher": actor}}), + &[], + Some(batch), + ) + .map_err(internal)?; + if outcome != "reverted" { + continue; + } + let revert_reason = format!( + "source {} reverted the ad hoc {}: {}", + plan.preview.source.sha, debt.verb, debt.reason + ); + if debt.verb == Verb::Hold.as_str() { + append_claim_tx( + transaction, + origin, + &debt.subject, + "delivery.hold", + actor, + &json!({"fields": {"held": false, "until_unix_ms": 0, "legacy_adoption": false, + "reason": revert_reason}}), + &[], + Some(batch), + ) + .map_err(internal)?; + } else if debt.verb == Verb::Suspend.as_str() { + resume_forced_suspension_tx(transaction, origin, debt, actor, &revert_reason, batch)?; + } + } + Ok(()) +} + +/// Resume the seat a forced suspension holds, as `agents resume` on its own host would: the +/// request names the seat's current launch and the suspend it resumes. +fn resume_forced_suspension_tx( + transaction: &Transaction<'_>, + origin: &str, + debt: &crate::model::SourceDebt, + actor: Option<&str>, + reason: &str, + batch: &str, +) -> Result<(), FractalError> { + let request = claim(transaction, &debt.id, None)? + .and_then(|record| record.body["fields"]["request"].as_str().map(str::to_owned)) + .ok_or_else(|| FractalError::new("internal", "a forced suspension debt without its request"))?; + let Some(row) = current_desired_row(transaction, &debt.subject).map_err(internal)? else { + return Ok(()); + }; + let Some(member) = row.member.as_deref() else { + return Ok(()); + }; + let member: MemberSpec = serde_json::from_str(member).map_err(internal)?; + let predecessors = latest_claim_id_tx(transaction, &debt.subject) + .map_err(internal)? + .into_iter() + .collect::>(); + append_claim_tx( + transaction, + origin, + &debt.subject, + "runtime.action.requested", + actor, + &json!({"fields": {"action": "resume", "runtime_id": member.runtime_id, "reason": reason}, + "evidence": [row.claim_id, request]}), + &predecessors, + Some(batch), + ) + .map_err(internal)?; + Ok(()) +} + +/// The debt each of `set_revisions` closed, newest closure first. +fn closed_debts( + connection: &Connection, + set_revisions: &BTreeSet, +) -> Result, FractalError> { + let mut statement = connection + .prepare_cached(/* sqlite-large-value: allow-for-now lv-h80b258f37688 owner=fractal_store::store::owned_sets TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT debt.id,debt.store_index,debt.batch_id,debt.subject,debt.kind,debt.origin,debt.actor,debt.body, + debt.predecessors,debt.accepted_at_unix_ms, + json_extract(closed.body,'$.fields.outcome'),json_extract(closed.body,'$.fields.set_revision'),closed.id + FROM claims closed JOIN claims debt ON debt.id=json_extract(closed.body,'$.fields.debt') + WHERE closed.kind='source.debt.closed' AND debt.kind='source.debt.recorded' + AND json_extract(closed.body,'$.fields.set_revision') IN (SELECT value FROM json_each(?1)) + ORDER BY closed.store_index DESC", + )) + .map_err(internal)?; + let revisions = serde_json::to_string(set_revisions).map_err(internal)?; + let rows = statement + .query_map([revisions], |row| { + Ok((claim_from_row(row)?, row.get::<_, String>(10)?, row.get::<_, String>(11)?, row.get::<_, String>(12)?)) + }) + .map_err(internal)? + .collect::, _>>() + .map_err(internal)?; + Ok(rows + .into_iter() + .filter_map(|(record, outcome, closing, closure)| { + let mut debt = debt_view(&record)?; + debt.outcome = Some(outcome); + debt.closed_by = Some(json!({"set_revision": closing, "claim": closure})); + Some(debt) + }) + .collect()) +} + +/// `fractal source status --audit` shows at most this many audit records, newest first. +const AUDIT_STATUS_LIMIT: i64 = 200; + +/// `fractal source status`: every selected set's policy, all open debt, the debt each set's +/// current revision closed, and with `audit` the newest audit records. +pub fn source_status(connection: &Connection, audit: bool) -> Result { + let views = selected(connection, None)?; + let sets = views + .iter() + .map(|view| { + json!({"set": view.id, "revision": view.revision, "enforce": enforcement(view).as_str(), + "policy": view.receipt.policy, "source": view.receipt.source, + "members": view.receipt.members.len(), "blockers": view.blockers}) + }) + .collect::>(); + let open = open_debt_records(connection, None, None)? + .iter() + .filter_map(debt_view) + .collect::>(); + let closed = closed_debts(connection, &views.iter().map(reference).collect())?; + let mut status = json!({"sets": sets, "open": open, "closed": closed}); + if audit { + let mut statement = connection + .prepare_cached(/* sqlite-large-value: allow-for-now lv-hd1fb700a1544 owner=fractal_store::store::owned_sets TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms + FROM claims WHERE kind='source.audited' ORDER BY CANONICAL_DESC(claims) LIMIT ?1", + )) + .map_err(internal)?; + let audited = statement + .query_map([AUDIT_STATUS_LIMIT], claim_from_row) + .map_err(internal)? + .map(|row| { + row.map(|c| { + let f = &c.body["fields"]; + json!({"id": c.id, "subject": c.subject, "verb": f["verb"], "actor": c.actor, + "set": f["set"], "set_revision": f["set_revision"], "refusal": f["refusal"], + "accepted_at_unix_ms": c.accepted_at_unix_ms}) + }) + }) + .collect::, _>>() + .map_err(internal)?; + status["audited"] = json!(audited); + status["audited_limit"] = json!(AUDIT_STATUS_LIMIT); + } + Ok(status) +} + /// A member's conflicts come from its owning set, rather than independent declaration leaves. pub fn conflicts_at( connection: &Connection, @@ -693,7 +1454,7 @@ pub fn plan_tx( || !input.resource_refreshes.is_empty() || !input.replica_repairs.is_empty() || input.subjects.values().any(|s| { - (!matches!(s.kind.as_str(), "agent" | "schedule") + (!matches!(s.kind.as_str(), "agent" | "schedule" | "subscription") && !(s.kind == "resource" && crate::graph::declared_uri(&s.desired).is_some()) && task_parent(s).is_none_or(|parent| { input.subjects.get(parent).is_none_or(|p| p.kind != "agent") @@ -704,7 +1465,7 @@ pub fn plan_tx( { return Err(FractalError::new( "unsupported-set-member", - "owned sets manage top-level agents with their exec and pty tasks, missions and schedules only", + "owned sets manage top-level agents with their exec and pty tasks, missions, schedules and subscriptions only", )); } let old = selected(transaction, None)? @@ -721,10 +1482,24 @@ pub fn plan_tx( }) }; let bundle_digest = canonical_hash(&(&input.subjects, &input.missions)).map_err(internal)?; + let prior_policy = old.as_ref().and_then(|v| v.receipt.policy.clone()); + let policy = resolve_policy(prior_policy.as_ref(), options.policy.as_ref())?; + // Every debt this set still carries; this publication converges and closes all of it. + let open_debts = match &old { + Some(old) => open_debt_records(transaction, Some(&old.id), None)?, + None => Vec::new(), + }; + // An ad hoc creation forced into a claimed namespace is adopted when the source declares it. + let created: BTreeSet = open_debts + .iter() + .filter(|r| r.body["fields"]["verb"] == Verb::Create.as_str()) + .map(|r| r.subject.clone()) + .collect(); let noop = old.as_ref().is_some_and(|v| { v.receipt.source == options.source && v.receipt.bundle_digest == bundle_digest && v.receipt.rollout == options.rollout + && v.receipt.policy == policy }); let mut blockers = Vec::new(); let mut manual_seats = input.subjects.values().any(crate::rollout::manual); @@ -752,6 +1527,17 @@ pub fn plan_tx( .chain(view.receipt.retired.values()) .any(|m| matches!(m.kind.as_str(), "exec" | "pty")) }); + // Older daemons drop revisions carrying a policy (the receipt hash covers it) or + // subscription members, and ignore debt. + let source_enforcement = policy.is_some() + || input.subjects.values().any(|s| s.kind == "subscription") + || old.as_ref().is_some_and(|view| { + view.receipt + .members + .values() + .chain(view.receipt.retired.values()) + .any(|m| m.kind == "subscription") + }); let membership = fleet_membership_tx(transaction).map_err(internal)?; for member in membership.incarnations().filter(|m| m.end.is_none()) { let advertised = |feature: &str| -> Result { @@ -767,6 +1553,12 @@ pub fn plan_tx( member.name )); } + if source_enforcement && !advertised("source_enforcement")? { + blockers.push(format!( + "host/{} has not advertised source enforcement support; upgrade before activation", + member.name + )); + } if manual_seats && !advertised("seat_rollout_manual")? { blockers.push(format!("host/{} has not advertised manual seat-rollout support; upgrade before activation", member.name)); } @@ -843,11 +1635,12 @@ pub fn plan_tx( if own.is_none() && !tokens.is_empty() { // An agent's tasks are adopted with the agent that declares them. let adopted = options.adopt.contains(s) + || created.contains(s) || input .subjects .get(s) .and_then(task_parent) - .is_some_and(|parent| options.adopt.contains(parent)); + .is_some_and(|parent| options.adopt.contains(parent) || created.contains(parent)); if !adopted { blockers.push(format!("explicit adoption required: {s}")); } else { @@ -871,6 +1664,8 @@ pub fn plan_tx( "publish definition; retain active runs".to_owned() } else if s.starts_with("schedule/") { "update future occurrences; retain created runs".to_owned() + } else if s.starts_with("subscription/") { + "update deliveries; retain delivered messages".to_owned() } else if let Some(prior) = existing { let old_subject = claim(transaction, &prior.claim, None)? .and_then(|c| serde_json::from_value::(c.body).ok()); @@ -1007,6 +1802,8 @@ pub fn plan_tx( s.clone(), if m.kind == "schedule" { "stop future occurrences; retain runs" + } else if m.kind == "subscription" { + "stop deliveries; retain delivered messages" } else { if manual { "publish retirement; rollout pending (manual)" @@ -1024,9 +1821,45 @@ pub fn plan_tx( retired.insert(s.clone(), retiring_member); } } + // A forced creation the source still does not declare is taken over and retired by the set: + // a mission through its retired definition, any other subject through its stopped form. + if !noop { + let orphaned: Vec = created + .iter() + .filter(|s| !live.contains_key(*s) && !changes.contains_key(*s)) + .cloned() + .collect(); + for s in &orphaned { + if let Some(mission_id) = s.strip_prefix("mission/") { + let claim_id: String = transaction + .query_row( + "SELECT claim_id FROM mission_definitions WHERE mission_id=?1", + [mission_id], + |row| row.get(0), + ) + .map_err(internal)?; + let c = claim(transaction, &claim_id, None)?.ok_or_else(|| { + FractalError::new("owned-set-pending", "missing mission reference") + })?; + let spec: MissionSpec = serde_json::from_value(c.body).map_err(internal)?; + let spec = crate::mission::retired_mission(spec)?; + intent.missions.insert(spec.id.clone(), spec); + heads.insert( + s.clone(), + mission_definition_token_tx(transaction, mission_id).map_err(internal)?, + ); + } else { + intent.subjects.insert(s.clone(), stop_declaration(s)?); + heads.insert(s.clone(), intent_leaves_tx(transaction, s).map_err(internal)?); + } + changes.insert(s.clone(), "retiring".into()); + effects.insert(s.clone(), "stop ad hoc creation the source does not declare".into()); + } + } + // Stopping a forced ad hoc creation is not a retirement of source-declared membership. let retiring = changes - .values() - .filter(|v| v.as_str() == "retiring") + .iter() + .filter(|(s, v)| v.as_str() == "retiring" && !created.contains(*s)) .count(); let old_count = old.as_ref().map_or(0, |v| v.receipt.members.len()); let mass = retiring > 0 && (retiring >= 10 || retiring.saturating_mul(2) >= old_count); @@ -1051,6 +1884,31 @@ pub fn plan_tx( .map(|(subject, _)| subject.as_str()), Some(current_index(transaction).map_err(internal)?), )?; + // Publication converges every member to this bundle, so it closes all of the set's open + // debt and reports what each forced change becomes. + let mut reverted_debts = Vec::new(); + if !noop { + for record in &open_debts { + let Some(mut debt) = debt_view(record) else { continue }; + let forced = record.body["fields"]["revision"].as_str(); + let adopted = forced.is_some() && live.get(&debt.subject).map(String::as_str) == forced; + debt.outcome = Some(match (forced.is_some(), adopted) { + (true, true) => "adopted", + (true, false) => "reverted", + // A forced hold or suspension still in place is released or resumed. + (false, _) if forced_request_in_place(transaction, record)? => "reverted", + (false, _) => "closed", + }.into()); + if debt.outcome.as_deref() == Some("reverted") { + effects.entry(debt.subject.clone()).or_insert_with(|| format!( + "revert ad hoc change by {}: {}", + debt.actor.as_deref().unwrap_or("unknown"), + debt.reason + )); + } + reverted_debts.push(debt); + } + } let rollouts = input.subjects.values().filter(|desired| crate::rollout::manual(desired)) .filter_map(|desired| { let refusal = desired.member.as_ref() @@ -1073,6 +1931,8 @@ pub fn plan_tx( empty: live.is_empty(), blockers, noop, + policy, + reverted_debts, }; Ok(Plan { intent, @@ -1080,10 +1940,32 @@ pub fn plan_tx( retired, adoptions, bundle_digest, + prior_policy, materialize, }) } +/// The selected and the requested policy each name who may publish the set; an empty list +/// allows anyone. Replicated admission does not re-check this (writers are not signed yet). +pub fn check_publisher(plan: &Plan, actor: Option<&str>) -> Result<(), FractalError> { + for policy in [&plan.prior_policy, &plan.preview.policy].into_iter().flatten() { + if !policy.publisher.is_empty() && !actor.is_some_and(|a| policy.publisher.iter().any(|p| p == a)) { + return Err(FractalError::new( + "source-publisher-refused", + format!( + "{} may be published only by {}, not {}", + plan.preview.set, + policy.publisher.join(", "), + actor.unwrap_or("an anonymous actor"), + ), + ) + .with_detail("set", plan.preview.set.clone()) + .with_detail("publisher", json!(policy.publisher))); + } + } + Ok(()) +} + pub fn validate_apply(plan: &Plan, options: &Options) -> Result<(), FractalError> { if !plan.preview.blockers.is_empty() { return Err( @@ -1183,6 +2065,7 @@ pub fn commit_tx( members, retired, adoptions: plan.adoptions.clone(), + policy: plan.preview.policy.clone(), }; let hash = canonical_hash(&revision).map_err(internal)?; let predecessors = rows(transaction, None)? @@ -1190,7 +2073,7 @@ pub fn commit_tx( .filter(|v| Some(reference(v)) == revision.previous) .map(|v| v.claim) .collect::>(); - append_claim_tx( + let receipt = append_claim_tx( transaction, origin, &plan.preview.set, @@ -1200,7 +2083,9 @@ pub fn commit_tx( &predecessors, Some(batch), ) - .map_err(internal) + .map_err(internal)?; + close_debts_tx(transaction, origin, plan, &format!("{}@{hash}", plan.preview.set), actor, batch)?; + Ok(receipt) } /// Rebuild the existing desired and mission projections through exact set member references. @@ -1300,7 +2185,7 @@ pub fn validate_receipt(subject_name: &str, body: &Value) -> Result<(), FractalE )); } for (s, m) in revision.members.iter().chain(&revision.retired) { - if !matches!(m.kind.as_str(), "agent" | "mission" | "schedule" | "exec" | "pty") + if !matches!(m.kind.as_str(), "agent" | "mission" | "schedule" | "subscription" | "exec" | "pty") || !s.starts_with(&format!("{}/", m.kind)) || m.claim.is_empty() || m.revision.is_empty() @@ -1386,4 +2271,61 @@ impl Store { })) }).map_err(internal)? } + + /// `fractal source status`: every set's policy, open and closed source debt and, with + /// `audit`, the newest audit records. + pub fn source_status(&self, audit: bool) -> Result { + source_status(&self.readers.get(), audit) + } + + /// Refuse a runtime desired-state write (`hold`, `suspend`, `resume`, `retire`) that the + /// subject's owning set refuses, before the caller's other checks. The write itself goes + /// through [`Store::append_source_gated`] or carries [`Store::source_follow_up`]. + pub fn source_gate( + &self, + subject: &str, + verb: Verb, + force_reason: Option<&str>, + ) -> Result<(), FractalError> { + gate(&self.readers.get(), subject, verb, force_reason).map(drop) + } + + /// The follow-up of a gated runtime write to `subject`, run in that write's transaction: the + /// gate is decided again there and records debt for a forced write (naming the write as its + /// request) or an audit record for a write an `enforce` set would refuse. + pub fn source_follow_up<'a>( + &'a self, + subject: &'a str, + verb: Verb, + actor: Option<&'a str>, + force_reason: Option<&'a str>, + ) -> impl Fn(&Transaction<'_>, &ClaimRecord) -> Result<(), FractalError> + Sync + 'a { + move |transaction, record| match gate(transaction, subject, verb, force_reason)? { + Gate::Allow => Ok(()), + Gate::Force(view) => record_debt_tx(transaction, &self.origin, &view, subject, verb, + Forced::Request(&record.id), None, actor, force_reason.unwrap_or_default(), None) + .map(drop), + Gate::Audit(view, refused) => record_audit_tx(transaction, &self.origin, &view, subject, + verb, actor, &refused, None) + .map(drop), + } + } + + /// Append a gated runtime write to `subject` with its [`Store::source_follow_up`] in one + /// transaction. A replay records nothing. + pub fn append_source_gated( + &self, + subject: &str, + input: &ClaimInput, + verb: Verb, + force_reason: Option<&str>, + ) -> Result<(ClaimRecord, bool), FractalError> { + if let Some(actor) = &input.actor { + self.graph.ensure_principal_key(actor)?; + } + self.fractal.validate_usage_limits_writer(&input.kind, input.actor.as_deref())?; + let then = self.source_follow_up(subject, verb, input.actor.as_deref(), force_reason); + append_claim_with_commit_context(&self.graph, input, None, None, None, None, + ClaimCommitContext { then: Some(&then), ..Default::default() }) + } } diff --git a/crates/fractal-store/src/store/publication.rs b/crates/fractal-store/src/store/publication.rs index 2a19fc94e9b..0729c7fd7f1 100644 --- a/crates/fractal-store/src/store/publication.rs +++ b/crates/fractal-store/src/store/publication.rs @@ -9,6 +9,8 @@ pub type Guard = dyn Fn(&DesiredSubject, &Check) -> Result<(), FractalError> + S pub struct CommitChecks<'a> { pub owned: Option<&'a owned_sets::Options>, pub guard: Option<&'a Guard>, + /// `--force --reason`: accept ad hoc changes to owned-set members as source debt. + pub force: Option<&'a str>, } pub struct Check { diff --git a/crates/fractal/src/agents_cli.rs b/crates/fractal/src/agents_cli.rs index 8978fb5646d..fe53ebecd82 100644 --- a/crates/fractal/src/agents_cli.rs +++ b/crates/fractal/src/agents_cli.rs @@ -194,6 +194,7 @@ pub(super) async fn run_agents( .context("setting or releasing a hold needs --reason")?, idempotency_key: format!("delivery-hold:{}", uuid::Uuid::now_v7()), legacy_adoption: false, + force: args.force, }, ) .await?; @@ -331,6 +332,7 @@ pub(super) async fn run_agents( no_gate_check: true, check: false, inputs: vec![], + force_reason: None, }, json_output, ) @@ -406,6 +408,7 @@ pub(super) async fn run_agents( format!("st agents start {}", args.identity), args.actor.clone(), Some((&subject, &tokens)), + args.force.then(|| args.reason.clone()).flatten(), ) .await? }; @@ -495,9 +498,12 @@ pub(super) async fn run_agents( endpoint, "/v1/agents/suspend", &subject, - &args.actor, - args.reason.as_deref(), - None, + SuspensionRequest { + actor: &args.actor, + reason: args.reason.as_deref(), + host: None, + force: args.force, + }, &args.timeout, ) .await?; @@ -523,9 +529,12 @@ pub(super) async fn run_agents( endpoint, "/v1/agents/resume", &subject, - &args.actor, - None, - args.host.as_deref(), + SuspensionRequest { + actor: &args.actor, + reason: args.reason.as_deref(), + host: args.host.as_deref(), + force: args.force, + }, &args.timeout, ) .await?; @@ -720,11 +729,13 @@ pub(super) async fn run_agents( print!("{kdl}"); return Ok(()); } - let response = publish_text( + let response = publish_text_with_expected( &cli_client(endpoint), kdl, format!("st agents stop {subject}"), args.actor, + None, + args.force.then(|| args.reason.clone()).flatten(), ) .await?; print_value(&response, json_output)?; @@ -1588,27 +1599,34 @@ async fn run_agent_queue( Ok(()) } +/// What a suspend or resume request asks for, apart from its subject. +struct SuspensionRequest<'a> { + actor: &'a str, + reason: Option<&'a str>, + host: Option<&'a str>, + force: bool, +} + /// Ask the daemon to suspend or resume `subject`, then wait for the request to finish: the seat /// suspended, or resumed with its harness bound to the suspended native session. async fn request_suspension( endpoint: &Endpoint, route: &str, subject: &str, - actor: &str, - reason: Option<&str>, - host: Option<&str>, + request: SuspensionRequest<'_>, timeout_text: &str, ) -> Result { let timeout = fractal_daemon::graph::parse_duration(timeout_text, false)?; let client = cli_client(endpoint); - let request: ClaimRecord = client + let claim: ClaimRecord = client .post( route, &json!({ "subject": subject, - "actor": actor, - "reason": reason, - "host": host, + "actor": request.actor, + "reason": request.reason, + "host": request.host, + "force": request.force, "idempotency_key": uuid::Uuid::now_v7().to_string(), }), ) @@ -1619,7 +1637,7 @@ async fn request_suspension( loop { if let ClientResource::Agent(agent) = gateway.agents_get(subject).await?.value && let Some(suspension) = agent.suspension.clone() - && suspension.operation_id == request.id + && suspension.operation_id == claim.id { let failure = || { let blocking = if suspension.blocking.is_empty() { diff --git a/crates/fractal/src/cli_help.rs b/crates/fractal/src/cli_help.rs index 0de8ee5acad..be924e9d8cc 100644 --- a/crates/fractal/src/cli_help.rs +++ b/crates/fractal/src/cli_help.rs @@ -46,6 +46,7 @@ const GROUPS: &[(&str, &[&str])] = &[ "apply", "validate", "sets", + "source", "backup", "doctor", "admission", diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 87b0b45098a..f568024711b 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -2262,6 +2262,7 @@ fn legacy_delivery_hold( reason: "Unexpired delivery hold adopted from the previous native driver".into(), idempotency_key: format!("delivery-hold-adoption:{subject}:{until_unix_ms}"), legacy_adoption: true, + force: false, }) } diff --git a/crates/fractal/src/launch_cli.rs b/crates/fractal/src/launch_cli.rs index 6cb363695de..0a620eff1bc 100644 --- a/crates/fractal/src/launch_cli.rs +++ b/crates/fractal/src/launch_cli.rs @@ -804,6 +804,7 @@ async fn retire_mission(client: &Client, args: MissionRetireArgs, json_output: b &MissionRetireRequest { actor: args.actor, idempotency_key: format!("mission-retire:{mission}:{nonce}"), + force_reason: args.force.then_some(args.reason).flatten(), }, ) .await?; diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 99008097da7..1ef3e248e5b 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -317,6 +317,11 @@ enum Command { #[command(subcommand)] command: OwnedSetsCommand, }, + /// Inspect source enforcement: each owned set's policy and open ad hoc source debt. + Source { + #[command(subcommand)] + command: SourceCommand, + }, /// Start the HTTP API, readers, peers, and reconciler. Up(UpArgs), /// Understand what needs action now. @@ -1414,6 +1419,9 @@ struct MissionPublishArgs { /// Mission input values for exec gate checks. #[arg(long = "input", value_parser = parse_input)] inputs: Vec<(String, String)>, + /// Set by `fractal apply --force --reason`; `missions publish` never forces. + #[arg(skip)] + force_reason: Option, } #[derive(Args)] @@ -1603,6 +1611,12 @@ struct MissionRetireArgs { #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] #[arg(long = "as", value_parser = parse_publication_actor)] actor: String, + /// Why the mission is retired ad hoc; needed with --force. + #[arg(long)] + reason: Option, + /// Retire a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "reason")] + force: bool, } #[derive(Args)] @@ -3146,6 +3160,9 @@ struct AgentHoldArgs { release: bool, #[arg(long)] reason: Option, + /// Hold a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "duration")] + force: bool, #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] #[arg(long = "as")] actor: Option, @@ -3388,6 +3405,33 @@ struct ApplyArgs { allow_empty: bool, #[arg(long, requires = "set")] confirm_retire: Option, + /// Replace the set's source policy with this JSON file + /// (`{"enforce","publisher","namespaces","unmanaged_allow"}`; an omitted field keeps the + /// selected policy's). Explicit flags below override its fields. + #[arg(long = "source-policy", requires = "set")] + source_policy: Option, + /// The set's source enforcement: `off` (plain declarations of members are refused, nothing + /// else), `audit` (also record every write `enforce` would refuse) or `enforce` (refuse ad hoc + /// writes to members unless forced as source debt). Omitted keeps the selected policy. + #[arg(long, value_parser = ["off", "audit", "enforce"], requires = "set")] + enforce: Option, + /// The only actor allowed to publish this set. Repeatable; omitted keeps the selected list. + #[arg(long = "publisher", requires = "set")] + publisher: Vec, + /// Claim new top-level subjects matching this glob (`*` any run) for the set; plain apply + /// creating one is refused. Repeatable; omitted keeps the selected policy's namespaces. + #[arg(long = "namespace", requires = "set")] + namespace: Vec, + /// Exempt subjects matching this glob from the set's namespaces. Repeatable. + #[arg(long = "unmanaged-allow", requires = "set")] + unmanaged_allow: Vec, + /// Accept an ad hoc change to an owned-set member and record it as source debt; the next + /// set publication reverts it. Needs --reason. + #[arg(long, requires = "reason", conflicts_with = "set")] + force: bool, + /// Why this ad hoc change is forced; recorded with its actor in `fractal source status`. + #[arg(long, requires = "force", value_parser = clap::builder::NonEmptyStringValueParser::new())] + reason: Option, /// Complete person or agent subject authoring the publication. #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] #[arg(long = "as", env = "ST_AGENT", value_parser = parse_publication_actor)] @@ -3410,6 +3454,17 @@ enum OwnedSetsCommand { }, } +#[derive(Subcommand)] +enum SourceCommand { + /// List each owned set's source policy, open source debt, and the debt each set's current + /// revision closed. + Status { + /// Also list the newest writes `audit` sets recorded instead of refusing. + #[arg(long)] + audit: bool, + }, +} + /// Bundle each versioned file into one intent so references may cross file boundaries. fn read_apply_files(files: &[PathBuf], allow_empty: bool) -> Result { anyhow::ensure!( @@ -3467,6 +3522,7 @@ async fn run_apply(client: &Client, args: ApplyArgs, json_output: bool) -> Resul no_gate_check: args.no_gate_check, check: args.check, inputs: args.inputs, + force_reason: args.force.then_some(args.reason).flatten(), }, json_output, ) @@ -3479,7 +3535,35 @@ async fn run_owned_set_apply( args: ApplyArgs, json_output: bool, ) -> Result<()> { - use fractal_daemon::store::owned_sets::{Options, Preview, Request, Source}; + use fractal_daemon::store::owned_sets::{Enforce, Options, PolicyRequest, Preview, Request, Source}; + let mut policy: Option = args + .source_policy + .as_ref() + .map(|path| -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading source policy {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing source policy {}", path.display())) + }) + .transpose()?; + if args.enforce.is_some() || !args.publisher.is_empty() || !args.namespace.is_empty() || !args.unmanaged_allow.is_empty() { + let request = policy.get_or_insert_with(PolicyRequest::default); + if let Some(enforce) = &args.enforce { + request.enforce = Some(match enforce.as_str() { + "audit" => Enforce::Audit, + "enforce" => Enforce::Enforce, + _ => Enforce::Off, + }); + } + for (flag, field) in [ + (args.publisher, &mut request.publisher), + (args.namespace, &mut request.namespaces), + (args.unmanaged_allow, &mut request.unmanaged_allow), + ] { + if !flag.is_empty() { + *field = Some(flag); + } + } + } let options = Options { set: args.set.context("owned publication needs --set")?, source: Source { @@ -3502,6 +3586,7 @@ async fn run_owned_set_apply( adopt: args.adopt.into_iter().collect(), allow_empty: args.allow_empty, confirm_retire: args.confirm_retire, + policy, expected_subjects: Default::default(), }; let mut request = Request { @@ -3604,6 +3689,12 @@ struct AgentStartArgs { /// Read the daemon's declaration and print the effective agent KDL without publishing it. #[arg(long)] print_kdl: bool, + /// Start a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "reason", conflicts_with = "print_kdl")] + force: bool, + /// Why this ad hoc start is forced; recorded with its actor in `fractal source status`. + #[arg(long, requires = "force", value_parser = clap::builder::NonEmptyStringValueParser::new())] + reason: Option, } #[derive(Args)] @@ -3693,6 +3784,9 @@ struct AgentStopArgs { /// when. #[arg(long)] reason: Option, + /// Stop a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "reason", conflicts_with = "print_kdl")] + force: bool, /// Print the exact stop KDL without publishing it. #[arg(long)] print_kdl: bool, @@ -3722,6 +3816,9 @@ struct AgentSuspendArgs { /// Why the agent is suspended, recorded with the request. #[arg(long)] reason: Option, + /// Suspend a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "reason")] + force: bool, /// How long to wait for the agent to suspend. #[arg(long, default_value = "5m")] timeout: String, @@ -3739,6 +3836,12 @@ struct AgentResumeArgs { #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] #[arg(long = "as", value_parser = parse_publication_actor)] actor: String, + /// Why the agent is resumed; needed with --force. + #[arg(long)] + reason: Option, + /// Resume a member of an `enforce` owned set ad hoc, recording source debt with --reason. + #[arg(long, requires = "reason")] + force: bool, /// How long to wait for the agent to resume its native session. #[arg(long, default_value = "10m")] timeout: String, @@ -6007,6 +6110,12 @@ async fn run(cli: Cli) -> Result<()> { Command::Apply(args) => run_apply(&client, args, cli.json).await, Command::Deploy(args) => deploy_cli::run(&endpoint, args, cli.json).await, Command::Sets { command } => run_owned_sets(&endpoint, command, cli.json).await, + Command::Source { command: SourceCommand::Status { audit } } => { + let status: Value = client + .get(if audit { "/v1/source/status?audit=true" } else { "/v1/source/status" }) + .await?; + print_value(&status, cli.json) + } Command::Ui(_) => unreachable!("the TUI runs before the CLI runtime"), Command::Web { .. } => unreachable!("the browser opens before the CLI runtime"), Command::Demo { .. } => unreachable!("a demo runs before the CLI runtime"), @@ -7485,7 +7594,7 @@ async fn run_up(args: UpArgs) -> Result<()> { fields: BTreeMap::from([ ( "features".into(), - serde_json::json!({"owned_sets":1,"owned_set_tasks":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1,"mission_authority":1, + serde_json::json!({"owned_sets":1,"owned_set_tasks":1,"source_enforcement":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1,"mission_authority":1, "harness_access_auto": u8::from(config.active_access_mode() == fractal_drivers::access::AccessMode::Auto), "messages_stream": u8::from(messages_shard.is_ok()), // Counts its stream mail in its idle, unread, held and usage indexes. It @@ -8070,6 +8179,7 @@ async fn publish_mission_intent( intent: resolved, expected_subjects: mission.subject_tokens, actor: Some(args.actor), + force_reason: args.force_reason, }, ) .await?; @@ -8664,7 +8774,7 @@ async fn publish_text( source_name: String, actor: String, ) -> Result { - publish_text_with_expected(client, kdl, source_name, actor, None).await + publish_text_with_expected(client, kdl, source_name, actor, None, None).await } async fn publish_text_with_expected( @@ -8673,6 +8783,7 @@ async fn publish_text_with_expected( source_name: String, actor: String, expected: Option<(&str, &[String])>, + force_reason: Option, ) -> Result { let intent = IntentInput { kdl, @@ -8708,6 +8819,7 @@ async fn publish_text_with_expected( intent: resolved, expected_subjects: mission.subject_tokens, actor: Some(actor.clone()), + force_reason, }, ) .await diff --git a/crates/fractal/src/main_tests.rs b/crates/fractal/src/main_tests.rs index c7df80ec078..425bb7cf154 100644 --- a/crates/fractal/src/main_tests.rs +++ b/crates/fractal/src/main_tests.rs @@ -7882,6 +7882,7 @@ async fn graph_delivery_gate_closes_on_hold_outage_and_mismatched_subject() { reason: "adopted hold".into(), idempotency_key: "adopt-test".into(), legacy_adoption: true, + force: false, }; let deadline = request.until_unix_ms; let mut paths = NativePaths { @@ -8653,6 +8654,13 @@ async fn publish_test_mission(client: &Client, root: &Path, goal: &str) { adopt: vec![], allow_empty: false, confirm_retire: None, + source_policy: None, + enforce: None, + publisher: vec![], + namespace: vec![], + unmanaged_allow: vec![], + force: false, + reason: None, dry_run: false, at_index: None, actor: "person/test".into(), diff --git a/crates/fractal/src/onboarding.rs b/crates/fractal/src/onboarding.rs index a8ffb590a66..f6834331db0 100644 --- a/crates/fractal/src/onboarding.rs +++ b/crates/fractal/src/onboarding.rs @@ -425,6 +425,7 @@ async fn apply( expected_subjects: preview.subject_tokens, idempotency_key: hex::encode(hash.finalize()), actor: config.person.clone(), + force_reason: None, }, ) .await diff --git a/crates/fractal/tests/action_coverage.rs b/crates/fractal/tests/action_coverage.rs index 109365a1bc0..3acb1ac5408 100644 --- a/crates/fractal/tests/action_coverage.rs +++ b/crates/fractal/tests/action_coverage.rs @@ -431,6 +431,7 @@ async fn cli_owned_seat_rollout_captures_fences_and_survives_restart() { adopt: Default::default(), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: Default::default(), }; let mut args = vec![ diff --git a/crates/fractal/tests/agents_restart.rs b/crates/fractal/tests/agents_restart.rs index 04ede22d334..b92aa0a1c23 100644 --- a/crates/fractal/tests/agents_restart.rs +++ b/crates/fractal/tests/agents_restart.rs @@ -257,7 +257,7 @@ impl Fixture { source: Source { repository: "acme/example".into(), r#ref: "refs/heads/main".into(), sha: "a".repeat(40), sequence: 1 }, expected_set: "absent".into(), rollout: None, adopt: std::collections::BTreeSet::from(["agent/example/worker".into()]), - allow_empty: false, confirm_retire: None, expected_subjects: Default::default(), + allow_empty: false, confirm_retire: None, policy: None, expected_subjects: Default::default(), }; options.expected_subjects = store.owned_set_preview(&intent, &options).unwrap().expected_subjects; store.apply_owned_set(&intent, &options, "own-fixture", "person/avery").unwrap(); @@ -944,6 +944,7 @@ async fn one_shot_exit_retires_an_owned_set_member_without_changing_its_source() adopt: std::collections::BTreeSet::from([subject.clone()]), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: Default::default(), }; options.expected_subjects = fixture diff --git a/crates/fractal/tests/daemon_cost.rs b/crates/fractal/tests/daemon_cost.rs index 632ff79d52b..2416071426d 100644 --- a/crates/fractal/tests/daemon_cost.rs +++ b/crates/fractal/tests/daemon_cost.rs @@ -735,6 +735,7 @@ const PROBES: &[Probe] = &[ post("POST /v1/sets/apply", "/v1/sets/apply", |_, attempt| { owned_set_request(&format!("apply-{attempt}")) }), + get("GET /v1/source/status", "/v1/source/status"), // Posting and registration also require GitHub; count their local writes with invented // object IDs. The comment probe includes the default watch declaration. direct("POST /v1/github/comment", |store, fixture, attempt| { diff --git a/crates/fractal/tests/owned_sets.rs b/crates/fractal/tests/owned_sets.rs index 3236e71c75b..80f15259c39 100644 --- a/crates/fractal/tests/owned_sets.rs +++ b/crates/fractal/tests/owned_sets.rs @@ -1,6 +1,7 @@ //! Each fixture serves an isolated daemon over its own Unix socket and durable graph. use fractal_daemon::SeatsStoreCore; use fractal_daemon::OwnedSetsStore; +use fractal_daemon::RetirementsStore; use std::{path::Path, sync::Arc, time::Duration}; use serde_json::{Value, json}; @@ -11,7 +12,7 @@ use fractal_daemon::{ model::{ClaimInput, IntentInput}, store::{ Store, - owned_sets::{Options, Preview, Request, Source}, + owned_sets::{Enforce, Options, PolicyRequest, Preview, Request, Source}, }, }; use tokio::sync::{Notify, watch}; @@ -143,6 +144,7 @@ async fn request(daemon: &Daemon, sequence: u64, kdl: String) -> Request { adopt: Default::default(), allow_empty: false, confirm_retire: None, + policy: None, expected_subjects: Default::default(), }, actor: "person/operator".into(), @@ -1802,3 +1804,387 @@ async fn agent_tasks_publish_with_their_agent_and_revision_tokens_pin_the_same_b .unwrap_err(); assert!(error.to_string().contains("unresolved-revision-token"), "{error}"); } + +const HARVEST: &str = "mission \"garden/harvest\" state=\"ready\" { goal \"Harvest\"; step \"work\" { assigned-to \"agent/garden/orchard\" } }\n"; +const ROSE: &str = "agent \"garden/rose\" {\n workspace \".\"\n harness \"codex\" {}\n}\n"; +const LILY: &str = "agent \"garden/lily\" {\n workspace \".\"\n command \"true\"\n}\n"; + +/// The orchard and rose agents and the harvest mission, plus `extra` declarations. +fn enforced_bundle(extra: &str) -> String { + format!("{}{ROSE}{HARVEST}{extra}", bundle("source", false)) +} + +fn enforce(mode: Enforce) -> PolicyRequest { + PolicyRequest { enforce: Some(mode), ..Default::default() } +} + +async fn publish_set(d: &Daemon, sequence: u64, kdl: String, policy: Option) -> (Preview, Value) { + let mut publication = request(d, sequence, kdl).await; + publication.options.policy = policy; + let preview = preview(d, &mut publication).await; + let result: Value = d.client.post("/v1/sets/apply", &publication).await.unwrap(); + assert_eq!(result["publication"]["changed"], true, "{result}"); + (preview, result) +} + +/// A plain `fractal apply`, optionally `--force --reason`. +async fn plain(d: &Daemon, kdl: &str, key: &str, force: Option<&str>) -> anyhow::Result { + let planned: Value = d + .client + .post("/v1/intent/mission", &json!({"intent": {"kdl": kdl}, "at_index": null})) + .await?; + d.client + .post("/v1/intent/apply", &json!({"intent": {"kdl": kdl}, "expected_subjects": planned["subject_tokens"], + "idempotency_key": key, "actor": "person/operator", "force_reason": force})) + .await +} + +fn until() -> u64 { + u64::try_from(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()).unwrap() + + 900_000 +} + +async fn hold(d: &Daemon, key: &str, force: bool) -> anyhow::Result { + d.client + .post("/v1/delivery/hold", &json!({"subject": "agent/garden/rose", "actor": "person/operator", + "held": true, "until_unix_ms": until(), "reason": "quiet during the demo", + "idempotency_key": key, "force": force})) + .await +} + +async fn hold_active(d: &Daemon) -> bool { + let held: Value = d.client.get("/v1/delivery/hold?subject=agent%2Fgarden%2Frose").await.unwrap(); + held["active"] == true +} + +async fn source_status(d: &Daemon, audit: bool) -> Value { + d.client + .get(if audit { "/v1/source/status?audit=true" } else { "/v1/source/status" }) + .await + .unwrap() +} + +fn assert_contains(text: &str, expected: &[&str]) { + for expected in expected { + assert!(text.contains(expected), "{expected:?} missing from: {text}"); + } +} + +/// An `enforce` set refuses each ad hoc desired-state write to a member, naming the verb, the +/// set, its source and the escape. A set without a policy keeps today's refusal, with no force. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_enforced_set_refuses_ad_hoc_writes_naming_each_verb() { + let root = tempfile::tempdir().unwrap(); + let key = Arc::new(MemberKey::generate().unwrap().0); + let d = daemon(root.path(), "amber", key.clone(), &key).await; + publish_set(&d, 10, enforced_bundle(""), Some(enforce(Enforce::Enforce))).await; + let source = [ + "set-managed-subject", "owned-set/garden from acme/garden refs/heads/main", + "enforce enforce", "--force --reason TEXT", + ]; + let refused = plain(&d, &bundle("ad hoc", false), "apply", None).await.unwrap_err().to_string(); + assert_contains(&refused, &source); + assert_contains(&refused, &["ad hoc apply is refused"]); + let refused = plain(&d, "version 2\nstop \"agent/garden/orchard\"\n", "stop", None).await.unwrap_err().to_string(); + assert_contains(&refused, &source); + assert_contains(&refused, &["ad hoc stop is refused"]); + let refused = hold(&d, "hold", false).await.unwrap_err().to_string(); + assert_contains(&refused, &source); + assert_contains(&refused, &["ad hoc hold is refused"]); + assert!(!hold_active(&d).await); + let refused = d + .client + .post::<_, Value>("/v1/missions/garden%2Fharvest/retire", + &json!({"actor": "person/operator", "idempotency_key": "retire-harvest"})) + .await + .unwrap_err() + .to_string(); + assert_contains(&refused, &source); + assert_contains(&refused, &["ad hoc mission retire is refused"]); + let refused = plain(&d, &bundle("ad hoc", false), "blank", Some(" ")).await.unwrap_err().to_string(); + assert_contains(&refused, &["source-force-refused", "nonempty --reason"]); + assert!(source_status(&d, false).await["open"].as_array().unwrap().is_empty()); + + // `off` is today's behaviour: the refusal stands and there is no force path. + publish_set(&d, 11, enforced_bundle(""), Some(enforce(Enforce::Off))).await; + assert!(d.store.owned_sets().unwrap()[0].receipt.policy.is_none()); + let refused = plain(&d, &bundle("ad hoc", false), "off", None).await.unwrap_err().to_string(); + assert_contains(&refused, &["set-managed-subject", "publish its declaration through that set"]); + let refused = plain(&d, &bundle("ad hoc", false), "off-force", Some("hotfix")).await.unwrap_err().to_string(); + assert_contains(&refused, &["source-force-refused", "no force path"]); + hold(&d, "off-hold", false).await.unwrap(); + assert!(hold_active(&d).await); +} + +/// Forced writes take effect and record debt; the next publication converges every member, +/// reports each debt as reverted, adopted or closed, writes `source.debt.closed` for it in the +/// same transaction, and releases a forced hold there too. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forced_writes_record_debt_until_the_next_publication_closes_it() { + let root = tempfile::tempdir().unwrap(); + let key = Arc::new(MemberKey::generate().unwrap().0); + let d = daemon(root.path(), "amber", key.clone(), &key).await; + let policy = PolicyRequest { + enforce: Some(Enforce::Enforce), + namespaces: Some(vec!["agent/garden/*".into(), "mission/garden/*".into()]), + unmanaged_allow: Some(vec!["agent/garden/scratch-*".into()]), + ..Default::default() + }; + publish_set(&d, 10, enforced_bundle(""), Some(policy)).await; + let set = d.store.owned_sets().unwrap().remove(0); + let orchard = "agent/garden/orchard"; + let source_revision = d.store.mission_spec("garden/harvest", None).unwrap().unwrap().revision; + + let forced = plain(&d, &bundle("ad hoc", false), "orchard", Some("hotfix while CI is down")).await.unwrap(); + let recorded = &forced["source_debts_recorded"][0]; + assert_eq!((recorded["subject"].as_str(), recorded["verb"].as_str()), (Some(orchard), Some("apply"))); + assert_eq!(recorded["actor"], "person/operator"); + assert_eq!(recorded["reason"], "hotfix while CI is down"); + assert_eq!(recorded["sha"], format!("{:040x}", 10)); + let forced_token = d.store.selected_desired_token(orchard).unwrap().unwrap(); + assert_ne!(forced_token, set.receipt.members[orchard].claim); + assert_eq!(recorded["claim"], forced_token); + + let mission = format!("version 2\n{}", HARVEST.replace("goal \"Harvest\"", "goal \"Harvest early\"")); + let forced = plain(&d, &mission, "harvest", Some("frost tonight")).await.unwrap(); + assert_eq!(forced["source_debts_recorded"][0]["verb"], "mission publish"); + let forced_revision = d.store.mission_spec("garden/harvest", None).unwrap().unwrap().revision; + assert_ne!(forced_revision, source_revision); + + hold(&d, "rose", true).await.unwrap(); + assert!(hold_active(&d).await); + + let refused = plain(&d, &format!("version 2\n{LILY}"), "lily", None).await.unwrap_err().to_string(); + assert_contains(&refused, &["set-managed-subject", "namespace claimed by owned-set/garden"]); + plain(&d, &format!("version 2\n{LILY}"), "lily-forced", Some("trying a lily")).await.unwrap(); + plain(&d, &format!("version 2\n{}", LILY.replace("lily", "tulip")), "tulip", Some("trying a tulip")).await.unwrap(); + let plant = format!("version 2\n{}", HARVEST.replace("garden/harvest", "garden/plant")); + let forced = plain(&d, &plant, "plant", Some("trying a planting")).await.unwrap(); + assert_eq!(forced["source_debts_recorded"][0]["verb"], "create"); + plain(&d, &format!("version 2\n{}", LILY.replace("lily", "scratch-1")), "scratch", None).await.unwrap(); + let status = source_status(&d, false).await; + assert_eq!(status["open"].as_array().unwrap().len(), 6, "{status}"); + assert_eq!(status["sets"][0]["enforce"], "enforce"); + + // The source now declares the lily exactly as forced, and still omits the tulip. + let (preview, published) = publish_set(&d, 11, enforced_bundle(LILY), None).await; + assert!(preview.effects[orchard].starts_with("revert ad hoc change by person/operator: hotfix"), "{:?}", preview.effects); + assert_eq!(preview.changes["agent/garden/tulip"], "retiring"); + assert_eq!(preview.changes["mission/garden/plant"], "retiring"); + let outcomes: std::collections::BTreeMap = published["publication"]["source_debts_reverted"] + .as_array() + .unwrap() + .iter() + .map(|d| (d["subject"].as_str().unwrap().to_owned(), + (d["verb"].as_str().unwrap().to_owned(), d["outcome"].as_str().unwrap().to_owned()))) + .collect(); + let expected = [ + (orchard, "apply", "reverted"), + ("mission/garden/harvest", "mission publish", "reverted"), + ("agent/garden/rose", "hold", "reverted"), + ("agent/garden/lily", "create", "adopted"), + ("agent/garden/tulip", "create", "reverted"), + ("mission/garden/plant", "create", "reverted"), + ]; + assert_eq!(outcomes.len(), expected.len(), "{outcomes:?}"); + for (subject, verb, outcome) in expected { + assert_eq!(outcomes[subject], (verb.to_owned(), outcome.to_owned()), "{subject}"); + let closed = d.store.latest_claim(subject, Some("source.debt.closed")).unwrap().unwrap(); + assert_eq!(closed.body["fields"]["outcome"], outcome); + assert_eq!(closed.body["fields"]["publisher"], "person/operator"); + } + // Converged: the set's declarations hold again and the forced hold is released. + let set = d.store.owned_sets().unwrap().remove(0); + assert_eq!(d.store.selected_desired_token(orchard).unwrap().unwrap(), set.receipt.members[orchard].claim); + assert_eq!(d.store.mission_spec("garden/harvest", None).unwrap().unwrap().revision, source_revision); + assert!(set.receipt.members.contains_key("agent/garden/lily")); + assert!(set.receipt.retired.contains_key("agent/garden/tulip")); + assert!(set.receipt.retired.contains_key("mission/garden/plant")); + assert_eq!( + d.store.mission_spec("garden/plant", None).unwrap().unwrap().state, + fractal_daemon::model::MissionState::Retired + ); + assert!(!hold_active(&d).await); + let status = source_status(&d, false).await; + assert!(status["open"].as_array().unwrap().is_empty(), "{status}"); + assert_eq!(status["closed"].as_array().unwrap().len(), 6, "{status}"); + assert_eq!(status["closed"][0]["closed_by"]["set_revision"], format!("owned-set/garden@{}", set.revision)); +} + +/// Terminal retirement of a live member cannot be forced; through client-v0 it is refused as +/// `set-managed-subject`, not `internal`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_retire_of_a_live_member_is_refused_through_client_v0() { + let root = tempfile::tempdir().unwrap(); + let key = Arc::new(MemberKey::generate().unwrap().0); + let d = daemon(root.path(), "amber", key.clone(), &key).await; + publish_set(&d, 10, enforced_bundle(""), Some(enforce(Enforce::Enforce))).await; + let orchard = "agent/garden/orchard"; + let client = fractal_client::Client::unix_as(root.path().join("amber").join("st3.sock"), "person/operator"); + let snapshot = client.capabilities().await.unwrap().snapshot.id; + let error = client + .agent_retire( + &format!("action/retire-{}", "a".repeat(64)), + "retire-orchard-terminally", + fractal_client::Fence { + snapshot_id: snapshot, + runtime_desired_revision: Some(d.store.selected_desired_token(orchard).unwrap().unwrap()), + ..Default::default() + }, + fractal_client::AgentRetireParameters { + agent: orchard.into(), + reason: "done with it".into(), + successor: None, + expect_revision: None, + }, + ) + .await + .unwrap_err(); + let text = format!("{error:?} {error}"); + assert_contains(&text, &["SetManagedSubject", "retirement is terminal and cannot be forced", + "omit agent/garden/orchard from the set publication"]); + assert!(!text.contains("Internal"), "{text}"); + assert!(d.store.agent_retirement(orchard).unwrap().is_none()); +} + +/// An `audit` set refuses nothing beyond `off`: each write `enforce` would refuse proceeds and +/// records `source.audited` with that refusal. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_audit_set_records_what_enforce_would_refuse() { + let root = tempfile::tempdir().unwrap(); + let key = Arc::new(MemberKey::generate().unwrap().0); + let d = daemon(root.path(), "amber", key.clone(), &key).await; + let policy = PolicyRequest { + enforce: Some(Enforce::Audit), + namespaces: Some(vec!["agent/garden/*".into()]), + ..Default::default() + }; + publish_set(&d, 10, enforced_bundle(""), Some(policy)).await; + hold(&d, "audited-hold", false).await.unwrap(); + assert!(hold_active(&d).await); + plain(&d, &format!("version 2\n{LILY}"), "audited-lily", None).await.unwrap(); + let orchard = "agent/garden/orchard"; + // A declaration cannot take effect over the set's member reference, so it is still refused. + let refused = plain(&d, &bundle("ad hoc", false), "audited-apply", None).await.unwrap_err().to_string(); + assert_contains(&refused, &["set-managed-subject", "ad hoc apply is refused"]); + let token = d.store.selected_desired_token(orchard).unwrap().unwrap(); + d.store.retire_agent(orchard, "person/operator", "done", &token, None, "audited-retire", &[]).unwrap(); + let status = source_status(&d, true).await; + assert!(status["open"].as_array().unwrap().is_empty(), "{status}"); + let audited: Vec<(String, String)> = status["audited"] + .as_array() + .unwrap() + .iter() + .map(|a| (a["subject"].as_str().unwrap().to_owned(), a["verb"].as_str().unwrap().to_owned())) + .collect(); + assert_eq!(audited, [ + (orchard.to_owned(), "retire".to_owned()), + ("agent/garden/lily".to_owned(), "create".to_owned()), + ("agent/garden/rose".to_owned(), "hold".to_owned()), + ], "{status}"); + assert_eq!(status["audited"][0]["actor"], "person/operator"); + assert_contains(status["audited"][0]["refusal"].as_str().unwrap(), &["retirement is terminal"]); + assert!(source_status(&d, false).await.get("audited").is_none()); +} + +/// A policy needs every active member to advertise `source_enforcement`; a set naming its +/// publishers refuses anyone else; debt and its closure replicate and both replicas converge. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn source_policy_is_gated_fenced_to_its_publisher_and_debt_replicates() { + let root = tempfile::tempdir().unwrap(); + let keys = (0..2).map(|_| Arc::new(MemberKey::generate().unwrap().0)).collect::>(); + let amber = daemon(root.path(), "amber", keys[0].clone(), &keys[0]).await; + let ivory = daemon(root.path(), "ivory", keys[1].clone(), &keys[0]).await; + for (i, name) in ["amber", "ivory"].into_iter().enumerate() { + let mut fields = json!({"fleet_id":FLEET,"member_key":keys[i].public(), + "via":if i==0{"anchor"}else{"invite"},"mode":"listening"}); + if i != 0 { + fields["sponsor"] = json!("host/amber"); + } + append(&amber.store, &format!("host/{name}"), "fleet.member-admitted", fields); + } + let exchange = |amber: &Daemon, ivory: &Daemon| { + share(&amber.store, &ivory.store, "amber"); + share(&ivory.store, &amber.store, "ivory"); + }; + exchange(&amber, &ivory); + let policy = PolicyRequest { + enforce: Some(Enforce::Enforce), + publisher: Some(vec!["person/operator".into()]), + ..Default::default() + }; + let mut publication = request(&amber, 10, enforced_bundle("")).await; + publication.options.policy = Some(policy); + let blocked: Preview = amber.client.post("/v1/sets/preview", &publication).await.unwrap(); + assert!( + blocked.blockers.iter().any(|b| b == "host/ivory has not advertised source enforcement support; upgrade before activation"), + "{:?}", blocked.blockers + ); + for (daemon, name) in [(&amber, "amber"), (&ivory, "ivory")] { + append(&daemon.store, &format!("daemon/{name}"), "daemon.started", + json!({"status":"running","features":{"owned_sets":1,"source_enforcement":1}})); + } + exchange(&amber, &ivory); + preview(&amber, &mut publication).await; + let _: Value = amber.client.post("/v1/sets/apply", &publication).await.unwrap(); + exchange(&amber, &ivory); + + // Only the named publisher may publish the set. + let mut stranger = request(&amber, 11, enforced_bundle(LILY)).await; + stranger.actor = "person/intruder".into(); + preview(&amber, &mut stranger).await; + let refused = amber.client.post::<_, Value>("/v1/sets/apply", &stranger).await.unwrap_err().to_string(); + assert_contains(&refused, &["source-publisher-refused", "person/operator"]); + + let orchard = "agent/garden/orchard"; + let forced = plain(&amber, &bundle("ad hoc", false), "orchard", Some("hotfix")).await.unwrap(); + exchange(&amber, &ivory); + let forced_token = forced["source_debts_recorded"][0]["claim"].as_str().unwrap().to_owned(); + for daemon in [&amber, &ivory] { + assert_eq!(daemon.store.selected_desired_token(orchard).unwrap().unwrap(), forced_token); + assert_eq!(source_status(daemon, false).await["open"].as_array().unwrap().len(), 1); + } + publish_set(&amber, 12, enforced_bundle(""), None).await; + exchange(&amber, &ivory); + for daemon in [&amber, &ivory] { + let set = daemon.store.owned_sets().unwrap().remove(0); + assert!(set.blockers.is_empty(), "{:?}", set.blockers); + assert_eq!(set.receipt.policy.as_ref().unwrap().publisher, ["person/operator"]); + assert_eq!(daemon.store.selected_desired_token(orchard).unwrap().unwrap(), set.receipt.members[orchard].claim); + let status = source_status(daemon, false).await; + assert!(status["open"].as_array().unwrap().is_empty(), "{status}"); + assert_eq!(status["closed"][0]["outcome"], "reverted", "{status}"); + } +} + +/// A top-level subscription is a set member: omitting it retires it through its stopped form. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_omitted_subscription_member_is_retired_by_its_stopped_form() { + let root = tempfile::tempdir().unwrap(); + let key = Arc::new(MemberKey::generate().unwrap().0); + let d = daemon(root.path(), "amber", key.clone(), &key).await; + // A repository's standing observer needs no declaration of its own. + let subscription = "subscription \"garden/alerts\" { observer \"observer/github/acme/garden\"; to \"agent/garden/orchard\"; on \"issues\"; delivery \"message\" { every \"30m\" } }\n"; + let (planned, _) = publish_set(&d, 10, format!("{}{subscription}", bundle("source", false)), None).await; + assert_eq!(planned.changes["subscription/garden/alerts"], "added"); + let set = d.store.owned_sets().unwrap().remove(0); + assert_eq!(set.receipt.members["subscription/garden/alerts"].kind, "subscription"); + // Outside a set, plain apply has no top-level subscription at all: the ad hoc path is refused + // before ownership is consulted, so the set stays the subscription's only writer. + let refused = plain(&d, &format!("version 2\n{}", subscription.replace("30m", "1h")), "sub", None) + .await.unwrap_err().to_string(); + assert_contains(&refused, &["runtime-outside-mission"]); + // Omitting a member retires it, which needs confirmation of this exact preview. + let mut omit = request(&d, 11, bundle("source", false)).await; + let planned = preview(&d, &mut omit).await; + omit.options.confirm_retire = Some(planned.digest.clone()); + d.client.post::<_, Value>("/v1/sets/apply", &omit).await.unwrap(); + assert_eq!(planned.changes["subscription/garden/alerts"], "retiring"); + assert_eq!(planned.effects["subscription/garden/alerts"], "stop deliveries; retain delivered messages"); + let set = d.store.owned_sets().unwrap().remove(0); + assert!(set.blockers.is_empty(), "{:?}", set.blockers); + assert!(set.receipt.retired.contains_key("subscription/garden/alerts")); + let stopped = d.store.desired_subject_with_writer("subscription/garden/alerts").unwrap().unwrap().0; + assert_eq!(stopped.kind, "subscription"); + assert!(fractal_daemon::graph::subscription_spec(&stopped.desired).unwrap().stopped); +} diff --git a/docs/st3/action-coverage.json b/docs/st3/action-coverage.json index 85831ac80ce..ba80ff771b2 100644 --- a/docs/st3/action-coverage.json +++ b/docs/st3/action-coverage.json @@ -2365,6 +2365,14 @@ ], "verification": "real CLI over private Unix socket; disk store reopened between actions" }, + { + "command": "st source status", + "description": "Show each set's source policy, open and closed source debt and, with --audit, audit records", + "tests": [ + "crates/fractal/tests/owned_sets.rs::forced_writes_record_debt_until_the_next_publication_closes_it" + ], + "verification": "the command's daemon route over isolated disk-backed Unix daemons; forced debt, convergence and closure" + }, { "command": "st store prepare-indexes", "description": "Build and verify mandatory owner indexes offline; stop all store writers first", diff --git a/docs/st3/actor-contract.json b/docs/st3/actor-contract.json index 57e294713bc..3f1d945ea0a 100644 --- a/docs/st3/actor-contract.json +++ b/docs/st3/actor-contract.json @@ -2217,6 +2217,15 @@ "actor_environment": null, "ignores_actor": false }, + "st source status": { + "actor": "none", + "accepts_person": false, + "can_carry_person": false, + "as_role": "none", + "alternate_actor_option": null, + "actor_environment": null, + "ignores_actor": false + }, "st store prepare-indexes": { "actor": "none", "accepts_person": false, diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 3daf13f9002..0b1eace8558 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -598,6 +598,7 @@ "retire-incarnation-mismatch", "invalid-custody-successor", "custody-pending", + "set-managed-subject", "attention-migrated", "arrangement-exists", "arrangement-folder-exists", diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 2ebe3338385..f1660101ed8 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -804,7 +804,7 @@ ], "flows": [ {"id": "flow-h0f24faa60a9f", "path": "crates/fractal-store/src/backup.rs", "function": "restore", "owner": "fractal_store::backup", "fingerprint": "367616d58ec64e13d447f49aa64db2731708beadc4a192eae7860c648e752808", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Offline backup restore is a historical producer/open path; write-preserving recovery and opener quiescence require a separate plan."}, - {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "0f90aec8e253911fe3860d326e69241918efbd9428a4594c48edae4a4f20582d", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, + {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "dfe70e54024cc4c673e9d09ab9892e7ae56b8ce44dd222c543465d4a29f3ebc4", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, {"id": "flow-hae49bfcdc951", "path": "crates/fractal-store/src/store.rs", "function": "insert_event", "owner": "fractal_store::store", "fingerprint": "9cdeca113f2b75bcf56424758796dc68d0158e63ba3c7520de898551700bbf4e", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "description": "Event positions/publication must retain canonical pointer/body and ordering semantics."}, {"id": "flow-hcc1764594fd2", "path": "crates/fractal-store/src/store/backup.rs", "function": "receive_backup_page", "owner": "fractal_store::store::backup", "fingerprint": "35b1ec2cf1835ef24f9f29107f60813ca41eb4197e8a2be9a98b5771cbccaed7", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Backup import permits valid historical records under its own record/page limits, not client extractor limits."}, {"id": "flow-he7b2f39a2da2", "path": "crates/smallclaims/src/store.rs", "function": "append_claim_record_tx", "owner": "smallclaims::store", "fingerprint": "fd48ee537bf54b5b208b4e3da218640c9239c63fd3b76ab3e836cf6ef81b97b8", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Local append serializes the logical body and predecessor identities; no route-limit inference."}, @@ -1223,6 +1223,7 @@ {"id": "lv-h196978d00369", "path": "crates/fractal-api-core/src/api/agents_request_slo/ledger.rs", "kind": "sql-template", "fingerprint": "9b217f7f4e63535d2369ed3261319b4fd18f4ae4734576595a18db7a45f88434", "owner": "fractal_api_core::api::agents_request_slo::ledger", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 77, "excerpt": "DELETE FROM minute_receipts WHERE slot < ?1"}, {"id": "lv-h1975c08b9306", "path": "crates/fractal-store/src/store/declared_admission.rs", "kind": "unresolved-sql-sink", "fingerprint": "f4a5d72b64cd4bddb555a9f2907a03b6dd3e5b648502a4dbd55c8efd52a8c274", "owner": "fractal_store::store::declared_admission", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 239, "excerpt": "& canonical_sql ( \"SELECT NOT EXISTS(SELECT 1 FROM json_each(?5) k WHERE json_extract(claims.body,'$.fields.features.'||k.value) IS NOT 1) AND EXISTS(SELECT 1 FROM replica_envelopes e JOIN replica_env"}, {"id": "lv-h197e92111ab4", "path": "crates/fractal-missions/src/store/terminal_step_guard_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "160e804026917a32ca102da71a10bda54195713cc6916ddac581bee13ee81333", "owner": "fractal_missions::store::terminal_step_guard_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 54, "excerpt": "[ subject ] , | row | { ( 0 . . columns ) . map ( | column | row . get ( column ) ) . collect : : < rusqlite : : Result < Vec < _ > > > ( ) }"}, + {"id": "lv-h19840d31732b", "path": "crates/fractal-store/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "af992ad629b26a8f5ec07ce5bd5f976a76e2f69f3debed45caf2a283178aea51", "owner": "fractal_store::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1136, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE subject=?1 AND kind='runtime.action.requested' AND actor IS NOT NULL AND json_extract(body,'$.fields.action') IN ('suspend','resume') ORDER BY CANONICAL_"}, {"id": "lv-h198436c4efad", "path": "crates/fractal-missions/src/store/work_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "b6dcca8910b4e531384e6493d66664d731f5dd979cda086c8ace119dd8ebe5ca", "owner": "fractal_missions::store::work_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 128, "excerpt": "[ generation ] , | row | row . get : : < _ , String > ( 0 )"}, {"id": "lv-h198683860b03", "path": "crates/fractal-store/src/store/directive_notes.rs", "kind": "sql-template", "fingerprint": "f6751074c1768fb74b28fd07ccd6c506a3da3bf5ac43801b77061879b1181d86", "owner": "fractal_store::store::directive_notes", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 189, "excerpt": "DELETE FROM local_directive_note_dirty; DELETE FROM local_directive_note_pending;"}, {"id": "lv-h19aa951d908a", "path": "crates/fractal-delivery/src/store/terminal_gate_evidence/native_source/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4149c07b83691fb2878060055b32e2a1c5a6263a444d445cd676b72a6d09c50a", "owner": "fractal_delivery::store::terminal_gate_evidence::native_source::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 356, "excerpt": "\"ROLLBACK TO native_rollback;RELEASE native_rollback\""}, @@ -3009,6 +3010,7 @@ {"id": "lv-h809cfe2eadf6", "path": "crates/smallclaims/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "e9540ebdd2df8148d53b8d6a55d398d2a9b1209d5adeef65158558072e293741", "owner": "smallclaims::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6843, "excerpt": "& canonical_sql ( \"SELECT documents.hash FROM documents WHERE documents.name=?1 AND documents.created_index<=?2 ORDER BY binding_key DESC LIMIT 1\" ) , params ! [ reference , selected ] , | row | row ."}, {"id": "lv-h80ad1593edbf", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "28b4ca6b53d3ec70e999785ef5f3cad8839915d4d0183c1392fce01351b0ad94", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6268, "excerpt": "UPDATE mission_runs SET updated_at_unix_ms='0' WHERE id=?1"}, {"id": "lv-h80ae4507da68", "path": "crates/fractal-store/src/store/completion_state.rs", "kind": "sql-template", "fingerprint": "ecd21b220ecdd03059f5e3a0181b8105754d31efd8120301c4c5372c393c121d", "owner": "fractal_store::store::completion_state", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 220, "excerpt": "DELETE FROM local_completion_health_v1_pending WHERE claim_id=?1"}, + {"id": "lv-h80b258f37688", "path": "crates/fractal-store/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "b50fd6c07651e66fc40b442771d9c1e24090756593a703a752ac0591cb5c4e0d", "owner": "fractal_store::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1168, "excerpt": "& canonical_sql ( \"SELECT debt.id,debt.store_index,debt.batch_id,debt.subject,debt.kind,debt.origin,debt.actor,debt.body, debt.predecessors,debt.accepted_at_unix_ms, json_extract(closed.body,'$.fields"}, {"id": "lv-h80c949c995f9", "path": "crates/smallclaims/src/sqlite/writer_queue/tests.rs", "kind": "sql-template", "fingerprint": "be1a79b2b53ab6704f858727b653dba8af64ab748c34c3196ff0993c16ae40f0", "owner": "smallclaims::sqlite::writer_queue::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 120, "excerpt": "INSERT INTO values_ VALUES(2)"}, {"id": "lv-h80c9cf262e7f", "path": "crates/fractal-store/src/store/missions.rs", "kind": "sql-template", "fingerprint": "6e70ff62ff921e72f3db8f07baeec1df8f6152572d79972d4feeb25e6486b5af", "owner": "fractal_store::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 484, "excerpt": "INSERT INTO step_runs(subject, run_id, generation_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, created_at_unix_ms, updated_at_unix_ms, constraints)"}, {"id": "lv-h80d0240fd28e", "path": "crates/fractal-store/src/store/message_marks.rs", "kind": "unresolved-sql-sink", "fingerprint": "a6a3b37876cf51a02823494089378b13b4cfb767c3cfcb27c1863e13041d0f36", "owner": "fractal_store::store::message_marks", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 376, "excerpt": "[ message ] , | row | row . get ( 0 )"}, @@ -3625,6 +3627,7 @@ {"id": "lv-ha63cafb1e6ec", "path": "crates/fractal-api-client-v0/src/api/client_v0/conversation_blocks/conformance_tests.rs", "kind": "included-source", "fingerprint": "fe88de8f46687b50e243d2e7e13b5c690aa570c6142aa58c41dbdbcef8f69847", "owner": "fractal_api_client_v0::api::client_v0::conversation_blocks::conformance_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 88, "excerpt": "include_str ! ( \"../../../../../fractal-daemon/fixtures/native-records/schema-conformance.jsonl\" )"}, {"id": "lv-ha6479e16f374", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "e4cc6f68708be39c42f71efa03a4b2489d2136fb0df4dde2ad74901da72ddc78", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 87, "excerpt": "[ SAVED ] , | row | row . get ( 0 )"}, {"id": "lv-ha64a86b53612", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "59d9781204682b2198bb3abd0539c544c5b22dfa7690688bdee18d0bed950ec9", "owner": "smallclaims::store::receive_writer_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 466, "excerpt": "& format ! ( \"SELECT COUNT(*) FROM {table}\" ) , [ ] , | row | row . get : : < _ , usize > ( 0 )"}, + {"id": "lv-ha652f7f9027c", "path": "crates/fractal-store/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "b913b20551edd31fa441dbb3c5ddd585411e797b650d642c17cbe56202dfa9d5", "owner": "fractal_store::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1125, "excerpt": "& canonical_sql ( \"SELECT id,json_extract(body,'$.fields.held') FROM claims WHERE subject=?1 AND kind='delivery.hold' ORDER BY CANONICAL_DESC(claims) LIMIT 1\" ) , [ & debt . subject ] , | row | Ok ( ("}, {"id": "lv-ha65e1b1067de", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "2153b63b7aab368f7f28840b3f77e7922203972b4d754635b88c01ca4f6c202e", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 402, "excerpt": "DELETE FROM run_generations WHERE id='ancestor'"}, {"id": "lv-ha67abecc6bbe", "path": "crates/fractal-api-core/src/api/agents_request_slo/ledger.rs", "kind": "sql-template", "fingerprint": "cadbd330d01782578e2222f8fb2064a604b1c76fd09c22c6fcd0fae6d8793cd1", "owner": "fractal_api_core::api::agents_request_slo::ledger", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 231, "excerpt": "DELETE FROM fault_outbox WHERE through_ms > ?1 OR reviewer <> ?2"}, {"id": "lv-ha6bec6a3d57f", "path": "crates/smallclaims/src/store/replica_record.rs", "kind": "sql-template", "fingerprint": "621f10fab1d7953903c1c6ac771e982f26dc97aac55ab220c4cf87d1f4eb6d5d", "owner": "smallclaims::store::replica_record", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 22, "excerpt": "UPDATE replica_envelopes SET receipt_state='pending' WHERE (writer, sequence, envelope_hash) IN ( SELECT writer, sequence, envelope_hash FROM replica_records WHERE state='invalid' AND error_code='inte"}, @@ -3845,6 +3848,7 @@ {"id": "lv-hb4745e0911a9", "path": "crates/fractal-store/src/store/checkpoint_capture_epoch_tests.rs", "kind": "sql-template", "fingerprint": "9a60cec716324dc49cc7fa43d7921c3b5f6652271ae6cdc3cc405a0097e122e1", "owner": "fractal_store::store::checkpoint_capture_epoch_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 154, "excerpt": "UPDATE {table} SET {reference}=?1 WHERE {reference}=?2"}, {"id": "lv-hb47d29970c26", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "f10e20728e77f302935d7b4f1031c77dca8743a680305ee8cdf2f818e584bbb5", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6788, "excerpt": "\"ROLLBACK\""}, {"id": "lv-hb49149840f8d", "path": "crates/smallclaims/src/store/projection_digest.rs", "kind": "unresolved-sql-sink", "fingerprint": "bbfac8f07b9a24bc278443aa0d496ef7bbd0019ed35cee7698f44b358102850e", "owner": "smallclaims::store::projection_digest", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 445, "excerpt": "[ ] , | row | row . get ( 0 )"}, + {"id": "lv-hb491fd4d2964", "path": "crates/fractal-store/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "45ec71ec948496baf07cd910b4afa4ce24cf1ca23ceb90d896df79a238e08dc4", "owner": "fractal_store::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1044, "excerpt": "& canonical_sql ( \"SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms FROM claims WHERE kind='source.debt.recorded' AND store_index<=?1 AND (?2 IS NULL OR j"}, {"id": "lv-hb49b53d4bf64", "path": "crates/fractal-attention/src/store/attention_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "90dbe74bec90013705bd92e02619372a20cb7733a5375be33830ea72c895a370", "owner": "fractal_attention::store::attention_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 155, "excerpt": "[ subject ] , | row | row . get ( 0 )"}, {"id": "lv-hb4a17852ca35", "path": "crates/fractal-store/src/store/publication.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::publication", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "& query"}, {"id": "lv-hb4ac3d3059e1", "path": "crates/fractal-store/src/store/status_summary_tests.rs", "kind": "sql-template", "fingerprint": "c9a09b11ff6ab079fbc412af4f0d90b393bd1fcbe770fc4c3a7fcdbdb56c2bc2", "owner": "fractal_store::store::status_summary_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 365, "excerpt": "INSERT INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES (?1,?2,?3,'fixture',?4)"}, @@ -4369,6 +4373,7 @@ {"id": "lv-hd1d134afc90b", "path": "crates/fractal-reconcile/src/reconcile.rs", "kind": "sql-template", "fingerprint": "f1b2436151fe5b45a9ce184673be65aad93ba4572684352caa46fcdfd4e49d97", "owner": "fractal_reconcile::reconcile", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 31662, "excerpt": "INSERT INTO step_runs ({}) SELECT {select} FROM step_runs LIMIT 1"}, {"id": "lv-hd1df65300826", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "sql-template", "fingerprint": "b00efd86cc227528fbfa988943c50c7bc8f88c73ef6908072ddf7ed7cac1bc74", "owner": "fractal_store::store::client_messages", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 778, "excerpt": "DELETE FROM meta WHERE key=?1"}, {"id": "lv-hd1f5a94cf8be", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "107fb325815d852a48c48a97dc5425567d4e41bb5b0116e45c060f4faa6a0e72", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 3592, "excerpt": "UPDATE revision_proposals SET status='cancelled', updated_at_unix_ms=?2 WHERE id=?1"}, + {"id": "lv-hd1fb700a1544", "path": "crates/fractal-store/src/store/owned_sets.rs", "kind": "unresolved-sql-sink", "fingerprint": "528b7e38732e9399bff453bb0c4a8d97b6e563a1bfec2114295dcdef6e0d8b73", "owner": "fractal_store::store::owned_sets", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1220, "excerpt": "& canonical_sql ( \"SELECT id,store_index,batch_id,subject,kind,origin,actor,body,predecessors,accepted_at_unix_ms FROM claims WHERE kind='source.audited' ORDER BY CANONICAL_DESC(claims) LIMIT ?1\" , )"}, {"id": "lv-hd20534dfeafb", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "983441642e06e7610199310805eae2a0de9291fd7c423ca915641600bf2f65d0", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6366, "excerpt": "& format ! ( \"{LOCAL_OBSERVATION_COLUMNS} WHERE subject=?1 AND kind=?2 ORDER BY id DESC LIMIT 1\" ) , params ! [ subject , kind ] , | row | local_observation_from_row ( & self . origin , row ) ,"}, {"id": "lv-hd20703cfddb5", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "sql-template", "fingerprint": "a5aeb79a86eda686021e558b256fec8d1c3c86f810853d4e27c477ce2f42751a", "owner": "fractal_store::store::resources_preparation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 830, "excerpt": "UPDATE local_resource_preparation SET observed_at=?3,observation_key=?4, attribution=COALESCE(json_extract(?2,'$.fields.attribution_only'),0) WHERE subject=?1 AND (observation_key IS NULL OR COALESCE("}, {"id": "lv-hd2150e929695", "path": "crates/smallclaims/src/store/canonical.rs", "kind": "sql-template", "fingerprint": "f5b79d1ad62b425386858145177cb84c2db910db43dda866ed8c7579bff3c352", "owner": "smallclaims::store::canonical", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 67, "excerpt": " DROP TRIGGER IF EXISTS claim_positions_record_insert_v1; DROP TRIGGER IF EXISTS claim_positions_record_change_v1; DROP TRIGGER IF EXISTS claim_positions_record_changed_v1; DROP TRIGGER IF EXISTS clai"}, diff --git a/docs/st3/large-values.md b/docs/st3/large-values.md index 6fedc30e108..4e95a6eaf2c 100644 --- a/docs/st3/large-values.md +++ b/docs/st3/large-values.md @@ -202,6 +202,8 @@ These accept existing inline representations, not a value-size or cost bound. The reviewed `flow-h1f241b496964` admission flow now checks a repair's stored request before writing. This adds no SQL sink or body copy; it retains the existing body/predecessor representation debt and rejects agent-review repairs before Core append or projection. +The same flow takes a `source_forced` flag: a mission retire that its owning set's source gate admitted as forced skips only the plain `set-managed-subject` refusal, and records its source debt in the same transaction. This adds no SQL sink or body copy and keeps the body/predecessor representation debt. + ### Agent answer atomicity inventory review The dormant answer writer shares generic append's operation-outcome admission and isolates its mutations in a savepoint. These reviewed exceptions preserve existing value representations; they do not establish size bounds. diff --git a/docs/st3/operational-state/cli-commands.json b/docs/st3/operational-state/cli-commands.json index d337b7da94f..6e10d1b3973 100644 --- a/docs/st3/operational-state/cli-commands.json +++ b/docs/st3/operational-state/cli-commands.json @@ -104,6 +104,7 @@ "apply", "validate", "sets", + "source", "up", "now", "note", diff --git a/docs/st3/owned-sets.md b/docs/st3/owned-sets.md index 8ed34259763..4629a3ee2c5 100644 --- a/docs/st3/owned-sets.md +++ b/docs/st3/owned-sets.md @@ -97,7 +97,8 @@ The existing redacted agent `subject.definition` operation remains available. Omitting a seat declares a stop while keeping its conversations and history. Omitting a mission prevents new runs, including starts pinned to an older revision, while retaining active runs. -Omitting a schedule stops future occurrences and retains work already created. +Omitting a schedule stops future occurrences and retains work already created. Omitting a +top-level subscription stops its deliveries and retains messages it already delivered. A seat declared `one-shot` also permits its runtime host to retire that exact member after the process exits. The daemon records a stop while retaining set ownership and the source bundle. @@ -180,3 +181,76 @@ clients expose the additive `owned-set` resource and set list/detail operations. An optional [`when-idle` rollout policy](owned-seat-cutover.md) drains changed and retiring native seats and verifies their original conversation on the replacement. Without it, automatic seats retain their immediate runtime behavior. Manual seats defer cutover with or without the set policy. + +## Source enforcement + +A set's revision may carry a source policy. The publisher declares it and `--expect-set` fences it +with the rest of the revision. A publication that omits it keeps the selected policy: + +```sh +fractal apply --set garden ... --enforce enforce --publisher agent/dotfiles/factory-publisher \ + --namespace 'agent/garden/*' --unmanaged-allow 'agent/garden/scratch-*' +fractal apply --set garden ... --source-policy policy.json +``` + +`policy.json` holds `{"enforce": "off"|"audit"|"enforce", "publisher": [...], "namespaces": [...], +"unmanaged_allow": [...]}`. Each omitted field keeps the selected value, and explicit flags override +the file. A policy equal to `off` with empty lists is stored as absent. Namespace and allow globs +claim top-level `agent/`, `schedule/`, `subscription/` or `mission/` subjects only: an agent's +`exec`/`pty` tasks ride with their agent, and other `exec/` and `pty/` subjects are runtime +(mission-run tasks, owned terminals), so such a glob is refused with `invalid-source-policy`. + +- `off`, or no policy, is the behaviour above: a plain declaration of a member is refused with + `set-managed-subject`, and nothing else is checked. There is no force path. +- `enforce` refuses every ad hoc desired-state write to a live member: plain `fractal apply` + (definitions, schedules and subscriptions), `agents stop`, `agents start`, mission publication, + `missions retire`, `agents hold`, `agents suspend` and `agents resume`. It also refuses creation of + a new subject matching `namespaces` but not `unmanaged_allow`. The refusal names the verb, the set, + its source, and the escape. +- `audit` refuses nothing that `off` accepts. Each write that `enforce` would refuse proceeds and + appends `source.audited` (verb, actor, set revision and the refusal) in the same transaction. A + plain declaration of a member remains refused because it cannot take effect over the set's own + member reference. + +Terminal `agents retire` of a live member of an `enforce` set is refused with `set-managed-subject` +through every path, including client-v0. It cannot be forced: omit the member from the set +publication, then retire it. + +`--force --reason TEXT` accepts the write on an `audit` or `enforce` set. The reason must be +nonempty. Persons and agents may force (`fractal apply`, `agents stop|start|hold|suspend|resume`, +`missions retire`). The write takes effect, and `source.debt.recorded` on the member names the set +revision, its source, the verb, the reason, the forced claim (or, for a hold, suspend or resume, the +runtime request) and the changed pointers. The debt, like a `source.audited` record, is appended in +the transaction that makes the change: a write whose debt cannot be recorded does not happen. A +forced declaration (including a mission) overrides the set's member reference until the set +publishes again. + +The next publication with a new source sequence converges every member and closes all of the set's +open debt. Its preview lists `reverted_debts`, and apply returns `source_debts_reverted`. Each entry +has the outcome `reverted` (the source differs, or a forced hold or suspension is still in place), +`adopted` (the source now declares the forced value or created subject), or `closed` (a runtime +change that no longer stands, such as a forced resume or a hold someone replaced). A reverted change +shows the effect `revert ad hoc change by ACTOR: REASON`. In its own transaction the publication +writes one `source.debt.closed` per debt (debt claim, outcome, closing set revision, publisher), +releases a forced delivery hold that is still the seat's latest, and resumes a forced suspension +that is still the seat's latest suspend or resume: it appends the resume request `agents resume` +would, on the seat's current host, as the publisher. A forced creation that the source still omits +is stopped and kept as a retired member. + +```sh +fractal source status # policies, open debt, debt each current revision closed +fractal source status --audit # also the newest 200 audit records +``` + +When the selected or requested policy names `publisher`, only those actors may publish the set +(`source-publisher-refused`). Replicated admission does not re-check this because writers are not +signed yet. Treat the publisher list as a local guard, not authority against a compromised peer. + +A revision carrying a policy, or a set with subscription members, requires every active fleet +member to advertise `features.source_enforcement=1`. Older daemons reject such revisions and +ignore debt overrides. + +Not routed through source: messages, asks and answers, work claim and completion, run cancel, +mission run starts, a seat's own `restart-when-idle`, and restarting another agent (a runtime +action with no desired value). Fractal has no native `startable-by` grant yet, so grant-based +starts are not enforced. diff --git a/docs/st3/schema.md b/docs/st3/schema.md index c75297b8798..d9b10c8c975 100644 --- a/docs/st3/schema.md +++ b/docs/st3/schema.md @@ -219,6 +219,9 @@ Custom subjects use `custom/NAMESPACE/NAME`. Custom claims use `custom.NAMESPACE | `sekret.exited` | `sekret` | `system-only` | `append` | `local` | `at_unix_ms!:integer`, `call!:integer`, `caller:subject-reference(agent|person)`, `error:string`, `exit_code:integer`, `person!:subject-reference(person)`, `profile:string`, `seq!:integer`, `signal:integer` | | | `sekret.refused` | `sekret` | `system-only` | `append` | `local` | `argv:array`, `at_unix_ms!:integer`, `caller:subject-reference(agent|person)`, `person!:subject-reference(person)`, `profile:string`, `reason!:string`, `seq!:integer` | | | `slo.breach-transition` | `resource` | `system-only` | `append` | `durable` | `record!:object` | | +| `source.audited` | `agent`, `schedule`, `subscription`, `mission`, `exec`, `pty` | `system-only` | `append` | `durable` | `refusal!:string`, `set!:string`, `set_revision!:string`, `verb!:string` | | +| `source.debt.closed` | `agent`, `schedule`, `subscription`, `mission`, `exec`, `pty` | `system-only` | `append` | `durable` | `debt!:string`, `outcome!:string`, `publisher:any`, `set!:string`, `set_revision!:string` | | +| `source.debt.recorded` | `agent`, `schedule`, `subscription`, `mission`, `exec`, `pty` | `system-only` | `append` | `durable` | `after:any`, `before:any`, `claim:string`, `pointers:array`, `reason!:string`, `ref!:string`, `repository!:string`, `request:string`, `revision:string`, `set!:string`, `set_revision!:string`, `sha!:string`, `verb!:string` | | | `step-run.carried` | `step-run` | `system-only` | `once` | `durable` | `attempt:integer`, `claim_expires_at_unix_ms:integer`, `claim_incarnation:string`, `claimant:subject-reference`, `definition_hash:string`, `source:subject-reference`, `source_generation:subject-reference`, `source_step_run:subject-reference`, `status:string`, `worker_reported:boolean` | `step` | | `step-run.retried` | `step-run` | `system-only` | `append` | `durable` | `attempt:integer`, `goals:array`, `not_before_unix_ms:integer`, `reason:string`, `status:string` | `step` | | `step-run.state` | `step-run` | `system-only` | `state-transition` | `durable` | `attempt:integer`, `readiness_epoch:integer`, `reason:string`, `status:string` | `step` | diff --git a/fixtures/convergence/claims-v18.json b/fixtures/convergence/claims-v18.json index da97c02df0d..6486e678535 100644 --- a/fixtures/convergence/claims-v18.json +++ b/fixtures/convergence/claims-v18.json @@ -8567,6 +8567,192 @@ "projection": "slo-breaches", "wakes_reconciler": true }, + "source.audited": { + "kind": "source.audited", + "subjects": [ + "agent", + "schedule", + "subscription", + "mission", + "exec", + "pty" + ], + "fields": { + "refusal": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "set": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "set_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "verb": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "owned-sets", + "wakes_reconciler": false + }, + "source.debt.closed": { + "kind": "source.debt.closed", + "subjects": [ + "agent", + "schedule", + "subscription", + "mission", + "exec", + "pty" + ], + "fields": { + "debt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "outcome": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "publisher": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "set": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "set_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "owned-sets", + "wakes_reconciler": false + }, + "source.debt.recorded": { + "kind": "source.debt.recorded", + "subjects": [ + "agent", + "schedule", + "subscription", + "mission", + "exec", + "pty" + ], + "fields": { + "after": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "before": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "claim": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "pointers": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "ref": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "repository": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "set": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "set_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sha": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "verb": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "owned-sets", + "wakes_reconciler": false + }, "step-run.carried": { "kind": "step-run.carried", "subjects": [ diff --git a/release-notes/source-enforcement.json b/release-notes/source-enforcement.json new file mode 100644 index 00000000000..9f49ac2519f --- /dev/null +++ b/release-notes/source-enforcement.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Owned sets enforce their source: ad hoc desired-state writes to members are refused or forced with source debt that the next publication reverts, with an audit mode", + "replay": {"status": "none", "detail": "Existing owned-set receipts without a policy replay unchanged as enforce=off; new source.* claims fold into the same projections on every replica."}, + "schema": {"status": "changed", "detail": "No SQL migration; new claim kinds source.debt.recorded, source.debt.closed and source.audited, owned-set.revised may carry a policy, and set-managed-subject becomes a client-v0 error code."}, + "rules": {"status": "changed", "detail": "Sets with an audit or enforce policy gate apply, create in a claimed namespace, stop, start, mission publish and retire, hold, suspend, resume and retire of their members; --force --reason records debt in the same transaction, and publication closes all debt and reverts forced changes."}, + "compatibility": {"status": "changed", "detail": "Publishing a policy or subscription members blocks until every admitted fleet member advertises features.source_enforcement=1; only listed publishers may revise a set with a policy."}, + "downtime": {"status": "none", "detail": "No downtime; sets without a policy behave as before."}, + "manual": {"status": "none", "detail": "No user action is required; operators opt a set in with --enforce or --source-policy."}, + "recovery": {"status": "none", "detail": "Publishing the set with enforce off removes enforcement; open debt is closed by that publication."} +}