diff --git a/INVARIANTS.md b/INVARIANTS.md index 8b7d7d9d..2917b2d8 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -6,8 +6,8 @@ materialization, messaging, DING, or presence must preserve them. | Invariant | Guarantee | Proof | |---|---|---| | **R11 control-plane replacement safety** | Normal stop or SIGKILL of `st2 up`, atomic binary replacement, and control-plane restart leave live PTY and exec agents usable with the same PID and creation identity. The replacement adopts each survivor exactly once, launches only genuinely missing work, and never duplicates a survivor; only explicit teardown kills an agent. | `tests/nomad_survival.rs::normal_stop_and_binary_replacement_adopt_exec_unchanged_without_duplicate`; `tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_exec_unchanged_without_duplicate`; `tests/nomad_survival.rs::normal_stop_and_binary_replacement_adopt_pty_unchanged_without_duplicate`; `tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_pty_unchanged_without_duplicate`; `tests/nomad_survival.rs::explicit_teardown_kills_exec_but_plain_stop_does_not`; `tests/nomad_survival.rs::explicit_teardown_kills_pty_but_plain_stop_does_not` | -| **Runner-owned task identity** | Every launched PTY and exec task receives `ST_AGENT=.` from reconciliation. Missing values are injected, exact authored matches are accepted, conflicts refuse before workspace or runner mutation, and restart/replay preserves the value. Every managed PTY carries the actor path, while only the canonical agent PTY carries the agent compatibility role. | `tests/run.rs::runner_owned_identity_injects_compact_and_explicit_task_omissions_and_accepts_a_match`; `tests/run.rs::runner_owned_identity_conflict_refuses_before_materialization_or_runner_access`; `tests/run.rs::runner_owned_identity_metadata_is_form_equivalent_and_role_scoped`; `tests/run.rs::runner_owned_identity_is_rederived_for_dead_task_replay`; `src/run.rs::tests::build_run_command_persists_the_complete_managed_environment_before_the_command` | -| **R23 fail-closed diagnostic inventory** | `st2 tasks --json` joins a semantically stable desired-catalog observation to exact PTY and conservatively proven exec generations without writes or reconciliation. Task absence intent, typed agent desired state/rationale, and the owning supervisor's park decision remain distinct: a believable park and structured recovery argv carrying the exact canonical catalog and selected host accompany the unmodified runtime observation as a complete known fault, while an unbelievable marker is indeterminate. Missing roots are positively absent without creation; declaration drift, malformed, unreadable, duplicate, PID-reused, or incomplete evidence makes the typed envelope incomplete and exits non-zero. The observational boundary does not serialize catalog writers or authorize cutover. | `src/task_inventory.rs::stable_wire_shape_maps_pty_exec_explicit_default_and_ignores_foreign_host`; `src/task_inventory.rs::complete_missing_runtime_is_absent_but_incomplete_is_indeterminate`; `src/exec_backend.rs::legacy_plain_pid_is_observed_without_rewriting_it`; `src/exec_backend.rs::record_older_than_current_process_generation_cannot_be_promoted`; `src/task_inventory.rs::a_parked_task_reports_its_fault_alongside_a_truthful_runtime_state`; `src/task_inventory.rs::an_unbelievable_park_marker_makes_the_envelope_incomplete`; `tests/task_inventory_cli.rs::projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_defaults`; `tests/task_inventory_cli.rs::suspended_agent_projects_task_absence_and_agent_rationale_separately`; `tests/task_inventory_cli.rs` | +| **Runner-owned task identity** | Every launched PTY and exec task receives its owner's runner-owned agent key as `ST_AGENT` from reconciliation, decided once per pass by the DELTA-003 identity gate: the raw immutable agent ID once every live and archived subject is migrated, and `.` while anything is unproven. Migration freezes each live subject's ID to its former bus identity, so activation moves no default task ID and no session socket path. Missing values are injected, exact authored matches are accepted, conflicts refuse before workspace or runner mutation, and restart/replay preserves the value. Every managed PTY carries the versioned owned metadata snapshot: schema 2's `agent.actor.id` plus `agent.actor.address` replace schema 1's `agent.actor.path`, which is emitted as a removal so no PTY keeps a stale owned key across the cutover. Only the canonical compact agent task whose task ID equals the agent ID carries the agent compatibility role and the native display name; every other PTY has that role actively cleared and keeps its own display convention. Exec tasks receive no PTY metadata, and an address change is a metadata patch that enters no launch, teardown, garbage collection, or replacement. | `tests/run.rs::runner_owned_identity_injects_compact_and_explicit_task_omissions_and_accepts_a_match`; `tests/run.rs::runner_owned_identity_conflict_refuses_before_materialization_or_runner_access`; `tests/run.rs::runner_owned_identity_metadata_is_form_equivalent_and_role_scoped`; `tests/run.rs::runner_owned_identity_is_rederived_for_dead_task_replay`; `tests/run.rs::runner_owned_identity_is_the_raw_agent_id_with_schema_two_metadata_once_activated`; `tests/run.rs::activated_wrapper_argv_carries_the_agent_id_and_the_task_id`; `tests/reconcile.rs::activated_identity_keys_st_agent_and_default_task_ids_on_the_immutable_id`; `tests/reconcile.rs::activated_pty_metadata_is_the_schema_two_snapshot_and_retires_the_actor_path`; `tests/reconcile.rs::activated_role_and_display_name_are_scoped_to_the_canonical_agent_task`; `tests/reconcile.rs::an_activated_address_change_patches_metadata_without_touching_the_runtime`; `src/run.rs::tests::build_run_command_persists_the_complete_managed_environment_before_the_command` | +| **R23 fail-closed diagnostic inventory** | `st2 tasks --json` joins a semantically stable desired-catalog observation to exact PTY and conservatively proven exec generations without writes or reconciliation. Task absence intent, typed agent desired state/rationale, and the owning supervisor's park decision remain distinct: a believable park and structured recovery argv carrying the exact canonical catalog and selected host accompany the unmodified runtime observation as a complete known fault, while an unbelievable marker is indeterminate. Missing roots are positively absent without creation; declaration drift, malformed, unreadable, duplicate, PID-reused, or incomplete evidence makes the typed envelope incomplete and exits non-zero. The observational boundary does not serialize catalog writers or authorize cutover. Each row is keyed by its owning agent — the immutable agent ID once the catalog is migrated, the legacy bus identity while it is not — and carries that agent's bus address, `null` once retirement released it; neither reading drops a declared task, relaxes the duplicate-runtime-id refusal, or lets a missing address read as a missing task. | `src/task_inventory.rs::stable_wire_shape_maps_pty_exec_explicit_default_and_ignores_foreign_host`; `src/task_inventory.rs::complete_missing_runtime_is_absent_but_incomplete_is_indeterminate`; `src/exec_backend.rs::legacy_plain_pid_is_observed_without_rewriting_it`; `src/exec_backend.rs::record_older_than_current_process_generation_cannot_be_promoted`; `src/task_inventory.rs::a_parked_task_reports_its_fault_alongside_a_truthful_runtime_state`; `src/task_inventory.rs::an_unbelievable_park_marker_makes_the_envelope_incomplete`; `tests/task_inventory_cli.rs::projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_defaults`; `tests/task_inventory_cli.rs::suspended_agent_projects_task_absence_and_agent_rationale_separately`; `tests/task_inventory_cli.rs`; `src/task_inventory.rs::activated_rows_are_id_keyed_with_a_nullable_address_and_still_refuse_duplicates` | | **Transport-decoupled lifecycle** | Each task is isolated from a supervisor/transport process-group or cgroup cascade. | `tests/transport_isolation.rs`; `tests/transport_isolation_macos.rs` | | **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` | | **Bounded restart diagnostics** | Relaunching an exec task preserves the just-finished log as one prior generation while bounding retained diagnostics to current plus prior. Final retirement removes the PID and both logs. | `tests/exec_backend.rs::exec_restart_reap_keeps_bounded_diagnostics_and_final_remove_cleans_them`; `tests/run.rs::up_once_finally_removes_dead_retired_tasks_without_restarting_them` | @@ -31,7 +31,7 @@ materialization, messaging, DING, or presence must preserve them. | **Native-driver diagnostics are bounded and recoverable** | One additive-tolerant `st2.driver-diagnostic.v1` snapshot exposes the earliest failing native-driver boundary through closed driver/stage/reason/source/support vocabulary and origin-timed evidence age. Absent, malformed, foreign, unknown, and wrongly paired records never read healthy. Stage recovery clears only its own failure and removes the record after final recovery without changing prompt submission, retry, read-back, or archive semantics. A rejected provider credential is published on its own `providerAuth` stage from the harness's own typed turn result — never from provider prose, and never from an auth-class flag a capacity, policy, or retry class also sets — outranking the delivery and read-back symptoms it causes while the four producer gates outrank it; only a turn that reached its ordinary end clears it, and no credential knowledge or remedy text enters st2. Roster and Doctor share the typed reader/repair policy — Doctor advising on absence only for a driver that publishes a boundary result on every launch; telemetry labels contain only bounded driver/stage/reason/source/support/outcome values, never versions, identities, prompts, messages, or paths. | `src/driver_diagnostic.rs::every_stage_reason_and_source_has_bounded_wire_vocabulary`; `src/driver_diagnostic.rs::additive_fields_decode_but_malformed_foreign_and_unknown_records_are_indeterminate`; `src/driver_diagnostic.rs::recovery_clears_only_its_stage_and_reveals_the_next_failure`; `src/driver_diagnostic.rs::a_credential_rejection_is_evidence_only_from_a_typed_turn_result`; `src/driver_diagnostic.rs::a_rejected_credential_outranks_its_symptoms_but_not_the_producer_gates`; `src/claude_session.rs::stop_failure_classifies_only_the_credential_class_as_provider_auth`; `src/claude_session.rs::a_rejected_claude_credential_stands_until_a_turn_reaches_its_ordinary_end`; `src/codex_app_server.rs::a_rejected_codex_credential_reads_provider_auth_while_a_quota_failure_does_not`; `src/codex_app_server.rs::protocol_schema_gate_requires_the_distinct_credential_and_quota_error_words`; `src/opencode_session.rs::delivery_and_read_back_boundaries_publish_and_clear_diagnostics_without_changing_retry`; `src/metrics.rs::driver_diagnostic_metric_attributes_are_exactly_the_bounded_axes`; `tests/doctor.rs::native_driver_diagnostic_roster_and_doctor_agree_and_recovery_clears`; `src/pi_channel.rs::only_omps_own_credential_class_becomes_provider_auth`; `src/pi_channel.rs::a_rejected_omp_credential_stands_until_a_turn_reaches_its_ordinary_end`; `src/pi_channel.rs::the_pi_channel_publishes_no_credential_verdict`; `tests/doctor.rs::a_rejected_provider_credential_advises_while_absence_stays_silent` | | **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` | | **Suspension health** | A suspended declaration is healthy when no declared task is live and every retained dead record is explicitly keep-pinned. It requires no presence, but this weaker result never proves retirement. Resume preserves ordinary keep and adopt-only policy. | `tests/doctor.rs::suspended_declaration_is_healthy_when_tasks_are_absent_without_presence`; `tests/doctor.rs::suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep`; `tests/reconcile.rs::resuming_uses_ordinary_reconcile_and_does_not_override_keep` | -| **Archival leaves the live catalog** | `st2 catalog archive` moves a retired identity's whole directory — declaration and `resources/` byte-identical — under the catalog control plane's `.st2/archive`, where discovery structurally cannot reach it, and records one tombstone row in `st2 catalog graph --json`. Eligibility fails closed: a running or suspended declaration, any surviving live or dead declared task record, and any remaining declaration that still names the identity as `supervisor` each refuse before anything moves, and `--dry-run` proves the plan without creating the archive root. An archived directory with no readable tombstone is unexplained control-plane state that makes the graph incomplete. `st2 catalog unarchive` is the exact reverse move. | `tests/catalog_archive.rs::archive_moves_a_retired_seat_out_of_discovery_with_its_resources_byte_identical`; `tests/catalog_archive.rs::archive_refuses_a_running_or_suspended_declaration`; `tests/catalog_archive.rs::archive_refuses_while_any_declared_task_record_survives`; `tests/catalog_archive.rs::archive_refuses_an_identity_another_declaration_still_names_as_supervisor`; `tests/catalog_archive.rs::dry_run_reports_the_plan_and_changes_nothing`; `tests/catalog_archive.rs::an_archived_directory_without_a_tombstone_makes_the_graph_incomplete`; `tests/catalog_archive.rs::unarchive_restores_the_identity_byte_identically_and_clears_its_tombstone` | +| **Archival leaves the live catalog** | `st2 catalog archive` moves a retired identity's whole directory — declaration and `resources/` byte-identical — under the catalog control plane's `.st2/archive`, where discovery structurally cannot reach it, and records one tombstone row in `st2 catalog graph --json`. Eligibility fails closed: a running or suspended declaration, any surviving live or dead declared task record, and any remaining declaration that still names the identity as `supervisor` each refuse before anything moves, and `--dry-run` proves the plan without creating the archive root. An archived directory with no readable tombstone is unexplained control-plane state that makes the graph incomplete. `st2 catalog unarchive` is the exact reverse move, and an archived subject's immutable agent ID is frozen: the tombstone records it, the restored declaration must still declare the same one, and once every other live and archived subject carries an explicit ID an unmigrated archive cannot re-enter the catalog while a restored ID must be unique across the prospective live-and-archived set. | `tests/catalog_archive.rs::archive_moves_a_retired_seat_out_of_discovery_with_its_resources_byte_identical`; `tests/catalog_archive.rs::archive_refuses_a_running_or_suspended_declaration`; `tests/catalog_archive.rs::archive_refuses_while_any_declared_task_record_survives`; `tests/catalog_archive.rs::archive_refuses_an_identity_another_declaration_still_names_as_supervisor`; `tests/catalog_archive.rs::dry_run_reports_the_plan_and_changes_nothing`; `tests/catalog_archive.rs::an_archived_directory_without_a_tombstone_makes_the_graph_incomplete`; `tests/catalog_archive.rs::unarchive_restores_the_identity_byte_identically_and_clears_its_tombstone`; `tests/catalog_archive.rs::unarchive_refuses_when_the_tombstone_and_the_declaration_disagree_on_the_agent_id`; `tests/catalog_archive.rs::unarchive_refuses_an_unmigrated_archive_once_the_live_catalog_is_migrated`; `tests/catalog_archive.rs::unarchive_refuses_an_id_the_prospective_live_and_archived_set_already_holds` | | **Retirement archives itself after its grace period** | Each `st2 up` reconcile pass archives the local seats whose retirement outlived the catalog's `archive-after` (default `7d`; `"0"` disables the step), through the same fail-closed eligibility as the verb and at most 25 seats per pass. It never queues for the authoring lock: a contended lock skips the step. The grace period is measured from the supervisor's first observation of the retirement, recorded under `.st2/retired-observed.json` and never in the spec, and that ledger is reconciled to exactly the currently retired seats — so un-retiring drops the row and re-retiring serves a fresh grace period. | `tests/supervisor_auto_archive.rs::a_pass_records_a_fresh_retirement_and_archives_nothing`; `tests/supervisor_auto_archive.rs::a_retirement_older_than_the_grace_period_is_archived_with_a_tombstone`; `tests/supervisor_auto_archive.rs::archive_after_zero_disables_the_pass_entirely`; `tests/supervisor_auto_archive.rs::a_pass_archives_at_most_twenty_five_seats_and_drains_the_rest_next_pass`; `tests/supervisor_auto_archive.rs::a_contended_authoring_lock_skips_the_pass_instead_of_blocking_it`; `tests/supervisor_auto_archive.rs::un_retiring_a_seat_inside_the_grace_period_restarts_its_clock` | | **Crash loops surface** | A task parked by a fail-mode restart policy notifies its supervisor once over the bus. | `tests/run.rs::surface_crash_loop_notifies_the_supervisor_over_the_bus` | | **Parked tasks are visible and individually recoverable** | A parked task is reported alongside an unmodified runtime observation as a complete known fault; only an unbelievable marker fails closed. Park markers and unpark requests share the exact canonical catalog-folder plus host ownership scope, and the projected recovery argv carries both axes, so same-host supervisors cannot see, delete, consume, or advertise recovery into each other's channels even for the same task ID. A projected park whose supervisor generation is gone is positively not parked. An explicit per-task unpark clears that task's park and spent budget so it is launchable again and stays recovered past `interval`, releases no other parked task, restarts no healthy peer, and restores the agent's derived DING. | `src/flapping.rs::unpark_restores_a_launchable_task_not_just_a_cleared_flag`; `src/flapping.rs::unpark_is_per_task_and_reports_whether_it_changed_anything`; `src/park.rs::same_host_supervisors_isolate_markers_and_requests_by_catalog`; `src/park.rs::a_marker_from_a_dead_supervisor_reads_as_not_parked`; `src/park.rs::published_parks_are_readable_and_clear_when_the_task_recovers`; `src/park.rs::an_unbelievable_marker_is_indeterminate_not_absent`; `src/park.rs::a_request_is_consumed_exactly_once`; `src/task_inventory.rs::a_parked_task_reports_its_fault_alongside_a_truthful_runtime_state`; `src/task_inventory.rs::an_unbelievable_park_marker_makes_the_envelope_incomplete`; `tests/task_inventory_cli.rs::projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_defaults`; `tests/run.rs::an_operator_recovers_one_parked_task_without_disturbing_a_healthy_peer`; `tests/run.rs::an_unpark_request_for_a_task_that_is_not_parked_says_so` | @@ -41,5 +41,5 @@ materialization, messaging, DING, or presence must preserve them. | **Atomic resource proposal publication** | Every changed resource publication is one host-owned compare-and-swap fenced by binding generation, state revision, and prior carrier digest. A persistent cross-process lock admits at most one proposal from the same prior. The content-derived proposal ID binds the accepted carrier digest and semantic outbox envelope; the durable intent becomes eligible only with the exact canonical carrier, then folds into one authoritative catch-up state. A pre-carrier crash exposes old state, a post-carrier crash catches up on restart, and retry after a lost acknowledgement returns the durable receipt without another transition. Ordinary reconciliation fails closed on out-of-band divergence after a committed intent; only an explicit generation-advance recovery may re-adopt the canonical carrier or its absence while invalidating the old intent and fence. | `src/resource_profile.rs::tests::atomic_publication_fences_races_and_survives_crash_restarts`; `src/resource_profile.rs::tests::generation_advance_explicitly_recovers_diverged_or_missing_carrier` | | **Pass progress is independent of publication** | A reconcile pass completes whether or not resync publication is making progress. Publication runs on its own thread, so a pass's per-seat watch-set handshakes never wait for a catalog lock, a stream lock, or a refused publication. One publication is outstanding per subscription at a time, and a queued publication is dropped when its subscription is deactivated or removed by a refresh, so nothing is published to a seat the pass has already decided receives no events. | `src/run.rs::reconcile_pass_completes_while_a_resync_publication_is_blocked`; `src/resync.rs::a_flush_never_hands_off_a_subscription_whose_publication_is_outstanding`; `src/resync.rs::deactivation_drops_only_that_recipients_queued_publication`; `src/resync.rs::a_refresh_drops_a_queued_publication_for_a_subscription_it_removed` | | **Terminal refusals are classified, not retried** | A refused resync publication is classified by what could admit it later. A recipient that is declared but not running parks its reservation: it is attempted once, captures and schedules nothing while parked, and re-arms with its exact reserved bytes when a refresh carries that recipient again. An ambiguous recipient, a foreign owning host, and an undeclared stream are permanently refused: the reservation is dropped and the carrier baseline advances so the same transition is not re-captured. Everything else, including an absent declaration, stays retryable. | `src/resync.rs::a_not_running_recipient_parks_its_reservation_and_is_attempted_once`; `src/resync.rs::a_parked_reservation_re_arms_and_replays_when_its_recipient_runs_again`; `src/resync.rs::a_permanently_refused_reservation_is_dropped_and_not_recaptured`; `src/resync.rs::refusals_are_classified_by_what_could_admit_them_later` | -| **Unbindable session sockets fail at admission** | Host-scoped validation rejects a pty task whose session socket path would exceed the portable 104-byte `sun_path` bound, because `pty` refuses that bind and the task can then never spawn. The bound is derived from the pty root resolved for the selected host rather than a fixed identity length, only that host's pty tasks are judged against it, and the diagnostic names the resolved path and the byte overage. A park whose cause is that same unbindable path does not advise `st2 unpark`, which would relaunch into the identical failure. | `tests/validate.rs::an_unbindable_session_socket_path_is_rejected_at_admission`; `tests/validate.rs::another_hosts_long_identity_is_not_judged_against_this_hosts_pty_root`; `tests/validate.rs::a_long_exec_task_id_is_not_a_socket_path_issue`; `src/run.rs::session_socket_overage_is_derived_from_the_resolved_root`; `tests/run.rs::a_structurally_unrecoverable_park_does_not_advise_unpark` | +| **Unbindable session sockets fail at admission** | Host-scoped validation rejects a pty task whose session socket path would exceed the portable 104-byte `sun_path` bound, because `pty` refuses that bind and the task can then never spawn. The bound is derived from the pty root resolved for the selected host rather than a fixed identity length, only that host's pty tasks are judged against it, and the diagnostic names the resolved path and the byte overage. The shape it judges is the default task ID `.`, whose key is the subject's immutable agent ID once the identity model is activated; because migration freezes each live subject's ID to its former bus identity, every legacy task ID and socket path is preserved by construction and admission keeps judging exactly the same bytes. A park whose cause is that same unbindable path does not advise `st2 unpark`, which would relaunch into the identical failure. | `tests/validate.rs::an_unbindable_session_socket_path_is_rejected_at_admission`; `tests/validate.rs::another_hosts_long_identity_is_not_judged_against_this_hosts_pty_root`; `tests/validate.rs::a_long_exec_task_id_is_not_a_socket_path_issue`; `tests/reconcile.rs::a_migrated_subject_keeps_byte_identical_task_ids_and_socket_paths`; `src/run.rs::session_socket_overage_is_derived_from_the_resolved_root`; `tests/run.rs::a_structurally_unrecoverable_park_does_not_advise_unpark` | | **Proof references resolve** | Every qualified test named in this table exists in its named source file, so stale invariant claims fail the suite instead of silently surviving a refactor. | `tests/invariants.rs::qualified_proof_references_resolve` | diff --git a/crates/st2-wire/src/message.rs b/crates/st2-wire/src/message.rs index 285d01c1..3d9104a9 100644 --- a/crates/st2-wire/src/message.rs +++ b/crates/st2-wire/src/message.rs @@ -92,6 +92,16 @@ pub struct SentMessageRow { pub idempotency_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, + /// Display-only publication snapshot of the recipient's bus address at send time. Never a + /// selector. A version-1 row never carried one, so absence is absence. + #[serde(rename = "toAddress", default, skip_serializing_if = "Option::is_none")] + pub to_address: Option, + /// What [`Self::to`] names: `agent` for an immutable agent ID, `principal` or `external` for + /// that endpoint's canonical address. Absent means the row never declared a kind — a + /// consumer that reads a version-1 row as `agent` makes that inference itself, because the + /// wire shape must not manufacture an authority the writer never published. + #[serde(rename = "toKind", default, skip_serializing_if = "Option::is_none")] + pub to_kind: Option, } /// The stable `st2 message sent --json` envelope. @@ -258,6 +268,8 @@ mod tests { priority: None, idempotency_key: None, body: None, + to_address: None, + to_kind: None, }], }; let json = serde_json::to_value(indexed).unwrap(); @@ -265,4 +277,37 @@ mod tests { assert!(json["messages"][0].get("from").is_none()); assert!(json["messages"][0].get("body").is_none()); } + + /// A version-1 row declared neither a recipient address snapshot nor an endpoint kind, so it + /// emits neither key; a version-2 row emits both. Absence is never rendered as `null` and + /// never defaulted to `agent`, because that inference belongs to the consumer. + #[test] + fn recipient_address_and_kind_are_emitted_only_when_the_row_declared_them() { + let row = |to_address: Option<&str>, to_kind: Option<&str>| SentMessageRow { + filename: "1785000000000-abcdef.md".to_string(), + ts: 1_785_000_000_000, + to: "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3".to_string(), + subject: None, + in_reply_to: None, + tags: Vec::new(), + priority: None, + idempotency_key: None, + body: None, + to_address: to_address.map(str::to_string), + to_kind: to_kind.map(str::to_string), + }; + + let legacy = serde_json::to_value(row(None, None)).unwrap(); + assert!(legacy.get("toAddress").is_none()); + assert!(legacy.get("toKind").is_none()); + + let activated = + serde_json::to_value(row(Some("dev4.fractal.chat"), Some("agent"))).unwrap(); + assert_eq!(activated["toAddress"], "dev4.fractal.chat"); + assert_eq!(activated["toKind"], "agent"); + + // Both keys are optional on the way in, so a version-1 reader's output still parses. + let parsed = serde_json::from_value::(legacy).unwrap(); + assert!(parsed.to_address.is_none() && parsed.to_kind.is_none()); + } } diff --git a/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md b/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md index 6a592d30..b8bdd802 100644 --- a/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md +++ b/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md @@ -6,11 +6,16 @@ Status: open [Decision 0015](../.decisions/0015-immutable-agent-id-and-mutable-address.md) and root requirements R19 and R24-R26 define the accepted target identity -model. The implementation still uses positional `identity` plus current host as -logical subject ID, human route, ownership key, task prefix, state selector, and -`ST_AGENT`. `AgentSpec` has no explicit `id` or `address`; ordinary resolution, -roster output, graph output, supervisor edges, messages, authoring, and PTY -metadata all retain the pre-decision behavior. +model. st2 now implements it behind one catalog-scoped activation gate +(`src/identity.rs`): `AgentSpec` carries an optional explicit `id` and +`address`, `st2 catalog migrate-ids` freezes every live and structurally +archived subject's ID in one transaction, and the target writers apply once the +catalog is fully migrated. Two things keep this delta open. The canonical Agent +Spec grammar still makes `id` optional rather than required, so a declaration +with no ID is admissible in an unmigrated catalog. And no admitted host is +migrated yet: the model is proved on fixtures and on a scratch copy of a real +722-declaration plane, not on the live fleet, and no supported downstream +generator emits `id`. ## VRS @@ -42,9 +47,8 @@ reject the new endpoint and snapshot fields. ## Implementation -No runtime code changes are part of the VRS pull request that opens this delta. -Implementation must begin with tests at the Agent Spec and address-book -boundaries. It must then propagate one typed ID/address distinction through: +Implementation began with tests at the Agent Spec and address-book boundaries +and propagated one typed ID/address distinction through: - live and archived catalog validation, explicit-ID migration, unarchive, and ID-keyed supervisor references; @@ -80,6 +84,37 @@ After activation, an unmigrated archived declaration cannot re-enter the catalog; unarchive validates ID uniqueness, and a transition from retired to routable validates full-catalog address uniqueness. +Progress against those steps, at the time of writing: + +- **Step 1 is done.** Readers accept legacy and target Agent Specs, message + versions 1 and 2, harness-state and harness-context versions 1 and 2, and + appended roster/graph projections. PTY schema 2 is written only behind the + gate and its schema-1 reader is unchanged. +- **Step 2 is not done.** Reader readiness has not been proved on any admitted + host: the readers exist in an unreleased build. +- **Step 3 exists as `st2 catalog migrate-ids`** and is proved on fixtures + (including a 640-subject plane with a live/archived ID collision, refusal, + and crash-resume cases) and by a dry run plus a full migration against a + scratch copy of a real 722-declaration plane. It has not been run against any + live catalog. +- **Step 4 is not done**, and cannot be until step 2 is. +- **Step 5 is implemented but not activated anywhere**, because activation is a + property of a migrated catalog rather than a flag: `identity::activation` + answers `Activated` only when every live and structurally archived subject + carries an explicit ID and no migration transaction is outstanding. A mixed + catalog is separately inadmissible (`agent-id-missing`), which is what stops a + new declaration — including one published through a digest-bound path whose + exact bytes cannot carry an injected ID — from entering a migrated catalog + without one. + +One obligation has no owner yet: no path in st2 synthesizes a new agent +declaration, so UUIDv7 creation has no call site here. Every new subject enters +as operator- or generator-authored canonical KDL bound byte-exactly by a digest +(`st2 agent publish --input-sha256`, `st2 catalog bootstrap|apply`). The target +behavior therefore belongs to the grammar gate — the canonical Agent Spec +requiring `id` — and to whichever generator authors the declaration, not to an +st2 writer that would have to break the caller's digest binding to inject one. + ## Direction update implementation diff --git a/docs/vrs/02-agent-spec/spec.md b/docs/vrs/02-agent-spec/spec.md index e68a216c..fd2bc658 100644 --- a/docs/vrs/02-agent-spec/spec.md +++ b/docs/vrs/02-agent-spec/spec.md @@ -227,10 +227,14 @@ replacement, or state migration. Positional `identity` remains the declaration key and address fallback; it is not immutable subject identity. Authoring: future canonical `id` plus the pinned legacy -[discovery and identity contract][evals-discovery]. Current st2 source: -[`AgentSpec::identity`](../../../crates/agent-spec/src/spec.rs). Evidence: -[reconciliation](../../../src/reconcile.rs). -This target remains fenced by +[discovery and identity contract][evals-discovery]. st2 source: +[`AgentSpec::{id, address, effective_id, effective_address}`](../../../crates/agent-spec/src/spec.rs), +[migration](../../../src/catalog_migrate_ids.rs), +[selection and the activation gate](../../../src/identity.rs). Evidence: +[reconciliation](../../../src/reconcile.rs), +[migration proof](../../../tests/catalog_migrate_ids.rs). +`id` is optional in the shipped grammar and required in the target one; the +remaining gap is recorded in [DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md).

F03 host

diff --git a/docs/vrs/05-harness-state/spec.md b/docs/vrs/05-harness-state/spec.md index 470f1a46..e10dca25 100644 --- a/docs/vrs/05-harness-state/spec.md +++ b/docs/vrs/05-harness-state/spec.md @@ -16,15 +16,18 @@ unmet residuals: root `DQ3`'s supervisor-following gate (`DQ-H5`) and Claude's eventless deny path (the remaining `DQ-H1` window). Open questions are tracked in [open-questions.md](./open-questions.md). -The `agent` field's immutable-ID meaning is the accepted target, and it ships -as a new record version, `st2.harness-state.v2`: this record's version suffix -is its read contract, so changing an existing field's meaning reserves the next -version rather than reusing `v1`. Records and producers stay on -`st2.harness-state.v1`, whose `agent` remains a bus identity, until -[DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md) closes. -Version 2 is otherwise identical to the shape below. The reader-first rollout -accepts both versions — narrowing the `unsupported-schema` rule below to -versions outside that pair — before any version-2 writer activates. +The `agent` field's immutable-ID meaning ships as a new record version, +`st2.harness-state.v2`: this record's version suffix is its read contract, so +changing an existing field's meaning reserves the next version rather than +reusing `v1`. Version 2 is otherwise identical to the shape below. +Readers accept both versions — the `unsupported-schema` rule below narrows to +versions outside that pair. A producer emits version 2 only once its catalog has +migrated every live and structurally archived subject to an explicit agent ID; +until then it emits `st2.harness-state.v1`, whose `agent` remains a bus +identity. Write-side ownership stays exact own-version equality in both +directions, so a writer never coalesces against, restamps, or supersedes a +record of the other version — `agent` means different things under each, and +restating one namespace's bytes under the other's promise is not a merge. ## Scope diff --git a/docs/vrs/08-harness-context/spec.md b/docs/vrs/08-harness-context/spec.md index 6b9d4d40..4ac81704 100644 --- a/docs/vrs/08-harness-context/spec.md +++ b/docs/vrs/08-harness-context/spec.md @@ -43,14 +43,16 @@ transport runs. Open questions are tracked in [open-questions.md](./open-questions.md); the direction this design deliberately does not take yet is in [roadmap.md](./roadmap.md). -The `agent` field's immutable-ID meaning is the accepted target, and it ships -as a new record version, `st2.harness-context.v2`: the version suffix is the -read contract, so changing an existing field's meaning reserves the next -version rather than reusing `v1`. Records and producers stay on -`st2.harness-context.v1`, whose `agent` remains a bus identity, until -[DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md) closes. -Version 2 is otherwise identical to the shape below, and the reader-first -rollout accepts both versions before any version-2 writer activates. +The `agent` field's immutable-ID meaning ships as a new record version, +`st2.harness-context.v2`: the version suffix is the read contract, so changing +an existing field's meaning reserves the next version rather than reusing `v1`. +Version 2 is otherwise identical to the shape below, and readers accept both +versions. A producer emits version 2 only once its catalog has migrated every +live and structurally archived subject to an explicit agent ID; until then it +emits `st2.harness-context.v1`, whose `agent` remains a bus identity. The write +guard coalesces only against a record of the writer's own version, so the two +sibling records can never be versioned apart and a v1 straggler cannot downgrade +a v2 record's numbers under a v1 reading of `agent`. ## Scope diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 22bd86cb..2cd233cc 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -62,9 +62,15 @@ legacy control in `tests/eval_run_e2e.rs`. ## Immutable agent ID, mutable address, and presentation (R02, R08, R11, R13, R19, R24-R26) -This section is the accepted target contract. The current implementation remains -on the pre-decision identity model until -[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) closes. +This section is the accepted target contract. It is implemented behind one +catalog-scoped activation gate: the target writers apply once every live and +structurally archived subject carries an explicit agent ID, and until then the +pre-decision identity model remains normative implementation behavior. A mixed +catalog is inadmissible (`agent-id-missing`), so the gate is all-or-nothing +rather than per-subject, and only the whole-catalog ID migration holds a mixed +state inside its own transaction. +[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) records what +remains before the gate is a permanent property rather than a transition. An Agent Spec separates four values that the current implementation overloads: diff --git a/src/agent_author.rs b/src/agent_author.rs index acbc95d9..d7921131 100644 --- a/src/agent_author.rs +++ b/src/agent_author.rs @@ -72,6 +72,32 @@ impl PresentationField { } } +/// One single-positional-string child node these source-preserving edits may rewrite. +/// +/// Address is not presentation — it is the mutable route (R24/R25), and it carries authority +/// presentation never has — but it is edited by exactly the same span-bounded machinery: find, +/// replace, insert, or remove one child node while every other byte of the declaration survives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeclaredField { + Presentation(PresentationField), + Address, +} + +impl DeclaredField { + fn as_str(self) -> &'static str { + match self { + Self::Presentation(field) => field.as_str(), + Self::Address => "address", + } + } +} + +impl From for DeclaredField { + fn from(field: PresentationField) -> Self { + Self::Presentation(field) + } +} + /// Whether a request changed declaration bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -90,6 +116,23 @@ pub struct PresentationReceipt { pub retired: bool, } +/// Stable machine-readable receipt from one agent-address cutover. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressReceipt { + pub result: AuthorOutcome, + /// The subject's immutable agent ID (R24) — the value an address cutover must not touch. + pub id: String, + /// The positional declaration key, also unchanged: it stays the legacy address fallback. + pub identity: String, + /// The declared `address` after the edit. `None` means the positional fallback is effective. + pub address: Option, + /// `.` after the cutover. `None` for a retired subject, which is + /// non-routable and released its address. + pub bus_address: Option, + pub retired: bool, +} + /// Stable authored desired-state selector. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -196,6 +239,9 @@ impl std::error::Error for AuthorError {} #[derive(Debug)] struct AgentTarget { identity: String, + /// The subject's immutable catalog-global agent ID (R24): the explicit `id`, else the legacy + /// `.` bus identity that migration freezes as this subject's ID. + agent_id: String, source_host: String, source_identity: String, declaration: PathBuf, @@ -663,7 +709,7 @@ pub fn set_presentation( &target.identity, &target.source_host, &target.source_identity, - field, + field.into(), requested.as_deref(), || {}, )?; @@ -676,6 +722,125 @@ pub fn set_presentation( }) } +/// Assign or clear one subject's mutable agent address — one atomic address-book cutover (R25). +/// +/// The old address stops resolving as soon as the new catalog generation is visible; st2 stores no +/// rename history, redirect, implicit alias, or time-bounded compatibility route, so a stale +/// caller fails loudly and refreshes the roster. The edit rewrites exactly the `address` child +/// node, which is what makes the cutover nondisruptive by construction: the declaration-parent +/// state anchor, ID-keyed supervisor edges, task IDs, launch fingerprints, workspace, inbox, +/// archive, context, Resource state, and runtime ownership are all keyed off values this edit +/// never touches. `None` restores the positional `identity` fallback and is admitted only while +/// that fallback address is itself still unique on the resolved host. +pub fn set_address( + catalog_root: &Path, + selector: &str, + this_host: &str, + actor: Option<&str>, + requested: Option<&str>, +) -> Result { + let catalog_lock = CatalogLock::exclusive(catalog_root).map_err(|error| { + AuthorError::new( + "catalog-lock-failed", + format!("acquire catalog-authoring lock: {error:#}"), + ) + })?; + let found = crate::discover(catalog_root); + if let Some(error) = found.errors.first() { + return Err(AuthorError::new( + "catalog-malformed", + format!( + "cannot prove an exact address target while {} is malformed: {}", + error.path.display(), + error.message + ), + )); + } + let target = resolve_target(&found.specs, selector, this_host)?; + authorize_actor( + &found.specs, + &target.identity, + this_host, + actor, + "address-not-authorized", + )?; + if let Some(value) = requested { + agent_spec::validate_agent_address(value) + .map_err(|error| AuthorError::new("invalid-address", error.to_string()))?; + } + refuse_address_collision(catalog_root, &found.specs, this_host, &target, requested)?; + let result = edit_declaration( + &catalog_lock, + catalog_root, + &crate::catalog_transaction::retained_dir_path(catalog_lock.control()) + .map_err(|error| AuthorError::new("declaration-write-failed", error.to_string()))?, + &target.declaration, + &target.identity, + &target.source_host, + &target.source_identity, + DeclaredField::Address, + requested, + || {}, + )?; + let effective = requested.unwrap_or(&target.source_identity); + Ok(AddressReceipt { + result, + // A retired subject is non-routable and released its address, so null is the honest bus + // address here — exactly what the roster projects for the same subject. + bus_address: (!target.retired).then(|| format!("{}.{effective}", target.source_host)), + address: requested.map(str::to_owned), + id: target.agent_id, + identity: target.identity, + retired: target.retired, + }) +} + +/// Refuse an effective address that would not be unique on the target's resolved logical host. +/// +/// The prospective catalog is the discovery this command already holds with exactly this subject's +/// `address` replaced, so `validate.rs`'s `dup-address` rule — the same rule whole-catalog +/// validation enforces — decides explicit/explicit and explicit/identity-fallback collisions +/// alike, including the `--clear` case where the restored fallback is the candidate address. Any +/// duplicate address in the prospective catalog refuses: an address book with two claims on one +/// route cannot answer an ordinary reference, so there is no cutover to admit. +fn refuse_address_collision( + catalog_root: &Path, + specs: &[crate::AgentSpec], + this_host: &str, + target: &AgentTarget, + requested: Option<&str>, +) -> Result<(), AuthorError> { + let mut prospective = crate::Discovered { + specs: specs.to_vec(), + ..Default::default() + }; + for spec in &mut prospective.specs { + if spec.bus_id(this_host) == target.identity { + spec.address = requested.map(str::to_owned); + } + } + let report = crate::validate::validate_discovered(catalog_root, Some(this_host), &prospective); + match report + .issues + .iter() + .find(|issue| issue.code == "dup-address") + { + None => Ok(()), + Some(issue) => Err(AuthorError::new( + "address-conflict", + format!( + "{} is not unique on host {:?}: {}", + requested.map_or_else( + || format!("identity fallback address {:?}", target.source_identity), + |value| format!("address {value:?}") + ), + target.source_host, + issue.message + ), + )), + } +} + fn resolve_target( specs: &[crate::AgentSpec], selector: &str, @@ -700,6 +865,7 @@ fn resolve_target( )), [spec] => Ok(AgentTarget { identity: spec.bus_id(this_host), + agent_id: spec.effective_id(this_host), source_host: spec.resolved_host(this_host).to_owned(), source_identity: spec.identity.clone(), declaration: spec.path.clone(), @@ -795,7 +961,7 @@ fn edit_declaration_for_test( expected_identity, expected_host, expected_agent, - field, + field.into(), requested, before_commit, ) @@ -1470,7 +1636,7 @@ fn edit_declaration( expected_identity: &str, expected_host: &str, expected_agent: &str, - field: PresentationField, + field: DeclaredField, requested: Option<&str>, before_commit: impl FnOnce(), ) -> Result { @@ -1524,6 +1690,11 @@ fn edit_declaration( ), )); } + // No span-bounded field edit may change the subject's immutable agent ID (R24). Address, name, + // and description are all mutable; `id` is the one value that identifies the subject across + // every one of those changes, so the candidate must read back with the exact same bytes — or + // with none, on a declaration ID migration has not reached yet. + let expected_id = declared_id(target); let Some(replacement) = presentation_edit(text, target, field, requested)? else { return Ok(AuthorOutcome::Unchanged); }; @@ -1534,6 +1705,7 @@ fn edit_declaration( expected_agent, field, requested, + expected_id.as_deref(), )?; atomic_replace_checked( catalog_lock, @@ -1793,6 +1965,17 @@ pub(crate) fn agent_identity_parts(node: &KdlNode) -> (Option, Option Option { + node.children()? + .nodes() + .iter() + .find(|child| child.name().value() == "id") + .and_then(|child| child.get(0)) + .and_then(|value| value.as_string()) + .map(str::to_owned) +} + pub(crate) fn is_nix_managed(node: &KdlNode) -> bool { node.children().is_some_and(|children| { children @@ -1809,7 +1992,7 @@ pub(crate) fn is_nix_managed(node: &KdlNode) -> bool { fn presentation_edit( text: &str, target: &KdlNode, - field: PresentationField, + field: DeclaredField, requested: Option<&str>, ) -> Result, AuthorError> { let fields = target @@ -1834,7 +2017,7 @@ fn presentation_edit( } } -fn parse_field_value(node: &KdlNode, field: PresentationField) -> Result<&str, AuthorError> { +fn parse_field_value(node: &KdlNode, field: DeclaredField) -> Result<&str, AuthorError> { if node.children().is_some() || node.entries().len() != 1 || node.entries()[0].name().is_some() { return Err(AuthorError::new( @@ -1867,7 +2050,7 @@ pub(crate) fn quoted(value: &str) -> Result { fn replace_field( text: &str, node: &KdlNode, - field: PresentationField, + field: DeclaredField, value: &str, ) -> Result, AuthorError> { if parse_field_value(node, field)? == value { @@ -1890,7 +2073,7 @@ fn replace_field( fn insert_field( text: &str, target: &KdlNode, - field: PresentationField, + field: DeclaredField, value: &str, ) -> Result { insert_node( @@ -2053,16 +2236,26 @@ fn verify_candidate( expected_identity: &str, expected_host: &str, expected_agent: &str, - field: PresentationField, + field: DeclaredField, expected: Option<&str>, + expected_id: Option<&str>, ) -> Result<(), AuthorError> { let document = KdlDocument::parse(candidate).map_err(|error| { AuthorError::new( "unsafe-source-edit", - format!("presentation edit did not produce valid KDL: {error}"), + format!("field edit did not produce valid KDL: {error}"), ) })?; let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + if declared_id(target).as_deref() != expected_id { + return Err(AuthorError::new( + "agent-id-immutable", + format!( + "edit would change the immutable agent id of {expected_identity:?}; `id` is the \ + one declared value no authoring command may rewrite" + ), + )); + } let fields = target .children() .into_iter() @@ -3164,4 +3357,56 @@ mod tests { AuthorOutcome::Unchanged ); } + + /// Direct ID mutation is the one thing no span-bounded field edit may do. `id` is what makes + /// a subject the same subject across an address, name, description, host, or graph change, so + /// the read-back gate compares it rather than trusting the edit that produced the candidate. + #[test] + fn no_field_edit_may_rewrite_the_immutable_agent_id() { + let tampered = + "agent \"worker\" { id \"b\"; host \"h\"; command \"true\"; name \"Owner\" }\n"; + let error = verify_candidate( + tampered, + "h.worker", + "h", + "worker", + DeclaredField::Presentation(PresentationField::Name), + Some("Owner"), + Some("a"), + ) + .unwrap_err(); + assert_eq!(error.code(), "agent-id-immutable"); + + // Dropping the ID entirely is the same refusal: an unmigrated declaration is not a place + // to park a subject whose ID the catalog already froze. + let dropped = "agent \"worker\" { host \"h\"; command \"true\"; address \"ops\" }\n"; + assert_eq!( + verify_candidate( + dropped, + "h.worker", + "h", + "worker", + DeclaredField::Address, + Some("ops"), + Some("a"), + ) + .unwrap_err() + .code(), + "agent-id-immutable" + ); + + // The identical edit with the ID carried through is admitted. + let honest = + "agent \"worker\" { id \"a\"; host \"h\"; command \"true\"; address \"ops\" }\n"; + verify_candidate( + honest, + "h.worker", + "h", + "worker", + DeclaredField::Address, + Some("ops"), + Some("a"), + ) + .unwrap(); + } } diff --git a/src/catalog_archive.rs b/src/catalog_archive.rs index 1f795f1f..1b1872b7 100644 --- a/src/catalog_archive.rs +++ b/src/catalog_archive.rs @@ -340,33 +340,54 @@ pub fn unarchive(request: UnarchiveRequest) -> Result { // even for an archived directory the control plane can no longer explain. let tombstone = read_tombstone(&tombstone_path).ok().flatten(); let archived_at = tombstone.as_ref().map(|tombstone| tombstone.archived_at); - if let Some(expected) = tombstone.as_ref().and_then(|it| it.agent_id.as_deref()) { - // A migrated tombstone froze this subject's immutable agent ID (R24). The declaration it - // covers must still carry the same one, or restoring it would re-enter the live plane - // under an identity the catalog never recorded for this subject. - let declaration = from.join("agent.kdl"); - let shown = relative(&catalog, &declaration) - .unwrap_or_else(|| declaration.display().to_string()); - let declared = agent_spec::discovery::parse_declared(&declaration).with_context(|| { - format!("read {shown} to check its agent ID against its tombstone's agentId") - })?; - let declared_id = match declared.as_slice() { - [single] => single.id.as_deref(), + // The archived declaration's own `id` is authoritative for both checks below, so read it once. + let declaration = from.join("agent.kdl"); + let shown = + relative(&catalog, &declaration).unwrap_or_else(|| declaration.display().to_string()); + let frozen = tombstone.as_ref().and_then(|it| it.agent_id.as_deref()); + let declared_id = match agent_spec::discovery::parse_declared(&declaration) { + Ok(declared) => match declared.as_slice() { + [single] => single.id.clone(), many => anyhow::bail!( "{shown} declares {} agents; a tombstoned identity must be one canonical declaration", many.len() ), - }; + }, + // A legacy archive need not even hold a canonical `agent.kdl`, and that stays exactly as + // tolerated as it was. Only a migrated tombstone makes the declaration's ID load-bearing; + // otherwise the missing ID simply fails the activation gate closed below. + Err(error) if frozen.is_some() => { + return Err(error).with_context(|| { + format!("read {shown} to check its agent ID against its tombstone's agentId") + }); + } + Err(_) => None, + }; + if let Some(expected) = frozen { + // A migrated tombstone froze this subject's immutable agent ID (R24). The declaration it + // covers must still carry the same one, or restoring it would re-enter the live plane + // under an identity the catalog never recorded for this subject. anyhow::ensure!( - declared_id == Some(expected), + declared_id.as_deref() == Some(expected), "tombstone records agent ID '{expected}' but {shown} declares {}; repair one of them before unarchiving", - declared_id.map_or_else(|| "no explicit id".to_owned(), |id| format!("'{id}'")) + declared_id + .as_deref() + .map_or_else(|| "no explicit id".to_owned(), |id| format!("'{id}'")) ); } - // DELTA-003: refusing an UNMIGRATED archive (a tombstone with no `agentId`) after ID migration - // has activated, and validating the restored ID's uniqueness against the prospective - // live-and-archived set, both land with the writer PR (D3). Until then a legacy tombstone - // restores exactly as it always did. + // DELTA-003: after activation an unmigrated archived declaration cannot re-enter the catalog, + // and the restored ID's uniqueness is decided against the prospective live-and-archived set + // rather than the live catalog alone. Before activation the legacy model stays normative and a + // legacy tombstone restores exactly as it always did. + if let Some(prospective) = prospective_identities(&catalog, &request.host, &request.identity) { + let restored = declared_id.as_deref().with_context(|| { + format!( + "{shown} carries no explicit agent `id` and this catalog's identity model is \ + active; run `st2 catalog migrate-ids` before restoring it" + ) + })?; + prospective.refuse_duplicate(&catalog, &request.host, restored)?; + } let parent = to.parent().context("live identity path has no parent")?; fs::create_dir_all(parent) @@ -398,6 +419,80 @@ pub fn unarchive(request: UnarchiveRequest) -> Result { }) } +/// The prospective live-and-archived subject index one restore is decided against. +struct ProspectiveIdentities { + /// Every live subject the restore would join. + specs: Vec, + /// Every archived subject except the one being restored. + others: Vec, +} + +/// The prospective index, or `None` when the target identity model is not active for this catalog. +/// +/// The subject being restored is excluded from the activation question on purpose: it is the one +/// under decision, and counting it would make "an unmigrated archive cannot re-enter an activated +/// catalog" vacuous — the unmigrated archive would always force the catalog to read Legacy. +/// +/// An undecidable catalog — incomplete discovery, or an archive with unexplained state — reads as +/// not activated. DELTA-003 keeps every current invariant normative until activation is proven, +/// so an unreadable plane must never invent a refusal that legacy st2 did not make. +fn prospective_identities( + catalog: &Path, + host: &str, + identity: &str, +) -> Option { + let found = crate::discover_strict(catalog); + if !found.errors.is_empty() { + return None; + } + let observation = observe(catalog).ok()?; + if !observation.issues.is_empty() { + return None; + } + let others = observation + .archived + .into_iter() + .filter(|tombstone| !(tombstone.host == host && tombstone.identity == identity)) + .collect::>(); + crate::identity::activation_from( + &found.specs, + &others, + crate::catalog_migrate_ids::marker_path(catalog).exists(), + ) + .is_activated() + .then_some(ProspectiveIdentities { + specs: found.specs, + others, + }) +} + +impl ProspectiveIdentities { + /// Refuse a restored agent ID that another live or archived subject already holds. + /// + /// An agent ID is catalog-global, so the live catalog alone is the wrong universe: an archived + /// subject keeps its frozen ID and may be restored next, and two subjects on one immutable ID + /// is exactly the state migration went to the trouble of ruling out. + fn refuse_duplicate(&self, catalog: &Path, host: &str, restored: &str) -> Result<()> { + for spec in &self.specs { + anyhow::ensure!( + spec.effective_id(host) != restored, + "agent id '{restored}' is already held by the live declaration {}; \ + unarchiving would put two subjects on one immutable id", + relative(catalog, &spec.path).unwrap_or_else(|| spec.path.display().to_string()) + ); + } + for tombstone in &self.others { + anyhow::ensure!( + tombstone.agent_id.as_deref() != Some(restored), + "agent id '{restored}' is already held by the archived subject {}.{}", + tombstone.host, + tombstone.identity + ); + } + Ok(()) + } +} + /// Read every tombstone under the archive root, reporting entries that explain nothing. /// /// The caller already holds a catalog-authoring fence. An archived directory with no readable diff --git a/src/catalog_migrate_ids.rs b/src/catalog_migrate_ids.rs index fc365be3..59133a3e 100644 --- a/src/catalog_migrate_ids.rs +++ b/src/catalog_migrate_ids.rs @@ -356,9 +356,16 @@ pub fn migrate_ids(request: MigrateRequest) -> Result { // a reason migration did not cause — leaving every declaration rewritten, the generation // unmoved, and a marker whose resume can only fail the same way. Refusing up front keeps the // pre-existing fault the operator's to repair, exactly as `catalog apply` does. - catalog_transaction::validate_full_catalog(&catalog).context( - "refusing to migrate: the catalog does not currently admit, so a rewritten plane could not be re-admitted either; repair the declarations named above and retry", - )?; + // + // A resume skips it. The original attempt already proved the pre-migration plane admitted, and + // what a resume observes is a half-migrated plane — a state admission deliberately refuses + // (`agent-id-missing`) and that only this transaction is allowed to hold. The post-write + // re-admission below still runs, against the completed all-migrated plane. + if existing_marker.is_none() { + catalog_transaction::validate_full_catalog(&catalog).context( + "refusing to migrate: the catalog does not currently admit, so a rewritten plane could not be re-admitted either; repair the declarations named above and retry", + )?; + } if existing_marker.is_none() { write_marker( diff --git a/src/claude_session.rs b/src/claude_session.rs index b87f4a2c..7c949bc1 100644 --- a/src/claude_session.rs +++ b/src/claude_session.rs @@ -41,7 +41,13 @@ pub fn run( crate::pretrust::pretrust_claude(std::slice::from_ref(&workspace)) .with_context(|| format!("admitting Claude driver workspace {}", workspace.display()))?; install_signal_handler(); - let observer = SessionObserver::new(&agent_dir, &identity, "claude", &runtime_id)?; + // One gate decision per driver process, from the catalog this wrapper was launched against. + let observer = SessionObserver::new( + &agent_dir, + harness_state::RecordIdentity::for_driver(catalog_root, &identity), + "claude", + &runtime_id, + )?; // The runtime ID reaches hook subprocesses through the provider environment, so their // transitions carry the same pty session the wrapper's records do. let env = [ @@ -171,7 +177,8 @@ pub fn run_observe( // events that carry a compaction edge say nothing about top-level harness state and would // otherwise return below. Fail-open: a context record that cannot be written must never stop // a hook the harness is waiting on, and the numbers authorize nothing (HC-A02). - if let Err(error) = observe_compaction(&agent_dir, identity, event, &payload) { + let actor = harness_state::RecordIdentity::for_driver(catalog_root, identity); + if let Err(error) = observe_compaction(&agent_dir, &actor, event, &payload) { tracing::warn!("st2 claude-observe: harness-context compaction write failed: {error:#}"); } // The credential axis is independent of both the numbers and the categorical state, and is @@ -185,7 +192,7 @@ pub fn run_observe( }; let mut writer = observe_writer( &agent_dir, - identity, + &actor, runtime_id, event, &payload, @@ -215,15 +222,15 @@ pub fn run_observe( #[allow(clippy::too_many_arguments)] fn observe_writer( agent_dir: &Path, - identity: &str, + actor: &harness_state::RecordIdentity, runtime_id: Option<&str>, event: &str, payload: &serde_json::Value, exported_session: Option, exported_seq: Option, ) -> harness_state::Writer { - let pty_session = runtime_id.unwrap_or(identity).to_string(); - let writer = harness_state::Writer::new(agent_dir, identity, "claude", Some(pty_session)); + let pty_session = runtime_id.unwrap_or_else(|| actor.agent()).to_string(); + let writer = harness_state::Writer::new(agent_dir, actor.clone(), "claude", Some(pty_session)); if let Some(session) = exported_session { return match exported_seq { Some(seq) => writer.with_ownership(session, seq), @@ -237,7 +244,12 @@ fn observe_writer( // sequence between the wrapper's read and its write. Ineligible (a live wrapper or // its fresh claim placeholder owns the record) or unwritable both degrade to // token-only. - return match harness_state::claim_wrapperless(agent_dir, identity, "claude", &token) { + return match harness_state::claim_wrapperless( + agent_dir, + actor.clone(), + "claude", + &token, + ) { Ok(Some(seq)) => writer.with_ownership(token, seq), Ok(None) => writer.with_session(token), Err(error) => { @@ -276,10 +288,10 @@ fn wrapperless_token(payload: &serde_json::Value) -> Option { /// share the selection without sharing the takeover. fn context_writer( agent_dir: &Path, - identity: &str, + actor: &harness_state::RecordIdentity, payload: &serde_json::Value, ) -> Result { - let writer = harness_context::Writer::new(agent_dir, identity, Harness::Claude)?; + let writer = harness_context::Writer::new(agent_dir, actor.clone(), Harness::Claude)?; let exported = std::env::var(SESSION_ENV).ok().filter(|t| !t.is_empty()); Ok(match exported.or_else(|| wrapperless_token(payload)) { Some(token) => writer.with_session(token), @@ -310,7 +322,7 @@ fn context_writer( /// the relaunch claim (HC-R15), so the count describes this incarnation and not the seat's life. fn observe_compaction( agent_dir: &Path, - identity: &str, + actor: &harness_state::RecordIdentity, event: &str, payload: &serde_json::Value, ) -> Result<()> { @@ -337,7 +349,7 @@ fn observe_compaction( } _ => return Ok(()), }; - context_writer(agent_dir, identity, payload)? + context_writer(agent_dir, actor, payload)? .compacted(edge) .map(|_landed| ()) } @@ -471,7 +483,8 @@ fn record_statusline(catalog_root: &Path, identity: &str, raw: &[u8]) -> Result< // call that provably cannot record is worse than none: it reads as instrumentation. // `06-observability`'s spec already scopes `hook_invocations_total` to `claude-observe` and // says other hook surfaces are not instrumented yet, which is exactly this. - context_writer(&agent_dir, identity, &payload)? + let actor = harness_state::RecordIdentity::for_driver(catalog_root, identity); + context_writer(&agent_dir, &actor, &payload)? .observe(statusline_reading(&payload)) .map(|_landed| ()) } @@ -1176,8 +1189,15 @@ mod tests { let payload_a = serde_json::json!({ "session_id": "aaa" }); let payload_b = serde_json::json!({ "session_id": "bbb" }); let drive = |event: &str, payload: &serde_json::Value| { - let mut writer = - observe_writer(tmp.path(), "hetz.worker", None, event, payload, None, None); + let mut writer = observe_writer( + tmp.path(), + &harness_state::RecordIdentity::legacy("hetz.worker"), + None, + event, + payload, + None, + None, + ); writer .observe_unless_ended(observe_hook_event(event, payload).unwrap()) .unwrap() @@ -1202,6 +1222,43 @@ mod tests { ); } + /// The Claude hook producer under an activated catalog: `run_observe` resolves its actor once + /// from the catalog and hands it to both writers, so the transition it records names the + /// immutable agent ID under version 2. The wrapperless fallback for `pty_session` follows the + /// same value, because the actor's bytes ARE the key the driver was launched with. + #[test] + fn an_activated_hook_writes_the_agent_id_under_version_2() { + use crate::harness_state; + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + let tmp = tempfile::tempdir().unwrap(); + let payload = serde_json::json!({ "session_id": "aaa" }); + let mut writer = observe_writer( + tmp.path(), + &harness_state::RecordIdentity::activated(AGENT_ID), + None, + "UserPromptSubmit", + &payload, + None, + None, + ); + assert!( + writer + .observe_unless_ended(observe_hook_event("UserPromptSubmit", &payload).unwrap()) + .unwrap() + ); + let bytes = + String::from_utf8(std::fs::read(harness_state_path(tmp.path())).unwrap()).unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-state.v2""#), + "{bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + assert!( + bytes.contains(&format!(r#""ptySession":"{AGENT_ID}""#)), + "{bytes}" + ); + } + /// The status-line payload captured verbatim from the version in the producer table, before /// the session's first API response. const PRE_TURN: &str = @@ -1290,13 +1347,19 @@ mod tests { harness_context::read(&harness_context::harness_context_path(dir)).unwrap() } + /// This seat's actor as an unmigrated catalog resolves it: bus identity under version 1, + /// which is what every producer here writes until the identity model is activated. + fn seat() -> crate::harness_state::RecordIdentity { + crate::harness_state::RecordIdentity::legacy("Silber.fabric") + } + #[test] fn a_pre_turn_reading_lands_once_and_then_sits_inside_its_bucket() { let tmp = tempfile::tempdir().unwrap(); let dir = agent_dir(&tmp); let payload = fixture(PRE_TURN); - let mut writer = context_writer(&dir, "Silber.fabric", &payload).unwrap(); + let mut writer = context_writer(&dir, &seat(), &payload).unwrap(); assert!(writer.observe(statusline_reading(&payload)).unwrap()); // A withheld percent has no bucket, so a second identical render is inside the written // one and, well inside the heartbeat, writes nothing. That is the write guard (HC-R09) @@ -1318,7 +1381,7 @@ mod tests { let mut payload = session.clone(); payload["trigger"] = "auto".into(); - observe_compaction(&dir, "Silber.fabric", "PreCompact", &payload).unwrap(); + observe_compaction(&dir, &seat(), "PreCompact", &payload).unwrap(); let counted = context(&dir); assert_eq!(counted.compactions, 1); assert_eq!( @@ -1327,7 +1390,7 @@ mod tests { ); // The completion edge holds the count and only moves `lastCompactionMs` forward. - observe_compaction(&dir, "Silber.fabric", "PostCompact", &payload).unwrap(); + observe_compaction(&dir, &seat(), "PostCompact", &payload).unwrap(); let completed = context(&dir); assert_eq!( completed.compactions, 1, @@ -1339,7 +1402,7 @@ mod tests { // and deliberately inert — counting it is exactly the double count HC-R12 forbids. let mut restart = session; restart["source"] = "compact".into(); - observe_compaction(&dir, "Silber.fabric", "SessionStart", &restart).unwrap(); + observe_compaction(&dir, &seat(), "SessionStart", &restart).unwrap(); assert_eq!(context(&dir).compactions, 1); } @@ -1351,7 +1414,7 @@ mod tests { // No record at all: the PreCompact write never landed, so PostCompact is the first // evidence st2 has that a compaction happened and it counts rather than losing it. - observe_compaction(&dir, "Silber.fabric", "PostCompact", &payload).unwrap(); + observe_compaction(&dir, &seat(), "PostCompact", &payload).unwrap(); let observed = context(&dir); assert_eq!(observed.compactions, 1); assert_eq!( @@ -1368,7 +1431,7 @@ mod tests { "session_id": "s-1", "trigger": "auto", "agent_id": "sub-7" }); - observe_compaction(&dir, "Silber.fabric", "PreCompact", &payload).unwrap(); + observe_compaction(&dir, &seat(), "PreCompact", &payload).unwrap(); assert!(harness_context::read(&harness_context::harness_context_path(&dir)).is_none()); } @@ -1407,7 +1470,7 @@ mod tests { wrapperless_token(&payload).as_deref(), Some("claude-session-abc") ); - context_writer(&dir, "Silber.fabric", &payload) + context_writer(&dir, &seat(), &payload) .unwrap() .observe(statusline_reading(&payload)) .unwrap(); diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 73b69b43..e3a7d8e0 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -379,7 +379,10 @@ struct CodexDeliveryConfig { catalog_root: PathBuf, agent_dir: PathBuf, inbox: PathBuf, - identity: String, + /// The agent key this wrapper was launched with, paired with the record version it means — + /// one gate decision taken at driver start, so no record write consults the catalog. Its + /// bytes are the launch key either way, so every non-record use reads [`RecordIdentity::agent`]. + actor: harness_state::RecordIdentity, this_host: String, supervisor: Option, /// The codex-cli version the protocol gate admitted, carried for the native-driver @@ -406,7 +409,7 @@ impl CodexDeliveryConfig { catalog_root: catalog_root.to_path_buf(), inbox: message::inbox_dir(&agent_dir), agent_dir, - identity: identity.to_string(), + actor: harness_state::RecordIdentity::for_driver(catalog_root, identity), this_host, supervisor, producer_version: None, @@ -417,14 +420,14 @@ impl CodexDeliveryConfig { let Some(supervisor) = self.supervisor.as_deref() else { eprintln!( "st2 codex: agent '{}' has no supervisor for a protocol rejection report", - self.identity + self.actor.agent() ); return; }; - let subject = format!("Codex protocol rejected: {}", self.identity); + let subject = format!("Codex protocol rejected: {}", self.actor.agent()); let body = format!( "st2 rejected the installed Codex app-server protocol for agent '{}'. Native delivery did not start. Codex executable: '{}'. Error: {error:#}", - self.identity, codex + self.actor.agent(), codex ); let mut key_hash = Sha256::new(); key_hash.update(b"st2.codex-protocol-rejection.v1"); @@ -435,7 +438,7 @@ impl CodexDeliveryConfig { &self.catalog_root, supervisor, &self.this_host, - &self.identity, + self.actor.agent(), Some(&subject), None, &tags, @@ -445,7 +448,7 @@ impl CodexDeliveryConfig { ) { eprintln!( "st2 codex: failed to report agent '{}' protocol rejection to supervisor '{}': {report_error:#}", - self.identity, supervisor + self.actor.agent(), supervisor ); } } @@ -844,7 +847,7 @@ impl CodexInboxDelivery { // Scoped to inbox + status: this pump's own process group writes runtime records (presence // refreshes, harness-state transitions) into the same agent dir, and those must not wake it. let watcher = crate::watch::watch_delivery_inputs(&config.agent_dir, wake_tx); - let state = load_delivery_state(&state_path, &config.identity, runtime.runtime_id())?; + let state = load_delivery_state(&state_path, config.actor.agent(), runtime.runtime_id())?; // The pty session whose liveness vouches for the record is the wrapper's task: the // runtime ID names the pty registry entry, and only aliases the identity on // driver-expanded seats — a hand-authored seat may declare a different task ID. @@ -858,13 +861,13 @@ impl CodexInboxDelivery { let harness_writer = { let writer = harness_state::Writer::new( &config.agent_dir, - config.identity.clone(), + config.actor.clone(), "codex", Some(runtime.runtime_id().to_string()), ); match harness_state::claim( &config.agent_dir, - config.identity.clone(), + config.actor.clone(), "codex", runtime.incarnation(), ) { @@ -884,7 +887,7 @@ impl CodexInboxDelivery { // already says — rather than a live state that is not live. let context = match harness_context::Writer::new( &config.agent_dir, - config.identity.clone(), + config.actor.clone(), harness_context::Harness::Codex, ) { Ok(writer) => Some(CodexContextProducer::new( @@ -1095,13 +1098,16 @@ impl CodexInboxDelivery { .next_request_id .checked_add(1) .context("Codex delivery request ID overflow")?; - let client_id = - stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename); + let client_id = stable_client_user_message_id( + self.config.actor.agent(), + state.thread_id(), + &head.filename, + ); let filename = head.filename.clone(); let text = ding::poke_text( &self.config.catalog_root, &self.config.this_host, - &self.config.identity, + self.config.actor.agent(), head, ); let request = @@ -1913,7 +1919,7 @@ fn run_connected( // The stop handler is installed by run_controlled_owned before any spawn (the preflight's // detached app-server included); re-installing here would RESET a stop flag raised during // startup, so this function only relies on it. - let state_dir = state_dir(&delivery.catalog_root, &delivery.identity); + let state_dir = state_dir(&delivery.catalog_root, delivery.actor.agent()); let endpoint = format!("unix://{}", socket_path.display()); let tui_args = controlled_tui_args(&endpoint, &codex_argv[1..], resume_thread)?; let expected_resume = @@ -1957,7 +1963,7 @@ fn run_connected( (None, None) }; let harness_agent_dir = delivery.agent_dir.clone(); - let harness_identity = delivery.identity.clone(); + let harness_actor = delivery.actor.clone(); let event_thread = thread::spawn(move || { let resume = expected_resume .as_deref() @@ -2002,7 +2008,7 @@ fn run_connected( // sequence, since the claim put this token on disk. let mut writer = harness_state::Writer::new( &harness_agent_dir, - harness_identity.clone(), + harness_actor.clone(), "codex", Some(runtime.runtime_id().to_string()), ) @@ -2053,7 +2059,7 @@ fn run_connected( // claimed sequence — and the terminal record fences exactly the records this session wrote. let mut harness_writer = harness_state::Writer::new( &harness_agent_dir, - harness_identity.clone(), + harness_actor.clone(), "codex", Some(runtime.runtime_id().to_string()), ) @@ -4819,6 +4825,37 @@ mod tests { ); } + /// The Codex wrapper's one identity decision: taken when its delivery config resolves, from + /// the catalog it was launched against, and carried into every record it writes. A migrated + /// live subject's ID is frozen at its former bus identity, so the launch key is unchanged and + /// only the meaning — and with it the record version — moves. + #[test] + fn the_delivery_config_resolves_its_actor_through_the_gate() { + let host = run::detect_host(); + let declare = |catalog: &Path, body: &str| { + let path = catalog.join("agents").join(&host).join("worker/agent.kdl"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + format!("agent \"worker\" {{ host \"{host}\"; command \"true\"; {body} }}\n"), + ) + .unwrap(); + }; + let key = format!("{host}.worker"); + + let migrated = tempfile::tempdir().unwrap(); + declare(migrated.path(), &format!("id \"{key}\"")); + let activated = CodexDeliveryConfig::resolve(migrated.path(), &key).unwrap(); + assert!(activated.actor.is_activated()); + assert_eq!(activated.actor.agent(), key); + + let unmigrated = tempfile::tempdir().unwrap(); + declare(unmigrated.path(), ""); + let legacy = CodexDeliveryConfig::resolve(unmigrated.path(), &key).unwrap(); + assert!(!legacy.actor.is_activated()); + assert_eq!(legacy.actor.agent(), key); + } + #[test] fn protocol_rejection_reaches_the_declared_supervisor_once() { let tmp = tempfile::tempdir().unwrap(); @@ -5300,7 +5337,7 @@ mod tests { catalog_root: root.to_path_buf(), inbox: message::inbox_dir(&agent_dir), agent_dir, - identity: "h.worker".into(), + actor: harness_state::RecordIdentity::legacy("h.worker"), this_host: "h".into(), supervisor: None, producer_version: Some("codex-cli 0.153.0".into()), diff --git a/src/ding/mod.rs b/src/ding/mod.rs index fa055d26..1cf3a437 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -100,29 +100,106 @@ fn normalize_field(value: Option<&str>, fallback: &str, max_chars: usize) -> Str struct RelationshipResolver { specs: Vec, valid: bool, + /// The migration's durable collision metadata, so a version-1 sender endpoint is attributed + /// rather than universally retyped (`MESSAGE-R04`). + migration: Option, } impl RelationshipResolver { fn read(catalog_root: &Path) -> Self { let discovered = crate::discover_strict(catalog_root); + // An unreadable or foreign-schema migration record proves nothing about which subject kept + // which legacy bytes, so it invalidates the resolver rather than reading as "nothing + // collided" — that reading is exactly what would address a live replacement. + let migration = crate::catalog_migrate_ids::read_migration_record(catalog_root); Self { specs: discovered.specs, - valid: discovered.errors.is_empty(), + valid: discovered.errors.is_empty() && migration.is_ok(), + migration: migration.ok().flatten(), } } + + fn by_id(&self, this_host: &str, id: &str) -> Option<&crate::AgentSpec> { + self.specs + .iter() + .find(|spec| spec.effective_id(this_host) == id) + } +} + +/// The sender of one message as a reader must see it: the current bus address for humans, the +/// immutable agent ID as the only authority (`docs/vrs/03-message/spec.md`). +struct ProjectedSender<'a> { + /// What the DING line shows. `None` when the message claims no sender at all. + display: Option, + /// The declaration this sender provably is. `None` when the endpoint carries no authority: an + /// unattributable historical address, or a subject that no longer exists. + spec: Option<&'a crate::AgentSpec>, +} + +fn project_sender<'a>( + resolver: &'a RelationshipResolver, + this_host: &str, + recipient: &str, + msg: &Message, +) -> ProjectedSender<'a> { + let recorded = msg.from.as_deref(); + // A version-2 rendered message states its sender's immutable ID, and `from` is only the + // publication-time address snapshot: the current address wins for display, and the snapshot is + // the cosmetic fallback for a subject that no longer has a resolvable address. + if let Some(id) = msg.from_id.as_deref() { + let spec = resolver.by_id(this_host, id); + let display = spec + .map(|spec| spec.bus_address(this_host)) + .or_else(|| recorded.map(str::to_owned)) + .unwrap_or_else(|| id.to_owned()); + return ProjectedSender { + display: Some(display), + spec, + }; + } + let Some(recorded) = recorded else { + return ProjectedSender { + display: None, + spec: None, + }; + }; + // A version-1 message carries legacy bytes. This is an inbox row, so its state owner is the + // recipient reading it. + match message::attribute_endpoint(resolver.migration.as_ref(), recorded, Some(recipient)) { + // Untouched bytes are both the subject's frozen ID and its address. + message::LegacyAttribution::Frozen { .. } => ProjectedSender { + display: Some(recorded.to_owned()), + spec: resolve_spec(&resolver.specs, recorded, this_host), + }, + message::LegacyAttribution::Owned { id } => { + let spec = resolver.by_id(this_host, &id); + ProjectedSender { + display: Some( + spec.map(|spec| spec.bus_address(this_host)) + .unwrap_or_else(|| recorded.to_owned()), + ), + spec, + } + } + // Two subjects claimed these bytes and this endpoint is not the row's state owner: show the + // historical address, and grant it no authority at all. + message::LegacyAttribution::Historical { address } => ProjectedSender { + display: Some(address), + spec: None, + }, + } } fn relationship_marker( resolver: &RelationshipResolver, this_host: &str, recipient: &str, - claimed_sender: Option<&str>, + sender: Option<&crate::AgentSpec>, ) -> String { if !resolver.valid { return "?".to_string(); } - let Some(sender) = claimed_sender.and_then(|id| resolve_spec(&resolver.specs, id, this_host)) - else { + let Some(sender) = sender else { return "?".to_string(); }; let Some(recipient) = resolve_spec(&resolver.specs, recipient, this_host) else { @@ -180,11 +257,12 @@ fn poke_text_with_resolver( msg: &Message, ) -> String { let subject = normalize_field(msg.subject.as_deref(), "(no subject)", SUBJECT_MAX_CHARS); - let from = normalize_field(msg.from.as_deref(), "unknown", SENDER_MAX_CHARS); + let projected = project_sender(resolver, this_host, recipient, msg); + let from = normalize_field(projected.display.as_deref(), "unknown", SENDER_MAX_CHARS); let marker = if msg.stream.is_some() && msg.event_id.is_some() { SOURCE_MARKER.to_string() } else { - relationship_marker(resolver, this_host, recipient, msg.from.as_deref()) + relationship_marker(resolver, this_host, recipient, projected.spec) }; format!( "[DING] {marker} {from}: {subject} [id:{}]", @@ -1290,6 +1368,7 @@ mod tests { .and_then(|(timestamp, _)| timestamp.parse().ok()) .unwrap_or_default(), from: Some(from.to_string()), + from_id: None, subject: subject.map(str::to_string), in_reply_to: None, tags: vec![], @@ -1533,6 +1612,86 @@ mod tests { ); } + /// A version-2 message states its sender's immutable ID, so the line a human reads carries the + /// sender's *current* bus address even after a cutover, while the recorded `from` snapshot is + /// only the cosmetic fallback for a subject that no longer resolves. + #[test] + fn ding_projects_the_current_sender_address_with_the_id_as_authority() { + let catalog = tempfile::tempdir().unwrap(); + let directory = catalog.path().join("h").join("root"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("agent.kdl"), + "agent \"root\" {\n identity \"root\"\n host \"h\"\n id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\"\n address \"chat-v2\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", + ) + .unwrap(); + declare_agent(catalog.path(), "h", "recipient", Some("root")); + + let published = Message { + from: Some("h.chat".to_string()), + from_id: Some("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".to_string()), + ..msg("1785070000000-abc123.md", "h.chat", Some("cutover")) + }; + assert_eq!( + poke_text(catalog.path(), "h", "h.recipient", &published), + "[DING] ↓ h.chat-v2: cutover [id:abc123]", + "the current address is shown, and the ID still proves the supervision relationship" + ); + + // Cosmetic fallback: the ID names no live subject, so the publication-time snapshot is + // shown as written and the endpoint carries no relationship authority. + let gone = Message { + from_id: Some("0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3".to_string()), + ..published + }; + assert_eq!( + poke_text(catalog.path(), "h", "h.recipient", &gone), + "[DING] ? h.chat: cutover [id:abc123]" + ); + } + + /// A version-1 sender endpoint that catalog ID migration reassigned denotes two subjects, and + /// an inbox row's state owner is its recipient — never its sender. The bytes are therefore + /// rendered as a historical address with no authority, rather than as the live subject that + /// kept them (`MESSAGE-R04`). + #[test] + fn ding_refuses_authority_for_an_unattributable_legacy_sender() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "root", None); + declare_agent(catalog.path(), "h", "recipient", Some("root")); + let control = catalog.path().join(".st2"); + std::fs::create_dir_all(&control).unwrap(); + std::fs::write( + control.join("agent-id-migration.json"), + serde_json::to_vec(&crate::catalog_migrate_ids::MigrationRecord { + schema: crate::catalog_migrate_ids::MIGRATION_RECORD_SCHEMA.to_owned(), + migrated_at_ms: 1, + reassigned: vec![crate::catalog_migrate_ids::Reassignment { + legacy_bus_identity: "h.root".to_owned(), + kept_by_agent_id: "h.root".to_owned(), + kept_by_plane: crate::catalog_migrate_ids::Plane::Live, + reassigned_agent_id: "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3".to_owned(), + reassigned_host: "h".to_owned(), + reassigned_identity: "root".to_owned(), + reassigned_plane: crate::catalog_migrate_ids::Plane::Archived, + }], + }) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + poke_text( + catalog.path(), + "h", + "h.recipient", + &msg("1785070000000-abc123.md", "h.root", Some("colliding")) + ), + "[DING] ? h.root: colliding [id:abc123]", + "a reassigned legacy sender must not project the keeping subject's supervision marker" + ); + } + #[test] fn self_addressed_message_uses_identity_marker() { let catalog = tempfile::tempdir().unwrap(); diff --git a/src/eval_run.rs b/src/eval_run.rs index 48626a5b..8ae005ae 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -434,9 +434,10 @@ fn boot_team_with_runner( host: &str, runner: &dyn Runner, ) -> Result { - crate::reconcile::validate_task_identities(agent_specs, host)?; + let activation = crate::run::spec_team_activation(agent_specs); + crate::reconcile::validate_task_identities(agent_specs, host, &activation)?; let sessions = runner.list_sessions().context("listing pty sessions")?; - let plan = reconcile(agent_specs, &sessions, host)?; + let plan = reconcile(agent_specs, &sessions, host, &activation)?; let mut report = UpReport::default(); let mut cap = FlappingCap::default(); execute(&plan, runner, &mut cap, &mut report); @@ -448,7 +449,11 @@ fn supervised_eval_sessions( host: &str, runner: &dyn Runner, ) -> Result> { - crate::reconcile::validate_task_identities(agent_specs, host)?; + crate::reconcile::validate_task_identities( + agent_specs, + host, + &crate::run::spec_team_activation(agent_specs), + )?; runner .list_sessions() .context("listing supervised eval sessions") @@ -762,7 +767,7 @@ fn teardown_team_with_runner(specs: &[AgentSpec], host: &str, runner: &dyn Runne }) .collect(); if let Ok(sessions) = runner.list_sessions() { - let plan = reconcile(&retired, &sessions, host) + let plan = reconcile(&retired, &sessions, host, &crate::run::spec_team_activation(specs)) .expect("retired specs are excluded from task identity admission"); let mut report = UpReport::default(); let mut cap = FlappingCap::default(); @@ -2320,7 +2325,8 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" } Some("st2 ding --identity evalhost.mix.sup --root $ST_ROOT") ); - let plan = reconcile(&specs, &[], "evalhost").unwrap(); + let plan = reconcile(&specs, &[], "evalhost", &crate::run::spec_team_activation(&specs)) + .unwrap(); let targets = plan .launch .iter() @@ -2346,6 +2352,65 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" } ); } + /// DELTA-003 (step 5): an in-memory spec team declares no `id` on any member, so its identity + /// gate must answer legacy and every runner-owned task id must keep exactly the bytes it had + /// before the writers landed. The expected ids are written out literally on purpose — deriving + /// them through `agent_key`/`default_task_id` would let the helpers under test define their own + /// answer. + #[test] + fn a_spec_team_without_ids_stays_legacy_and_keeps_its_task_ids() { + let spec = parse_spec( + r#" + team "t" { + agent "a" { command "sleep 100000"; ding } + agent "b" { command "sleep 100000" } + } + "#, + ) + .unwrap(); + let specs = spec_to_agent_specs(&spec.agents, "evalhost", Path::new("/tmp/eval-root")); + assert!(specs.iter().all(|spec| spec.id.is_none())); + let activation = crate::run::spec_team_activation(&specs); + assert!(matches!( + activation, + crate::identity::IdentityActivation::Legacy(_) + )); + + let resolved = specs + .iter() + .flat_map(|spec| { + spec.tasks.iter().map(|task| { + crate::reconcile::default_task_id(spec, task, "evalhost", &activation) + }) + }) + .collect::>(); + assert_eq!( + resolved, + vec![ + "evalhost.t.a".to_owned(), + "evalhost.t.a.ding".to_owned(), + "evalhost.t.b".to_owned(), + ] + ); + + let mut spawned = reconcile(&specs, &[], "evalhost", &activation) + .unwrap() + .launch + .iter() + .flat_map(|launch| launch.tasks.iter()) + .map(|target| target.pty_id.clone()) + .collect::>(); + spawned.sort(); + assert_eq!( + spawned, + vec![ + "evalhost.t.a".to_owned(), + "evalhost.t.a.ding".to_owned(), + "evalhost.t.b".to_owned(), + ] + ); + } + #[test] fn supervised_exit_markers_share_the_projected_runtime_identity() { let parsed = parse_spec(r#"agent "worker" { command "exit 0" }"#).unwrap(); diff --git a/src/event.rs b/src/event.rs index 035b90cf..de1393af 100644 --- a/src/event.rs +++ b/src/event.rs @@ -91,7 +91,12 @@ pub enum EventReceiptStatus { } struct ResolvedStream { + /// The agent key this publication is owned by and persisted under: the immutable agent ID + /// once the identity model is active, else today's legacy bus identity. recipient: String, + /// How to reach that subject's own directories again — an exact ID under activation, so a + /// UUIDv7-created subject is reachable at all, and today's address reference otherwise. + selector: crate::identity::AgentSelector, } enum StreamAdmission { @@ -299,13 +304,26 @@ pub(crate) fn refusal_kind(error: &anyhow::Error) -> Option { .map(|refusal| refusal.kind) } +/// Resolve one publication's recipient. +/// +/// `recipient` states which namespace the caller named the subject in, and that statement is only +/// consulted once the identity model is active: resync names its recipient by the catalog-global +/// agent ID reconciliation gave it, while `st2 event emit` names an ordinary bus address. Under +/// `IdentityActivation::Legacy` both spellings collapse onto today's single precedence rule and +/// the raw bytes the caller passed, so nothing about a partially migrated catalog changes. fn resolve_stream( root: &Path, this_host: &str, - recipient: &str, + recipient: &crate::identity::AgentSelector, stream: &str, admission: StreamAdmission, ) -> anyhow::Result { + use crate::identity::AgentSelector; + + let reference = match recipient { + AgentSelector::Id(id) => id.as_str(), + AgentSelector::Address(address) => address.as_str(), + }; let discovered = crate::discover_strict(root); anyhow::ensure!( discovered.errors.is_empty(), @@ -317,24 +335,39 @@ fn resolve_stream( .collect::>() .join("; ") ); + // Decided once per publication from the discovery this function already performed. A + // partially migrated catalog has no coherent ID namespace, so it keeps today's recipient + // precedence, today's `canonical_recipient` bytes, and every current refusal normative. + let activated = stream_identity_activated(root, &discovered.specs); let mut matches = discovered .specs .into_iter() - .filter(|spec| { - spec.bus_id(this_host) == recipient - || (spec.resolved_host(this_host) == this_host && spec.identity == recipient) + .filter(|spec| match recipient { + // An exact ID and nothing else: an address must never answer for an ID (`R24`). + AgentSelector::Id(id) if activated => spec.effective_id(this_host) == *id, + // Ordinary address resolution: the host-qualified spelling, or the bare address when + // this host owns the subject. + AgentSelector::Address(address) if activated => { + spec.bus_address(this_host) == *address + || (spec.resolved_host(this_host) == this_host + && spec.effective_address() == *address) + } + _ => { + spec.bus_id(this_host) == reference + || (spec.resolved_host(this_host) == this_host && spec.identity == reference) + } }) .collect::>(); anyhow::ensure!( !matches.is_empty(), - "no agent '{recipient}' found in catalog {}", + "no agent '{reference}' found in catalog {}", root.display() ); if matches.len() > 1 { return Err(StreamRefusal::new( RefusalKind::Permanent, format!( - "agent recipient '{recipient}' is ambiguous; matched {} declarations: {}", + "agent recipient '{reference}' is ambiguous; matched {} declarations: {}", matches.len(), matches .iter() @@ -347,12 +380,17 @@ fn resolve_stream( let spec = matches .pop() .context("exactly one matching agent expected")?; + // Under Legacy this is exactly `bus_id`, so every diagnostic below keeps its current bytes. + let key = if activated { + spec.effective_id(this_host) + } else { + spec.bus_id(this_host) + }; if spec.resolved_host(this_host) != this_host { return Err(StreamRefusal::new( RefusalKind::Permanent, format!( - "agent '{}' is owned by host '{}'; event publication must run on that host", - spec.bus_id(this_host), + "agent '{key}' is owned by host '{}'; event publication must run on that host", spec.resolved_host(this_host) ), )); @@ -362,10 +400,7 @@ fn resolve_stream( if !spec.streams.iter().any(|declared| declared.name == stream) { return Err(StreamRefusal::new( RefusalKind::Permanent, - format!( - "agent '{}' does not declare stream '{stream}'", - spec.bus_id(this_host) - ), + format!("agent '{key}' does not declare stream '{stream}'"), )); } } @@ -378,17 +413,42 @@ fn resolve_stream( return Err(StreamRefusal::new( RefusalKind::RecipientNotRunning, format!( - "agent '{}' is {}; refusing event while its eyes are closed", - spec.bus_id(this_host), + "agent '{key}' is {}; refusing event while its eyes are closed", spec.desired_state.as_str() ), )); } + let selector = if activated { + crate::identity::AgentSelector::Id(key.clone()) + } else { + crate::identity::AgentSelector::Address(key.clone()) + }; Ok(ResolvedStream { - recipient: spec.bus_id(this_host), + recipient: key, + selector, }) } +/// The identity gate for one publication, reusing the caller's already-discovered live catalog. +/// +/// An unreadable or unexplained structural archive is not a migrated catalog: an unmigrated +/// archived subject could still re-enter this catalog, so activation stays off rather than +/// guessing. Everything here is fail-safe toward today's normative behavior. +fn stream_identity_activated(root: &Path, specs: &[crate::AgentSpec]) -> bool { + let Ok(observation) = crate::catalog_archive::observe(root) else { + return false; + }; + if !observation.issues.is_empty() { + return false; + } + crate::identity::activation_from( + specs, + &observation.archived, + crate::catalog_migrate_ids::marker_path(root).exists(), + ) + .is_activated() +} + pub fn render_event( from: &str, subject: Option<&str>, @@ -415,6 +475,7 @@ pub fn render_event( rendered } +/// Publish an event to a recipient named by an ordinary bus address — the CLI surface. #[allow(clippy::too_many_arguments)] pub fn emit( root: &Path, @@ -430,7 +491,7 @@ pub fn emit( emit_admitted( root, this_host, - recipient, + &crate::identity::AgentSelector::Address(recipient.to_owned()), stream, event_id, key, @@ -441,6 +502,9 @@ pub fn emit( ) } +/// Publish a built-in resync event to a recipient named by its agent key: reconciliation hands +/// resync the catalog-global immutable agent ID once the identity model is active, and today's +/// legacy bus identity while it is not. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_builtin_resync( root: &Path, @@ -455,7 +519,7 @@ pub(crate) fn emit_builtin_resync( emit_admitted( root, this_host, - recipient, + &crate::identity::AgentSelector::Id(recipient.to_owned()), crate::resync::RESYNC_STREAM, event_id, key, @@ -470,7 +534,7 @@ pub(crate) fn emit_builtin_resync( fn emit_admitted( root: &Path, this_host: &str, - recipient: &str, + recipient: &crate::identity::AgentSelector, stream: &str, event_id: &str, key: Option<&str>, @@ -491,13 +555,15 @@ fn emit_admitted( // suspension edit owns this lock, no later emit can publish from a stale running observation. let catalog_lock = crate::catalog_lock::CatalogLock::shared(root)?; validate_owner_binding(root, this_host, &catalog_lock)?; - let resolved = resolve_stream(root, this_host, recipient, stream, admission)?; - let canonical_recipient = resolved.recipient; + let ResolvedStream { + recipient: canonical_recipient, + selector, + } = resolve_stream(root, this_host, recipient, stream, admission)?; let from = format!("{canonical_recipient}/{stream}"); let rendered = render_event(&from, subject, stream, event_id, key, body); - message::with_resolved_state_dir( + message::with_selected_state_dir( root, - &canonical_recipient, + &selector, this_host, &["resources", "streams", stream], true, @@ -507,11 +573,19 @@ fn emit_admitted( let mut record = read_record(&record_path)? .unwrap_or_else(|| StreamRecord::fresh(stream, &canonical_recipient)); anyhow::ensure!( - record.version == EVENT_VERSION - && record.stream == stream - && record.recipient == canonical_recipient, - "stream state for '{}#{stream}' is not readable at version {EVENT_VERSION}", - canonical_recipient + record.version == EVENT_VERSION && record.stream == stream, + "stream state for '{canonical_recipient}#{stream}' is not readable at version {EVENT_VERSION}" + ); + // The persisted key is durable state written under whichever identity model was active + // at the time. Activation cannot move it for a subject that existed before: migration + // freezes a live subject's ID to its former bus identity, so these bytes are equal by + // construction. A mismatch therefore means the record belongs to a different subject + // than the one just resolved, and says so instead of blaming the record version. + anyhow::ensure!( + record.recipient == canonical_recipient, + "stream state at {} is owned by '{}', not by the resolved recipient '{canonical_recipient}'", + record_path.display(), + record.recipient ); if record @@ -523,9 +597,9 @@ fn emit_admitted( .pending .take() .expect("different pending event was just observed"); - let materialized = message::with_resolved_message_boxes( + let materialized = message::with_selected_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { let inbox_bytes = read_message_entry(inbox, &pending.filename)?; @@ -606,9 +680,9 @@ fn emit_admitted( ), None => { let predecessor = if supersede { - message::with_resolved_message_boxes( + message::with_selected_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { for entry in record.recent.iter().filter(|entry| { @@ -642,9 +716,9 @@ fn emit_admitted( test_event_checkpoint(event_id, "pending")?; } - let created = message::with_resolved_message_boxes( + let created = message::with_selected_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { // Publish before compacting. If predecessor archival fails or the process @@ -1191,3 +1265,161 @@ impl Drop for StreamLock { unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) }; } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + /// A subject created after activation: its ID is a UUIDv7 that is in no address namespace. + const WORKER_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + + fn declare_worker(root: &Path, extra: &str) -> PathBuf { + let directory = root.join("agents/hetz/worker"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("agent.kdl"), + format!( + "agent \"worker\" {{\n host \"hetz\"\n{extra} desired-state \"running\"\n command \"agent\"\n}}\n" + ), + ) + .unwrap(); + publish_owner_binding_for_test(root, "hetz").unwrap(); + directory + } + + fn stream_state(agent: &Path, stream: &str) -> StreamRecord { + let path = agent + .join("resources/streams") + .join(stream) + .join("state.json"); + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + /// Under activation the recipient is named by its immutable agent ID. A UUIDv7-created subject + /// has no address spelling of that ID at all, so ID resolution is the only thing that can reach + /// it: resolving the same bytes as an address would refuse the publication as unknown, and its + /// former bus identity is no longer a recipient name. + #[test] + fn an_activated_recipient_is_reached_and_persisted_by_its_immutable_id() { + let root = tempfile::tempdir().unwrap(); + let agent = declare_worker(root.path(), &format!(" id \"{WORKER_ID}\"\n")); + + let receipt = emit_builtin_resync( + root.path(), + "hetz", + WORKER_ID, + "resync-1", + None, + None, + "{}", + false, + ) + .expect("an activated catalog resolves its recipient by exact id"); + assert_eq!(receipt.recipient, WORKER_ID); + assert_eq!(receipt.status, EventReceiptStatus::Created); + + // Ownership is keyed on the ID in the durable record and in the rendered sender. + assert_eq!(stream_state(&agent, crate::resync::RESYNC_STREAM).recipient, WORKER_ID); + let inbox = crate::message::list_inbox(&crate::message::inbox_dir(&agent)).unwrap(); + assert_eq!(inbox.len(), 1); + assert_eq!( + inbox[0].from.as_deref(), + Some(format!("{WORKER_ID}/{}", crate::resync::RESYNC_STREAM).as_str()) + ); + + // The legacy bus identity is not a second route to an activated subject. + let refused = emit_builtin_resync( + root.path(), + "hetz", + "hetz.worker", + "resync-2", + None, + None, + "{}", + false, + ) + .expect_err("an address must not answer for an id"); + assert!( + format!("{refused:#}").contains("no agent 'hetz.worker' found"), + "{refused:#}" + ); + } + + /// `st2 event emit` names its recipient by ordinary bus address, and activation does not turn + /// that surface into an ID lookup: the subject's *current* address routes, its released + /// identity spelling does not, and ownership is still keyed on the immutable ID. + #[test] + fn an_activated_recipient_is_still_reachable_at_its_current_address() { + let root = tempfile::tempdir().unwrap(); + let agent = declare_worker( + root.path(), + &format!(" id \"{WORKER_ID}\"\n address \"chat\"\n stream \"gh-ci\" {{}}\n"), + ); + + for reference in ["hetz.chat", "chat"] { + let receipt = emit( + root.path(), + "hetz", + reference, + "gh-ci", + &format!("run-{reference}"), + None, + None, + "{}", + false, + ) + .expect("the current address routes"); + assert_eq!(receipt.recipient, WORKER_ID); + } + assert_eq!(stream_state(&agent, "gh-ci").recipient, WORKER_ID); + + let refused = emit( + root.path(), + "hetz", + "hetz.worker", + "gh-ci", + "run-legacy", + None, + None, + "{}", + false, + ) + .expect_err("the identity spelling is not an address once one is declared"); + assert!( + format!("{refused:#}").contains("no agent 'hetz.worker' found"), + "{refused:#}" + ); + } + + /// One unmigrated subject keeps the whole catalog on today's precedence: the legacy bus + /// identity and the bare local identity both resolve, and the persisted key is unchanged. + #[test] + fn an_unmigrated_catalog_keeps_the_legacy_recipient_precedence() { + let root = tempfile::tempdir().unwrap(); + let agent = declare_worker(root.path(), ""); + + for (index, recipient) in ["hetz.worker", "worker"].into_iter().enumerate() { + let receipt = emit_builtin_resync( + root.path(), + "hetz", + recipient, + &format!("resync-{index}"), + None, + None, + "{}", + false, + ) + .expect("legacy resolution accepts both spellings"); + assert_eq!(receipt.recipient, "hetz.worker"); + } + assert_eq!(stream_state(&agent, crate::resync::RESYNC_STREAM).recipient, "hetz.worker"); + let inbox = crate::message::list_inbox(&crate::message::inbox_dir(&agent)).unwrap(); + assert_eq!(inbox.len(), 2); + assert_eq!( + inbox[0].from.as_deref(), + Some(format!("hetz.worker/{}", crate::resync::RESYNC_STREAM).as_str()) + ); + } +} diff --git a/src/exec_backend.rs b/src/exec_backend.rs index b789a096..644ee2f3 100644 --- a/src/exec_backend.rs +++ b/src/exec_backend.rs @@ -1058,7 +1058,8 @@ mod generation_observation_tests { TaskTarget { kind: crate::spec::TaskKind::Exec, pty_id: id.to_string(), - bus_id: "host.test".to_string(), + agent_key: "host.test".to_string(), + bus_address: "host.test".to_string(), name: "probe".to_string(), derived: false, launch: TaskLaunch::Shell("sleep 30".to_string()), diff --git a/src/harness_context.rs b/src/harness_context.rs index ed66c888..3bbccdc2 100644 --- a/src/harness_context.rs +++ b/src/harness_context.rs @@ -42,13 +42,14 @@ use std::time::Duration; use anyhow::Context as _; use serde::{Deserialize, Serialize}; -use crate::harness_state::{lock_exclusive, write_json_atomic}; +use crate::harness_state::{RecordIdentity, lock_exclusive, write_json_atomic}; -/// The version this binary WRITES, and the exact string `write_locked` treats as its own record. +/// The legacy record version, whose `agent` field means the bus identity `.`. +/// Normative while the catalog is not fully migrated (DELTA-003's activation gate). const SCHEMA: &str = "st2.harness-context.v1"; -/// The reserved next version, whose `agent` field means the immutable agent ID instead of the -/// bus identity. Otherwise identical to v1's shape; nothing here writes it yet (reader-first -/// rollout, DELTA-003). +/// The target record version, whose `agent` field means the immutable agent ID. Otherwise +/// identical to v1's shape; only a writer over an +/// [`crate::identity::IdentityActivation::Activated`] catalog emits it. const SCHEMA_NEXT: &str = "st2.harness-context.v2"; /// Read admission: exactly the v1/v2 pair, never a prefix match. A foreign namespace @@ -426,7 +427,7 @@ pub struct Writer { path: PathBuf, lock_path: PathBuf, staging_dir: PathBuf, - agent: String, + identity: RecordIdentity, harness: Harness, session: String, } @@ -434,7 +435,7 @@ pub struct Writer { impl Writer { pub fn new( agent_dir: &Path, - agent: impl Into, + agent: impl Into, harness: Harness, ) -> anyhow::Result { anyhow::ensure!( @@ -445,12 +446,24 @@ impl Writer { path: harness_context_path(agent_dir), lock_path: agent_dir.join(LOCK_NAME), staging_dir: staging_dir(agent_dir)?, - agent: agent.into(), + identity: agent.into(), harness, session: crate::harness_state::session_token(), }) } + /// The version this writer WRITES, and therefore the version of a record it owns. Derived + /// from the writer's own identity rather than hardcoded: `agent` means a bus identity under + /// v1 and an immutable agent ID under v2, so coalescing across the pair in either direction + /// would reinterpret bytes it did not write. + fn schema(&self) -> &'static str { + if self.identity.is_activated() { + SCHEMA_NEXT + } else { + SCHEMA + } + } + /// Adopt the session's incarnation token, so sibling writer processes of one session agree on /// the provenance they publish. Provenance only: nothing is fenced on it. pub fn with_session(mut self, token: impl Into) -> Self { @@ -491,12 +504,15 @@ impl Writer { ) -> anyhow::Result { let _lock = lock_exclusive(&self.lock_path)?; // A record this writer does not own — unparseable bytes, any schema other than this - // binary's own write version (the tolerantly readable v2 included), a harness whose - // arithmetic is unknown — is not coalesced against. Ownership here is exact own-version - // equality, deliberately narrower than the reader's accepted pair: coalescing against a - // v2 record would silently downgrade it to v1 numbers under a v1 `agent` meaning. - let current = read_record(&self.path) - .filter(|record| record.schema == SCHEMA && record.harness != Harness::Unrecognized); + // WRITER's own version (the tolerantly readable other half of the pair included), a + // harness whose arithmetic is unknown — is not coalesced against. Ownership here is + // exact own-version equality, deliberately narrower than the reader's accepted pair, + // and it holds in both directions: coalescing a v2 record into a v1 write would + // downgrade its numbers under a v1 `agent` meaning, and coalescing a v1 record into a + // v2 write would restate a bus identity's numbers under an agent ID. + let current = read_record(&self.path).filter(|record| { + record.schema == self.schema() && record.harness != Harness::Unrecognized + }); let now_ms = crate::message::now_ms(); if compaction.is_none() && let (Some(current), Some(reading)) = (current.as_ref(), reading.as_ref()) @@ -537,8 +553,8 @@ impl Writer { _ => now_ms, }; let record = Record { - schema: SCHEMA.to_string(), - agent: self.agent.clone(), + schema: self.schema().to_string(), + agent: self.identity.agent().to_owned(), harness: self.harness, used_tokens, window_tokens, @@ -750,6 +766,9 @@ fn duration_ms(duration: Duration) -> u64 { mod tests { use super::*; + /// A migrated subject's frozen immutable ID, in the UUIDv7 shape creation mints. + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + fn writer(dir: &Path) -> Writer { Writer::new(dir, "hetz.worker", Harness::Codex).unwrap() } @@ -1268,9 +1287,10 @@ mod tests { } } - /// Every writer here stays on version 1 until DELTA-003 activates version-2 writers. + /// The gate decides the version, and `agent`'s meaning travels with it: a legacy writer + /// states the bus identity under v1, an activated one the immutable agent ID under v2. #[test] - fn the_writer_still_emits_version_1_bytes() { + fn a_legacy_writer_emits_version_1_and_an_activated_writer_emits_version_2() { let tmp = tempfile::tempdir().unwrap(); let agent_dir = catalog(tmp.path()); let path = harness_context_path(&agent_dir); @@ -1281,7 +1301,43 @@ mod tests { bytes.contains(r#""schema":"st2.harness-context.v1""#), "observe wrote {bytes}" ); - assert!(!bytes.contains(SCHEMA_NEXT), "no writer emits v2 yet"); + assert!(bytes.contains(r#""agent":"hetz.worker""#), "{bytes}"); + + let activated = catalog(&tmp.path().join("activated")); + let activated_path = harness_context_path(&activated); + let mut writer = + Writer::new(&activated, RecordIdentity::activated(AGENT_ID), Harness::Codex).unwrap(); + assert!(writer.observe(reading(85_000, 33.0)).unwrap()); + let bytes = String::from_utf8(fs::read(&activated_path).unwrap()).unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-context.v2""#), + "observe wrote {bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + } + + /// The mirror of [`a_write_over_an_unowned_version_does_not_coalesce_or_downgrade_it`]: an + /// activated writer owns no v1 record either. Coalescing there would restate a bus + /// identity's numbers under an agent ID, so the write lands wholesale with fresh counters. + #[test] + fn an_activated_writer_does_not_coalesce_a_version_1_record() { + let tmp = tempfile::tempdir().unwrap(); + let agent_dir = catalog(tmp.path()); + let path = harness_context_path(&agent_dir); + let mut legacy = writer(&agent_dir); + assert!(legacy.compacted_with(Compaction::new(CompactionTrigger::Auto), reading(85_000, 33.0)).unwrap()); + let planted = read_record(&path).unwrap(); + assert_eq!(planted.schema, SCHEMA); + assert_eq!(planted.compactions, 1); + + let mut activated = + Writer::new(&agent_dir, RecordIdentity::activated(AGENT_ID), Harness::Codex).unwrap(); + // Same bucket as the planted reading: a coalesced write would have been skipped. + assert!(activated.observe(reading(85_000, 33.0)).unwrap()); + let record = read_record(&path).unwrap(); + assert_eq!(record.schema, SCHEMA_NEXT); + assert_eq!(record.agent, AGENT_ID); + assert_eq!(record.compactions, 0, "no counter carried over"); } /// A record this writer does not own is never coalesced against or downgraded: the write diff --git a/src/harness_state.rs b/src/harness_state.rs index 3e3c37eb..ce5f2f2c 100644 --- a/src/harness_state.rs +++ b/src/harness_state.rs @@ -32,12 +32,12 @@ pub const HARNESS_STATE_REFRESH: Duration = Duration::from_secs(5 * 60); /// Maximum accepted positive difference between the writer's UTC clock and the reader's clock. pub const HARNESS_STATE_FUTURE_SKEW: Duration = Duration::from_secs(60); -/// The version this binary WRITES. Also the ownership key: a record is this writer's only when -/// it carries exactly this string (see [`Writer::observe`]). +/// The legacy record version, whose `agent` field means the bus identity `.`. +/// Normative while the catalog is not fully migrated (DELTA-003's activation gate). const SCHEMA: &str = "st2.harness-state.v1"; -/// The reserved next version, whose `agent` field means the immutable agent ID instead of the -/// bus identity. The shape is otherwise identical, so a tolerant reader decodes its axes exactly -/// like v1's; nothing here writes it yet (reader-first rollout, DELTA-003). +/// The target record version, whose `agent` field means the immutable agent ID. The shape is +/// otherwise identical, so a tolerant reader decodes its axes exactly like v1's; only a writer +/// over an [`crate::identity::IdentityActivation::Activated`] catalog emits it. const SCHEMA_NEXT: &str = "st2.harness-state.v2"; /// Read admission: exactly the v1/v2 pair, never a wider prefix match. A foreign namespace @@ -48,6 +48,111 @@ fn is_supported_schema(schema: &str) -> bool { schema == SCHEMA || schema == SCHEMA_NEXT } +/// The actor bytes a driver stamps into its record, together with the record version that says +/// what those bytes MEAN. The two travel as one value because they are one decision: `agent` +/// holds the bus identity under v1 and the immutable agent ID under v2, so a writer that knew +/// only the string could stamp an ID under a version promising a bus identity. +/// +/// Also the write-side ownership key: [`Writer`] owns a record only while its version matches +/// this one's (see [`Writer::observe`]). Both driver records share this type — [`Writer`] here +/// and [`crate::harness_context::Writer`] beside it resolve it against their own schema +/// constants — so one activation decision cannot version the two records apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordIdentity { + agent: String, + activated: bool, +} + +impl RecordIdentity { + /// Legacy: the bus identity `.`, written under the v1 version. + pub fn legacy(bus_identity: impl Into) -> Self { + Self { + agent: bus_identity.into(), + activated: false, + } + } + + /// Activated: the immutable catalog-global agent ID, written under the v2 version. + pub fn activated(agent_id: impl Into) -> Self { + Self { + agent: agent_id.into(), + activated: true, + } + } + + /// Resolve the pair through DELTA-003's activation gate, which the caller decides once per + /// command from the catalog — never per record write. A partially migrated catalog has no + /// coherent ID namespace to key record ownership on, so every current invariant stays + /// normative there and these writers keep emitting v1 with the bus identity. + pub fn resolve( + activation: &crate::identity::IdentityActivation, + agent_id: &str, + bus_identity: &str, + ) -> Self { + match activation { + crate::identity::IdentityActivation::Activated => Self::activated(agent_id), + crate::identity::IdentityActivation::Legacy(_) => Self::legacy(bus_identity), + } + } + + /// Resolve the actor one driver process was launched with, ONCE at driver start. + /// + /// A driver holds the agent key reconciliation decided for it (`--identity`, the same value + /// `ST_AGENT` carries) and the catalog it was launched against, so it answers the gate itself: + /// under activation that key already IS the immutable agent ID, and under legacy it is the bus + /// identity — the same bytes either way, which is why only the record version they are paired + /// with changes. Deciding once here and holding the result for the process's life keeps the + /// answer off the write path: writing a record must never discover a catalog. + /// + /// Fail-closed in every undecidable direction. An unreadable declaration, unexplained archive + /// state, and an outstanding migration marker each leave the catalog without a coherent ID + /// namespace, so the driver keeps writing what every record already on that disk is keyed by + /// rather than promising an ID meaning it cannot prove. + pub fn for_driver(catalog_root: &Path, agent_key: &str) -> Self { + match crate::identity::activation(catalog_root) { + Ok(activation) => Self::resolve(&activation, agent_key, agent_key), + Err(error) => { + tracing::debug!( + "identity activation is undecidable for {}; driver records stay legacy: {error:#}", + catalog_root.display() + ); + Self::legacy(agent_key) + } + } + } + + /// The actor bytes, whose meaning is [`Self::is_activated`]'s answer. + pub fn agent(&self) -> &str { + &self.agent + } + + pub fn is_activated(&self) -> bool { + self.activated + } +} + +/// A bare identity string is a legacy bus identity: a caller that never consulted the gate — a +/// test, or a helper handed a value read back from an already-legacy record — holds bus-identity +/// bytes by definition. Driver producers instead resolve their actor through +/// [`RecordIdentity::for_driver`]. +impl From for RecordIdentity { + fn from(bus_identity: String) -> Self { + Self::legacy(bus_identity) + } +} + +impl From<&str> for RecordIdentity { + fn from(bus_identity: &str) -> Self { + Self::legacy(bus_identity) + } +} + +impl From<&String> for RecordIdentity { + fn from(bus_identity: &String) -> Self { + Self::legacy(bus_identity.as_str()) + } +} + /// The claim-sequence floor sidecar, beside the record: claims stay monotonic even across a /// record this version cannot parse. const SEQ_FLOOR_NAME: &str = ".harness-state.seq"; @@ -258,7 +363,7 @@ impl Observation { pub struct Writer { path: PathBuf, lock_path: PathBuf, - agent: String, + identity: RecordIdentity, harness: &'static str, pty_session: Option, interrupted: bool, @@ -276,14 +381,14 @@ impl Writer { /// fresh. pub fn new( agent_dir: &Path, - agent: impl Into, + agent: impl Into, harness: &'static str, pty_session: Option, ) -> Self { Self { path: harness_state_path(agent_dir), lock_path: agent_dir.join(LOCK_NAME), - agent: agent.into(), + identity: agent.into(), harness, pty_session, interrupted: false, @@ -320,6 +425,18 @@ impl Writer { self.interrupted = true; } + /// The version this writer WRITES, and therefore the version of a record it owns. Derived + /// from the writer's own identity rather than hardcoded, so the write-side ownership tests + /// below hold in both directions: a v1 writer never touches a v2 record and a v2 writer + /// never touches a v1 one. + fn schema(&self) -> &'static str { + if self.identity.is_activated() { + SCHEMA_NEXT + } else { + SCHEMA + } + } + /// Hold the record's exclusive cross-process lock for one read→decide→rename cycle. The lock /// file is a permanent sibling; the guard releases on drop (close). fn locked(&self) -> anyhow::Result { @@ -404,30 +521,32 @@ impl Writer { if on_disk.as_ref().is_some_and(|current| current.seq > seq) { return Ok(false); } - // Write-side comparisons are EXACT own-version equality, deliberately narrower than the - // read admission in [`is_supported_schema`]: a v2 record is readable but is not this + // Write-side comparisons are EXACT own-version equality — this WRITER's version, not a + // hardcoded one — and deliberately narrower than the read admission in + // [`is_supported_schema`]: the other version of the pair is readable but is not this // writer's record. A record this writer does not own decodes its `seq` as serde-default - // zero, which every claim exceeds — a v1 straggler would otherwise replace a v2 record - // it does not own. Non-claiming writers refuse any other version outright; only the - // explicit written [`claim`] supersedes one. + // zero, which every claim exceeds, so without this a v1 straggler would replace a v2 + // record it does not own — and, once the gate opens, a v2 writer would restamp a v1 + // record whose `agent` means something else. Non-claiming writers refuse any other + // version outright; only the explicit written [`claim`] supersedes one. if on_disk .as_ref() - .is_some_and(|current| current.schema != SCHEMA) + .is_some_and(|current| current.schema != self.schema()) { return Ok(false); } self.claimed_seq = Some(seq); // Ownership is token equality: a record is this writer's only when it carries both this - // binary's own write version and this session's incarnation. Anything else — any other - // schema (the tolerantly readable v2 included), a predecessor's or successor's token, - // the empty pre-token form — is never coalesced + // writer's own version and this session's incarnation. Anything else — any other schema + // (the tolerantly readable other half of the version pair included), a predecessor's or + // successor's token, the empty pre-token form — is never coalesced // against and never treated as this session's terminal word; a genuine observation // replaces it wholesale (one logical owner per record), continuing the counter for // byte-distinctness. Timestamps deliberately play no part: a same-millisecond takeover // and a lingering predecessor writer are both real and both ambiguous by clock. let own_record = on_disk .as_ref() - .filter(|current| current.schema == SCHEMA && current.incarnation == self.session); + .filter(|current| current.schema == self.schema() && current.incarnation == self.session); if skip_if_ended && own_record.is_some_and(|current| { // Only a REAL terminal record from this session suppresses queued live frames: @@ -481,8 +600,8 @@ impl Writer { ), }; let record = Record { - schema: SCHEMA.to_string(), - agent: self.agent.clone(), + schema: self.schema().to_string(), + agent: self.identity.agent().to_owned(), harness: self.harness.to_string(), state: observation.state, blocked_on: observation.blocked_on, @@ -514,13 +633,13 @@ impl Writer { let Some(mut current) = read_record(&self.path) else { return Ok(()); }; - // A schema this writer does not own — exact own-version equality, not the wider read - // admission — must not be round-tripped through this version's + // A schema this writer does not own — exact equality against this WRITER's version, not + // the wider read admission — must not be round-tripped through this version's // record type, and a record this *session* does not own must never be kept fresh: a // lingering predecessor re-stamping its successor's record would keep a dead seat's // state alive for cross-host readers, and a successor re-stamping a predecessor's would // resurrect history. Token equality decides, in both directions. - if current.schema != SCHEMA + if current.schema != self.schema() || current.state == Activity::Ended || current.incarnation != self.session { @@ -780,7 +899,7 @@ fn next_stamp(on_disk: Option<&Record>, now_ms: u64) -> u64 { /// observation replaces it. pub fn claim( agent_dir: &Path, - agent: impl Into, + agent: impl Into, harness: &'static str, token: &str, ) -> anyhow::Result { @@ -818,8 +937,10 @@ fn claim_locked(writer: &Writer, token: &str) -> anyhow::Result { let now_ms = crate::message::now_ms(); let written_at_ms = next_stamp(on_disk.as_ref(), now_ms); let record = Record { - schema: SCHEMA.to_string(), - agent: writer.agent.clone(), + // The written claim supersedes ANY version it finds — that is what a claim is for — and + // states the claimer's own. + schema: writer.schema().to_string(), + agent: writer.identity.agent().to_owned(), harness: writer.harness.to_string(), state: Activity::Ended, blocked_on: BlockedOn::None, @@ -894,7 +1015,7 @@ pub const WRAPPERLESS_PREFIX: &str = "claude-session-"; /// `Ok(None)` = ineligible; unreadable bytes are also ineligible for this cautious path. pub fn claim_wrapperless( agent_dir: &Path, - agent: impl Into, + agent: impl Into, harness: &'static str, token: &str, ) -> anyhow::Result> { @@ -937,10 +1058,23 @@ static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); mod tests { use super::*; + /// A migrated subject's frozen immutable ID, in the UUIDv7 shape creation mints. + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + fn writer(dir: &Path) -> Writer { Writer::new(dir, "hetz.worker", "codex", Some("worker".to_string())) } + /// The same seat over an activated catalog: keyed by its agent ID, writing v2. + fn activated_writer(dir: &Path) -> Writer { + Writer::new( + dir, + RecordIdentity::activated(AGENT_ID), + "codex", + Some("worker".to_string()), + ) + } + /// A new session arriving the way real wrappers do: a written claim, then adoption. fn takeover(dir: &Path, harness: &'static str) -> Writer { let token = session_token(); @@ -1570,9 +1704,10 @@ mod tests { } } - /// Reader-first rollout: readers accept the v1/v2 pair, but every writer here stays on v1. + /// The gate decides the version every write path emits, and `agent`'s meaning travels with + /// it: the bus identity under v1, the immutable agent ID under v2. #[test] - fn every_writer_path_emits_version_1_bytes() { + fn a_legacy_writer_emits_version_1_and_an_activated_writer_emits_version_2() { let tmp = tempfile::tempdir().unwrap(); let path = harness_state_path(tmp.path()); @@ -1583,6 +1718,7 @@ mod tests { observed.contains(r#""schema":"st2.harness-state.v1""#), "observe wrote {observed}" ); + assert!(observed.contains(r#""agent":"hetz.worker""#), "{observed}"); writer.ended("exit 0").unwrap(); let terminal = String::from_utf8(fs::read(&path).unwrap()).unwrap(); @@ -1597,7 +1733,174 @@ mod tests { claimed.contains(r#""schema":"st2.harness-state.v1""#), "claim wrote {claimed}" ); - assert!(!claimed.contains(SCHEMA_NEXT), "no writer emits v2 yet"); + assert!(!claimed.contains(SCHEMA_NEXT), "a legacy writer never emits v2"); + + // The same three paths over an activated catalog, on their own seat. + let tmp = tempfile::tempdir().unwrap(); + let path = harness_state_path(tmp.path()); + let mut writer = activated_writer(tmp.path()); + writer.observe(active()).unwrap(); + let observed = String::from_utf8(fs::read(&path).unwrap()).unwrap(); + assert!( + observed.contains(r#""schema":"st2.harness-state.v2""#), + "observe wrote {observed}" + ); + assert!( + observed.contains(&format!(r#""agent":"{AGENT_ID}""#)), + "{observed}" + ); + + writer.ended("exit 0").unwrap(); + let terminal = String::from_utf8(fs::read(&path).unwrap()).unwrap(); + assert!( + terminal.contains(r#""schema":"st2.harness-state.v2""#), + "ended wrote {terminal}" + ); + + claim( + tmp.path(), + RecordIdentity::activated(AGENT_ID), + "codex", + &session_token(), + ) + .unwrap(); + let claimed = String::from_utf8(fs::read(&path).unwrap()).unwrap(); + assert!( + claimed.contains(r#""schema":"st2.harness-state.v2""#), + "claim wrote {claimed}" + ); + assert!(claimed.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{claimed}"); + } + + /// The gate itself: which bytes and which version a caller gets for one subject. + #[test] + fn the_record_identity_follows_the_activation_gate() { + let activated = RecordIdentity::resolve( + &crate::identity::IdentityActivation::Activated, + AGENT_ID, + "hetz.worker", + ); + assert!(activated.is_activated()); + assert_eq!(activated.agent(), AGENT_ID); + + for reason in [ + crate::identity::LegacyReason::MigrationIncomplete, + crate::identity::LegacyReason::CatalogNotMigrated { + unmigrated: 1, + first: "agents/hetz/worker/agent.kdl".to_owned(), + }, + ] { + let legacy = RecordIdentity::resolve( + &crate::identity::IdentityActivation::Legacy(reason), + AGENT_ID, + "hetz.worker", + ); + assert!(!legacy.is_activated()); + assert_eq!(legacy.agent(), "hetz.worker"); + } + } + + /// The driver-start boundary every producer resolves its actor through: one decision from the + /// catalog the driver was launched against, and Legacy in every direction the catalog cannot + /// prove. A driver that guessed Activated here would stamp raw agent-ID bytes into a version + /// promising a bus identity — the one thing pairing the two in [`RecordIdentity`] prevents. + #[test] + fn a_drivers_actor_follows_its_catalog_and_fails_closed() { + let declare = |catalog: &Path, identity: &str, body: &str| { + let dir = catalog.join("agents").join("hetz").join(identity); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("agent.kdl"), + format!("agent \"{identity}\" {{ host \"hetz\"; {body} }}\n"), + ) + .unwrap(); + }; + + // A fully migrated catalog: the launch key IS the immutable agent ID, under version 2. + let migrated = tempfile::tempdir().unwrap(); + declare(migrated.path(), "worker", &format!("id \"{AGENT_ID}\"")); + let actor = RecordIdentity::for_driver(migrated.path(), AGENT_ID); + assert!(actor.is_activated()); + assert_eq!(actor.agent(), AGENT_ID); + + // One unmigrated subject is enough: the catalog has no coherent ID namespace at all. + declare(migrated.path(), "other", ""); + let mixed = RecordIdentity::for_driver(migrated.path(), "hetz.worker"); + assert!(!mixed.is_activated()); + assert_eq!(mixed.agent(), "hetz.worker"); + + // An interrupted migration is unproven, not optimistic. + let interrupted = tempfile::tempdir().unwrap(); + declare(interrupted.path(), "worker", &format!("id \"{AGENT_ID}\"")); + let marker = crate::catalog_migrate_ids::marker_path(interrupted.path()); + fs::create_dir_all(marker.parent().unwrap()).unwrap(); + fs::write(&marker, "{}").unwrap(); + assert!(!RecordIdentity::for_driver(interrupted.path(), AGENT_ID).is_activated()); + + // An undiscoverable catalog cannot decide anything, so the driver keeps writing what the + // records already on that disk are keyed by. + let broken = tempfile::tempdir().unwrap(); + let dir = broken.path().join("agents/hetz/worker"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("agent.kdl"), "agent \"worker\" { host \"hetz\"").unwrap(); + let fallback = RecordIdentity::for_driver(broken.path(), "hetz.worker"); + assert!(!fallback.is_activated()); + assert_eq!(fallback.agent(), "hetz.worker"); + } + + /// Write-side ownership is exact equality against the WRITER's own version, and it fails + /// closed in BOTH directions: a v2 writer must not restamp or coalesce against a v1 record + /// whose `agent` means a bus identity, and a v1 writer must not touch a v2 one. Everything + /// else here matches — same seat, same session token, same claimed sequence — so the version + /// is the only thing deciding. + #[test] + fn the_version_gate_refuses_ownership_in_both_directions() { + let tmp = tempfile::tempdir().unwrap(); + let path = harness_state_path(tmp.path()); + let token = session_token(); + + // A legacy writer's own live v1 record… + let mut legacy = Writer::new(tmp.path(), "hetz.worker", "codex", Some("worker".into())) + .with_session(token.clone()); + legacy.observe(active()).unwrap(); + let v1 = fs::read(&path).unwrap(); + + // …is not an activated writer's record even sharing that session: the heartbeat leaves it + // byte-identical rather than round-tripping it, and a live frame is refused. + let mut activated = Writer::new( + tmp.path(), + RecordIdentity::activated(AGENT_ID), + "codex", + Some("worker".into()), + ) + .with_session(token.clone()); + activated.heartbeat().unwrap(); + assert_eq!(fs::read(&path).unwrap(), v1, "a v2 writer restamped a v1 record"); + assert!(!activated.observe_unless_ended(active()).unwrap()); + assert_eq!(fs::read(&path).unwrap(), v1, "a v2 writer coalesced into a v1 record"); + + // Only the written claim supersedes a version it does not own, and it states its own. + let next = session_token(); + let seq = claim( + tmp.path(), + RecordIdentity::activated(AGENT_ID), + "codex", + &next, + ) + .unwrap(); + let claimed: Record = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(claimed.schema, SCHEMA_NEXT); + assert_eq!(claimed.agent, AGENT_ID); + + // And the mirror: the legacy writer now holds this session's exact ownership, so only the + // version refuses it. + let v2 = fs::read(&path).unwrap(); + let mut legacy = Writer::new(tmp.path(), "hetz.worker", "codex", Some("worker".into())) + .with_ownership(next, seq); + legacy.heartbeat().unwrap(); + assert_eq!(fs::read(&path).unwrap(), v2, "a v1 writer restamped a v2 record"); + assert!(!legacy.observe_unless_ended(active()).unwrap()); + assert_eq!(fs::read(&path).unwrap(), v2, "a v1 writer coalesced into a v2 record"); } #[test] diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 00000000..662f32f4 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,503 @@ +//! Agent selection: immutable IDs, mutable addresses, and the activation gate between them. +//! +//! Decision 0015 splits one overloaded string into two typed namespaces. The immutable **agent ID** +//! is catalog-global and never routes for humans; the mutable **agent address** is unique per +//! logical host and is the only thing an ordinary human reference resolves against. Equal bytes in +//! the two namespaces do not collide, so an exact-ID selector performs only ID lookup and never +//! falls through to address lookup — that is what keeps an existing semantic ID from silently +//! staying alive as a route after a rename. +//! +//! Ordinary references are decided by a fail-closed candidate set rather than a precedence rule, +//! because a dotted semantic address and a host-qualified bus address are indistinguishable by +//! shape: `dotfiles.fractal.chat` is a legal bare address and a legal `.
` split. +//! Collecting both readings and requiring exactly one surviving subject makes the question +//! decidable without guessing which dot is the separator. +//! +//! [`activation`] is the gate DELTA-003 step 5 turns on. Until every live and structurally archived +//! subject carries an explicit ID, target writers stay on legacy behavior: a partially migrated +//! catalog has no coherent ID namespace to key ownership, provenance, or task identity on. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// How a caller named one agent. +/// +/// The two forms are mutually exclusive by construction. Every agent-selecting command exposes +/// both, and a command that defaults from `ST_AGENT` consumes it through [`Self::Id`] — an ambient +/// actor is an exact subject, never a route to re-resolve. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSelector { + /// Catalog-global exact ID lookup. Never falls through to address lookup. + Id(String), + /// An ordinary human reference, resolved by [`resolve_address`]. + Address(String), +} + +/// One routable subject in the address book. +/// +/// Retired subjects are absent: retirement releases the address and makes the subject +/// non-routable, so it neither resolves nor occupies the namespace. Suspended subjects are present. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressBookEntry { + /// The immutable catalog-global agent ID. + pub id: String, + /// The resolved logical host. + pub host: String, + /// The effective address: explicit `address`, else the positional `identity` fallback. + pub address: String, +} + +impl AddressBookEntry { + /// The human-routable bus address `.
`. + pub fn bus_address(&self) -> String { + format!("{}.{}", self.host, self.address) + } +} + +/// Why an ordinary reference did not name exactly one subject. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// No routable subject carries this address, in any admitted reading. + Unknown { reference: String }, + /// More than one distinct subject survives, so the reference is undecidable. + Ambiguous { + reference: String, + /// The surviving subjects' IDs, sorted, so a diagnostic can name them. + ids: Vec, + }, +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown { reference } => write!( + formatter, + "no routable agent has the address '{reference}'; a retired subject releases its address and does not resolve" + ), + Self::Ambiguous { reference, ids } => write!( + formatter, + "the address '{reference}' is ambiguous: it names {} subjects ({}); qualify it with a host or select the subject by its exact id", + ids.len(), + ids.join(", ") + ), + } + } +} + +impl std::error::Error for ResolveError {} + +/// Resolve one exact agent ID. Catalog-global, and never an address lookup. +pub fn resolve_id<'a>( + entries: &'a [AddressBookEntry], + id: &str, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + entries + .iter() + .find(|entry| entry.id == id) + .ok_or_else(|| ResolveError::Unknown { + reference: id.to_owned(), + }) +} + +/// Resolve one ordinary human reference through the fail-closed candidate set (R24). +/// +/// 1. When the caller pins a host, treat the complete input as an address in that host, and also +/// try the qualified split whose prefix equals the pinned host. +/// 2. Otherwise treat the complete input as a bare address across the selected catalog, and also +/// try every dotted split whose prefix is an admitted logical host and whose suffix is an +/// effective address in that host. +/// 3. Deduplicate by agent ID and succeed only when exactly one subject remains. +pub fn resolve_address<'a>( + entries: &'a [AddressBookEntry], + reference: &str, + pinned_host: Option<&str>, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + let hosts: BTreeSet<&str> = entries.iter().map(|entry| entry.host.as_str()).collect(); + let mut candidates: BTreeMap<&str, &AddressBookEntry> = BTreeMap::new(); + + let mut consider = |host: &str, address: &str| { + for entry in entries { + if entry.host == host && entry.address == address { + candidates.insert(entry.id.as_str(), entry); + } + } + }; + + match pinned_host { + Some(pinned) => { + consider(pinned, reference); + // Only the pinned host's own prefix is an admitted split: a caller that pinned a host + // cannot reach another one by spelling it into the reference. + if let Some(suffix) = reference + .strip_prefix(pinned) + .and_then(|rest| rest.strip_prefix('.')) + { + consider(pinned, suffix); + } + } + None => { + for host in &hosts { + consider(host, reference); + } + for (index, _) in reference.match_indices('.') { + let (prefix, suffix) = (&reference[..index], &reference[index + 1..]); + if hosts.contains(prefix) { + consider(prefix, suffix); + } + } + } + } + + match candidates.len() { + 1 => Ok(candidates.into_values().next().expect("one candidate")), + 0 => Err(ResolveError::Unknown { + reference: reference.to_owned(), + }), + _ => Err(ResolveError::Ambiguous { + reference: reference.to_owned(), + ids: candidates.into_keys().map(str::to_owned).collect(), + }), + } +} + +/// Resolve either selector form against one coherent address book. +pub fn resolve<'a>( + entries: &'a [AddressBookEntry], + selector: &AgentSelector, + pinned_host: Option<&str>, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + match selector { + AgentSelector::Id(id) => resolve_id(entries, id), + AgentSelector::Address(reference) => resolve_address(entries, reference, pinned_host), + } +} + +/// The routable address book of a discovered catalog. +/// +/// A subject with no explicit `id` contributes its effective ID — the legacy bus identity migration +/// freezes — so resolution works identically before and after migration. +pub fn address_book(specs: &[agent_spec::AgentSpec], this_host: &str) -> Vec { + specs + .iter() + .filter(|spec| !spec.desired_state.is_retired()) + .map(|spec| AddressBookEntry { + id: spec.effective_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + }) + .collect() +} + +/// Whether the target identity model is active for this catalog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityActivation { + /// Every live and structurally archived subject carries an explicit immutable ID. + Activated, + /// The catalog is not fully migrated, so legacy identity behavior remains normative. + Legacy(LegacyReason), +} + +/// Why the target model is not active. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LegacyReason { + /// Live or archived subjects still carry no explicit `id`. + CatalogNotMigrated { + unmigrated: usize, + /// One example, so a diagnostic can name something actionable. + first: String, + }, + /// A `st2 catalog migrate-ids` transaction did not complete. + MigrationIncomplete, +} + +impl std::fmt::Display for LegacyReason { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CatalogNotMigrated { unmigrated, first } => write!( + formatter, + "{unmigrated} subject(s) carry no explicit agent id (for example {first}); run `st2 catalog migrate-ids`" + ), + Self::MigrationIncomplete => formatter.write_str( + "a `st2 catalog migrate-ids` transaction did not complete; rerun it with --resume", + ), + } + } +} + +impl IdentityActivation { + pub fn is_activated(&self) -> bool { + matches!(self, Self::Activated) + } +} + +/// Decide activation from an already-discovered live catalog plus the structural archive. +/// +/// Both planes must be migrated. An unmigrated archived subject cannot re-enter an ID-keyed +/// catalog, and an unmigrated live subject has no ID to key ownership, provenance, or task +/// identity on — so a partially migrated catalog keeps every current invariant normative rather +/// than mixing two identity models in one pass. +pub fn activation_from( + specs: &[agent_spec::AgentSpec], + archived: &[crate::catalog_archive::Tombstone], + migration_incomplete: bool, +) -> IdentityActivation { + if migration_incomplete { + return IdentityActivation::Legacy(LegacyReason::MigrationIncomplete); + } + let mut unmigrated = Vec::new(); + for spec in specs { + if spec.id.is_none() { + unmigrated.push(spec.path.display().to_string()); + } + } + for tombstone in archived { + if tombstone.agent_id.is_none() { + unmigrated.push(format!( + ".st2/archive/{}/{}", + tombstone.host, tombstone.identity + )); + } + } + match unmigrated.first() { + None => IdentityActivation::Activated, + Some(first) => IdentityActivation::Legacy(LegacyReason::CatalogNotMigrated { + unmigrated: unmigrated.len(), + first: first.clone(), + }), + } +} + +/// Decide activation by reading the catalog. The caller must already hold a catalog read fence. +pub fn activation(catalog: &Path) -> Result { + let found = crate::discover_strict(catalog); + anyhow::ensure!( + found.errors.is_empty(), + "cannot decide identity activation: catalog discovery is incomplete:\n{}", + found + .errors + .iter() + .map(|error| format!(" {}: {}", error.path.display(), error.message)) + .collect::>() + .join("\n") + ); + let observation = crate::catalog_archive::observe(catalog) + .context("observe the structural archive for identity activation")?; + anyhow::ensure!( + observation.issues.is_empty(), + "cannot decide identity activation: the structural archive has unexplained state" + ); + let migration_incomplete = crate::catalog_migrate_ids::marker_path(catalog).exists(); + Ok(activation_from( + &found.specs, + &observation.archived, + migration_incomplete, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: &str, host: &str, address: &str) -> AddressBookEntry { + AddressBookEntry { + id: id.to_owned(), + host: host.to_owned(), + address: address.to_owned(), + } + } + + #[test] + fn a_bare_address_resolves_within_the_selected_catalog() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "notes")]; + assert_eq!(resolve_address(&entries, "chat", None).unwrap().id, "id-1"); + assert_eq!(resolve_address(&entries, "notes", None).unwrap().id, "id-2"); + } + + /// A host-qualified bus address and a dotted semantic address are the same shape, so both + /// readings are collected and the answer is the surviving subject. + #[test] + fn a_dotted_reference_is_decided_by_the_candidate_set_not_by_precedence() { + let entries = vec![ + entry("id-1", "dev3", "fractal.chat"), + entry("id-2", "dev4", "notes"), + ]; + // Only the bare reading exists. + assert_eq!( + resolve_address(&entries, "fractal.chat", None).unwrap().id, + "id-1" + ); + // Only the qualified reading exists. + assert_eq!( + resolve_address(&entries, "dev4.notes", None).unwrap().id, + "id-2" + ); + // The qualified reading of a dotted address also resolves, because `dev3` is an admitted + // host and `fractal.chat` is an address in it. + assert_eq!( + resolve_address(&entries, "dev3.fractal.chat", None) + .unwrap() + .id, + "id-1" + ); + } + + /// Both readings naming different subjects is undecidable, not first-wins. + #[test] + fn two_readings_of_one_reference_fail_closed() { + let entries = vec![ + // The bare reading: host `dev3` has the dotted address `dev4.notes`. + entry("id-1", "dev3", "dev4.notes"), + // The qualified reading: host `dev4` has the address `notes`. + entry("id-2", "dev4", "notes"), + ]; + let error = resolve_address(&entries, "dev4.notes", None).unwrap_err(); + match error { + ResolveError::Ambiguous { ids, .. } => assert_eq!(ids, vec!["id-1", "id-2"]), + other => panic!("expected ambiguity, got {other:?}"), + } + } + + /// The same address on two hosts is legal, so an unqualified reference to it is ambiguous and a + /// qualified one is exact. + #[test] + fn one_address_on_two_hosts_needs_a_host_to_be_decidable() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "chat")]; + assert!(matches!( + resolve_address(&entries, "chat", None), + Err(ResolveError::Ambiguous { .. }) + )); + assert_eq!( + resolve_address(&entries, "dev3.chat", None).unwrap().id, + "id-1" + ); + assert_eq!( + resolve_address(&entries, "chat", Some("dev4")).unwrap().id, + "id-2" + ); + } + + /// A pinned host is a boundary: a reference cannot reach another host by spelling it. + #[test] + fn a_pinned_host_admits_only_its_own_qualified_split() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "chat")]; + assert_eq!( + resolve_address(&entries, "dev3.chat", Some("dev3")) + .unwrap() + .id, + "id-1" + ); + assert!(matches!( + resolve_address(&entries, "dev4.chat", Some("dev3")), + Err(ResolveError::Unknown { .. }) + )); + } + + /// Deduplication is by agent ID, so one subject reachable through both readings is not + /// ambiguous with itself. + #[test] + fn one_subject_reached_twice_is_not_ambiguous() { + // `dev3` has an address that literally reads `dev3.chat`, so the bare reading and the + // qualified reading are the same subject only if the host also has `chat`. + let entries = vec![entry("id-1", "dev3", "chat")]; + assert_eq!( + resolve_address(&entries, "dev3.chat", None).unwrap().id, + "id-1" + ); + assert_eq!( + resolve_address(&entries, "dev3.chat", Some("dev3")) + .unwrap() + .id, + "id-1" + ); + } + + #[test] + fn an_unknown_address_fails_with_an_address_specific_diagnostic() { + let entries = vec![entry("id-1", "dev3", "chat")]; + let error = resolve_address(&entries, "ghost", None).unwrap_err(); + assert!( + format!("{error}").contains("no routable agent has the address 'ghost'"), + "{error}" + ); + } + + /// Exact ID selection never falls through to address lookup, and equal bytes across the two + /// namespaces do not collide. + #[test] + fn exact_id_selection_never_falls_through_to_address_lookup() { + let entries = vec![ + entry("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "dev3", "chat"), + entry("id-2", "dev4", "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"), + ]; + // The ID namespace answers with the subject that owns the ID. + assert_eq!( + resolve_id(&entries, "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1") + .unwrap() + .host, + "dev3" + ); + // The address namespace answers with the subject that owns the address. + assert_eq!( + resolve_address(&entries, "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", None) + .unwrap() + .host, + "dev4" + ); + // An ID that is not an ID does not become an address. + assert!(matches!( + resolve_id(&entries, "chat"), + Err(ResolveError::Unknown { .. }) + )); + } + + #[test] + fn the_selector_forms_route_to_their_own_namespace() { + let entries = vec![entry("id-1", "dev3", "chat")]; + assert_eq!( + resolve(&entries, &AgentSelector::Id("id-1".to_owned()), None) + .unwrap() + .address, + "chat" + ); + assert_eq!( + resolve(&entries, &AgentSelector::Address("chat".to_owned()), None) + .unwrap() + .id, + "id-1" + ); + assert!(resolve(&entries, &AgentSelector::Id("chat".to_owned()), None).is_err()); + } + + #[test] + fn activation_requires_both_planes_and_a_complete_transaction() { + use crate::catalog_archive::{TOMBSTONE_SCHEMA, Tombstone}; + + let tombstone = |agent_id: Option<&str>| Tombstone { + schema: TOMBSTONE_SCHEMA.to_owned(), + id: "h.gone".to_owned(), + host: "h".to_owned(), + identity: "gone".to_owned(), + archived_at: 1, + reason: None, + archive_root: ".st2/archive/h/gone".to_owned(), + agent_id: agent_id.map(str::to_owned), + }; + + assert_eq!( + activation_from(&[], &[tombstone(Some("h.gone"))], false), + IdentityActivation::Activated + ); + // An unmigrated archived subject blocks activation: it cannot re-enter an ID-keyed catalog. + assert!(matches!( + activation_from(&[], &[tombstone(None)], false), + IdentityActivation::Legacy(LegacyReason::CatalogNotMigrated { unmigrated: 1, .. }) + )); + // An interrupted transaction blocks activation regardless of what is already migrated. + assert_eq!( + activation_from(&[], &[tombstone(Some("h.gone"))], true), + IdentityActivation::Legacy(LegacyReason::MigrationIncomplete) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f0fd851..e4294ea3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod harness_context; pub mod harness_state; pub mod harness_version; pub mod hooks; +pub mod identity; pub mod host_lock; pub mod isolate; pub mod materialize; diff --git a/src/main.rs b/src/main.rs index 6117531e..6bd8427c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,6 +115,9 @@ enum Command { /// target when no positional session is given. #[arg(long)] identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--identity`. + #[arg(long = "agent-id", conflicts_with = "identity")] + agent_id: Option, /// Catalog root. Defaults to `$CATALOG`. #[arg(long, conflicts_with = "catalog_path")] root: Option, @@ -149,6 +152,9 @@ enum Command { Status { /// Whose status — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Set your status to this state instead of printing it. #[arg(long = "set")] set: Option, @@ -277,6 +283,9 @@ enum Command { /// Select one exact Agent Spec by its fully qualified `.`. #[arg(long, value_name = "HOST.IDENTITY")] identity: Option, + /// Select one exact subject by its immutable agent ID (R24). + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Machine-readable JSON array, including retirement and declared Resource bindings. #[arg(long)] json: bool, @@ -419,11 +428,20 @@ enum DriverCmd { enum AgentCmd { /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration. DesiredState { - /// Exact bus identity, or a bare stable identity only when unique. - identity: String, - /// Desired whole-agent lifecycle state. - #[arg(value_parser = ["running", "suspended", "retired"])] - state: String, + /// Ordinary agent reference — an exact bus address, or a bare address unique in the + /// catalog — or the desired state when `--id` names the subject. + #[arg(value_name = "IDENTITY_OR_STATE")] + first: Option, + /// The desired state, when the first positional is the agent reference. + #[arg(value_name = "STATE")] + second: Option, + /// Exact immutable agent ID (R24). The first positional is then the desired state. + /// + /// Both positionals stay optional so the exact-ID form can shift them; clap refuses a + /// non-required positional ahead of a required one, which is why the state is validated in + /// the handler rather than by a positional `value_parser`. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Required rationale for suspended/retired; forbidden for running. #[arg(long)] reason: Option, @@ -434,6 +452,9 @@ enum AgentCmd { #[arg(long)] json: bool, }, + /// Assign or clear an agent's mutable address — one atomic address-book cutover with no + /// alias, redirect, or rename history. `--clear` restores the positional identity fallback. + Address(PresentationArgs), /// Compute the authoritative digest bound by `agent publish --input-sha256`. Digest { /// A canonical KDL file containing exactly one top-level `agent` node. @@ -667,17 +688,23 @@ struct MsgCtx { host: Option, } +/// `[] ` plus the mutually exclusive exact-ID form. +/// +/// `--id` takes the agent off the positional list, so the first positional is then the value — +/// the same `[identity] ` convention `st2 message read` and `st2 resource read` already +/// use, and the reason clap's exclusion is expressed against the second positional. #[derive(Args)] struct PresentationArgs { - /// Exact bus identity, or a bare stable identity only when unique in the selected catalog. - identity: String, - /// Presentation text. Use --clear to remove the field. - #[arg( - value_name = "TEXT", - required_unless_present = "clear", - conflicts_with = "clear" - )] - value: Option, + /// Ordinary agent reference — an exact bus address, or a bare address unique in the catalog — + /// or the new value when `--id` names the subject. + #[arg(value_name = "IDENTITY_OR_TEXT")] + first: Option, + /// The new value, when the first positional is the agent reference. + #[arg(value_name = "TEXT")] + second: Option, + /// Exact immutable agent ID (R24). The first positional is then the new value. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Remove the optional field. #[arg(long)] clear: bool, @@ -689,6 +716,37 @@ struct PresentationArgs { host: Option, } +impl PresentationArgs { + /// The selected subject and the requested value, where `None` is the cleared representation. + fn selection(self) -> Result<(st2::identity::AgentSelector, Option, bool, Option)> + { + let (selector, value) = match self.agent_id { + Some(id) => (st2::identity::AgentSelector::Id(id), self.first), + None => ( + st2::identity::AgentSelector::Address( + self.first + .context("no agent selected: pass an agent reference or the `--id` form")?, + ), + self.second, + ), + }; + anyhow::ensure!( + !(self.clear && value.is_some()), + "--clear removes the field and takes no value" + ); + anyhow::ensure!( + self.clear || value.is_some(), + "a value is required unless --clear" + ); + Ok(( + selector, + if self.clear { None } else { value }, + self.json, + self.host, + )) + } +} + #[derive(Subcommand)] enum ServiceCmd { /// Write the `st2.service` systemd-user unit, enable it (start on boot), and start it now. @@ -763,6 +821,9 @@ enum ResourceCmd { Ls { /// Whose declaration to read — bus id or bare identity. Defaults to you (`$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Emit the bindings as a JSON array. #[arg(long)] json: bool, @@ -773,6 +834,9 @@ enum ResourceCmd { Read { first: String, second: Option, + /// Exact immutable agent ID (R24); `first` is then the binding name. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Emit the binding as a JSON object. #[arg(long)] json: bool, @@ -788,6 +852,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long, conflicts_with = "second")] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with_all = ["second", "agent"])] + agent_id: Option, /// Client-only wait bound in seconds. Expiry never cancels or retracts queued demand. #[arg(long, default_value_t = 30)] wait: u64, @@ -816,6 +883,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -829,6 +899,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -844,6 +917,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -858,6 +934,9 @@ enum ContextCmd { Read { /// Whose context — bus id or identity. Defaults to you (`$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Print the decision log instead of the working state. #[arg(long)] decisions: bool, @@ -873,12 +952,18 @@ enum ContextCmd { /// Overwrite an agent's working state (`now.md`) from stdin. Write { identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, #[command(flatten)] ctx: MsgCtx, }, /// Append a single decision (with its reasoning) to the log. Append { identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// The decision — a single line. #[arg(long)] decision: String, @@ -894,8 +979,12 @@ enum ContextCmd { enum MessageCmd { /// Send a new message to a recipient's inbox. Send { - /// Recipient: a bus id (`.`) or a bare identity in the catalog. - to: String, + /// Recipient: a bus address (`.
`) or a bare address in the catalog. + #[arg(required_unless_present = "to_id", conflicts_with = "to_id")] + to: Option, + /// Recipient by exact immutable agent ID (R24). Mutually exclusive with the positional. + #[arg(long = "to-id")] + to_id: Option, /// The message body. Read from stdin when omitted. #[arg(short = 'm', long = "message")] body: Option, @@ -932,6 +1021,9 @@ enum MessageCmd { Ls { /// Whose inbox — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// List the archive instead of the inbox. #[arg(long)] archive: bool, @@ -960,6 +1052,9 @@ enum MessageCmd { Sent { /// Whose sent index — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Print only the indexed message count. Refuses unavailable or partial coverage. #[arg(long)] count: bool, @@ -984,6 +1079,9 @@ enum MessageCmd { first: String, /// The message filename (when `first` is an identity). second: Option, + /// Exact immutable agent ID (R24) owning the box; `first` is then the filename. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Read from the archive instead of the inbox. #[arg(long)] archive: bool, @@ -1002,6 +1100,9 @@ enum MessageCmd { first: String, /// The message filename (when `first` is an identity). second: Option, + /// Exact immutable agent ID (R24) owning the box; `first` is then the filename. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, #[command(flatten)] ctx: MsgCtx, }, @@ -1024,8 +1125,12 @@ enum MessageCmd { enum EventCmd { /// Emit one producer-identified event into a declared agent stream. Emit { - /// Owning agent: `.` or a bare local identity. - recipient: String, + /// Owning agent: a bus address (`.
`) or a bare local address. + #[arg(required_unless_present = "recipient_id", conflicts_with = "recipient_id")] + recipient: Option, + /// Owning agent by exact immutable agent ID (R24). + #[arg(long = "recipient-id")] + recipient_id: Option, /// Declared stream name. #[arg(long)] stream: String, @@ -1060,6 +1165,9 @@ enum StreamCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Adapter command run under `sh -c`; omit both launch forms for external ingress. #[arg(long, conflicts_with = "adapter_argv")] command: Option, @@ -1077,6 +1185,9 @@ enum StreamCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, #[arg(long)] json: bool, #[command(flatten)] @@ -1212,10 +1323,11 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< Command::Ding { session, identity, + agent_id, root, host, interval, - } => ding_cmd(session, identity, root, host, interval), + } => ding_cmd(session, identity, agent_id, root, host, interval), Command::CodexAppServer { identity, runtime_id, @@ -1317,18 +1429,38 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< let catalog = catalog_arg(None)?; driver_expand_cmd(&catalog, &spec, agent.as_deref(), host.as_deref()) } - Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), + Command::Status { + identity, + agent_id, + set, + ctx, + } => status_cmd(identity, agent_id, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { presentation_cmd(st2::agent_author::PresentationField::Description, args) } + Command::Agent(AgentCmd::Address(args)) => address_cmd(args), Command::Agent(AgentCmd::DesiredState { - identity, - state, + first, + second, + agent_id, reason, host, json, - }) => desired_state_cmd(identity, state, reason, host, json), + }) => { + let (identity, state) = match &agent_id { + Some(_) => (None, first), + None => (first, second), + }; + let state = state.context( + "a desired state is required: `running`, `suspended`, or `retired`", + )?; + anyhow::ensure!( + matches!(state.as_str(), "running" | "suspended" | "retired"), + "desired state must be `running`, `suspended`, or `retired`, not '{state}'" + ); + desired_state_cmd(identity, agent_id, state, reason, host, json) + } Command::Agent(AgentCmd::Publish { spec, bundle, @@ -1644,10 +1776,11 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< catalog, status, identity, + agent_id, json, enrich, ctx, - } => agents_cmd(catalog, status, identity, json, enrich, ctx), + } => agents_cmd(catalog, status, identity, agent_id, json, enrich, ctx), Command::Tasks { host, json } => { if !json { anyhow::bail!("`st2 tasks` v1 requires --json"); @@ -2487,19 +2620,14 @@ fn presentation_cmd( field: st2::agent_author::PresentationField, args: PresentationArgs, ) -> Result<()> { - let PresentationArgs { - identity, - value, - clear, - json, - host, - } = args; + let (selector, requested, json, host) = args.selection()?; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); + let identity = resolve_declaration(&root, &host, selector)?; let actor = std::env::var("ST_AGENT") .ok() .filter(|value| !value.is_empty()); - let requested = if clear { None } else { value.as_deref() }; + let requested = requested.as_deref(); match st2::agent_author::set_presentation( &root, &identity, @@ -2546,8 +2674,66 @@ fn presentation_cmd( } } +/// Assign or clear one agent's mutable address (R25) — the third authoring sibling of `st2 rename` +/// and `st2 describe`, classified by exactly the same receipt and refusal vocabulary. +fn address_cmd(args: PresentationArgs) -> Result<()> { + let (selected, requested, json, host) = args.selection()?; + let root = catalog_arg(None)?; + let host = host.unwrap_or_else(detect_host); + let selector = resolve_declaration(&root, &host, selected)?; + let actor = std::env::var("ST_AGENT") + .ok() + .filter(|value| !value.is_empty()); + let requested = requested.as_deref(); + match st2::agent_author::set_address(&root, &selector, &host, actor.as_deref(), requested) { + Ok(receipt) => { + if json { + println!("{}", serde_json::to_string(&receipt)?); + } else { + let state = match (receipt.result, receipt.address.as_deref()) { + (st2::agent_author::AuthorOutcome::Changed, Some(value)) => { + format!("set to {value:?}") + } + (st2::agent_author::AuthorOutcome::Changed, None) => "cleared".to_owned(), + (st2::agent_author::AuthorOutcome::Unchanged, Some(value)) => { + format!("already {value:?}") + } + (st2::agent_author::AuthorOutcome::Unchanged, None) => { + "already clear".to_owned() + } + }; + println!( + "{} address: {state} (bus address {})", + receipt.id, + receipt + .bus_address + .as_deref() + .unwrap_or("none — the subject is retired and non-routable") + ); + } + Ok(()) + } + Err(error) => { + if json { + println!( + "{}", + serde_json::json!({ + "result": "error", + "code": error.code(), + "identity": selector, + "field": "address", + "error": error.to_string(), + }) + ); + } + Err(error.into()) + } + } +} + fn desired_state_cmd( - identity: String, + identity: Option, + agent_id: Option, state: String, reason: Option, host: Option, @@ -2561,6 +2747,7 @@ fn desired_state_cmd( }; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); + let identity = resolve_declaration(&root, &host, one_selector(identity, agent_id)?)?; let actor = std::env::var("ST_AGENT") .ok() .filter(|value| !value.is_empty()); @@ -2611,11 +2798,16 @@ fn desired_state_cmd( } } -fn status_cmd(identity: Option, set: Option, ctx: MsgCtx) -> Result<()> { +fn status_cmd( + identity: Option, + agent_id: Option, + set: Option, + ctx: MsgCtx, +) -> Result<()> { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(&ctx)?, + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(id) => id, + None => acting_route(&root, &host, &ctx)?, }; let sp = st2::status::status_path(&agent_dir_of(&root, &id, &host)?); match set { @@ -2637,6 +2829,7 @@ fn agents_cmd( catalog: Option, status_filter: Option, identity: Option, + agent_id: Option, json: bool, enrich: bool, mut ctx: MsgCtx, @@ -2651,12 +2844,13 @@ fn agents_cmd( let (root, host) = resolve_ctx(&ctx)?; let _catalog_lock = st2::CatalogLock::shared(&root) .context("acquire shared catalog-authoring lock for agent roster")?; - let found = if identity.is_some() { + let selected = identity.is_some() || agent_id.is_some(); + let found = if selected { st2::discover_strict(&root) } else { st2::discover(&root) }; - if identity.is_some() && !found.errors.is_empty() { + if selected && !found.errors.is_empty() { let errors = found .errors .iter() @@ -2677,6 +2871,16 @@ fn agents_cmd( rows.len() ); } + // An exact ID roster query is catalog-global and answers on the appended immutable `id`, not + // on the positional declaration key — equal bytes in the two namespaces never collide. + if let Some(agent_id) = &agent_id { + rows.retain(|row| row.id == *agent_id); + anyhow::ensure!( + rows.len() == 1, + "expected exactly one Agent Spec with agent id `{agent_id}`, found {}", + rows.len() + ); + } if let Some(f) = &status_filter { rows.retain(|r| r.status.as_str() == f); if let Some(identity) = &identity { @@ -2768,6 +2972,7 @@ fn context_column(context: Option<&st2::harness_context::Observed>) -> String { fn ding_cmd( session: Option, identity: Option, + agent_id: Option, root: Option, host: Option, interval: u64, @@ -2778,11 +2983,25 @@ fn ding_cmd( host, }; let (catalog_root, this_host) = resolve_ctx(&ctx)?; - let id = acting_id(&ctx)?; + // The poked pty session is a runtime task ID, so it keeps the exact bytes the caller named: + // under activation `ST_AGENT` carries the agent ID and the canonical agent task ID equals it. + // The inbox and status are declaration-parent state, so they resolve through the route. + let named = match &agent_id { + Some(id) => id.clone(), + None => acting_id(&ctx)?, + }; + let id = match agent_id { + Some(id) => resolve_route( + &catalog_root, + &this_host, + st2::identity::AgentSelector::Id(id), + )?, + None => acting_route(&catalog_root, &this_host, &ctx)?, + }; // The pty to poke defaults to the identity — an agent IS its pty, so the session id == the agent // id. So `st2 ding --identity mix.worker` pokes pty `mix.worker` (the redundant positional is now // optional). An explicit positional still overrides for the rare non-agent case. - let session = session.unwrap_or_else(|| id.clone()); + let session = session.unwrap_or(named); // Flat-bus aware: a native catalog agent → its resources/inbox; a catalog-LESS bus (an eval's // ST_ROOT) → the flat //inbox. Status lives beside it either way. let agent_dir = message::resolve_agent_dir(&catalog_root, &id, &this_host)? @@ -2828,12 +3047,159 @@ fn acting_id(ctx: &MsgCtx) -> Result { .context("no acting identity: pass --as or set $ST_AGENT") } +/// The acting subject's route. +/// +/// `--as` is an ordinary address reference; `$ST_AGENT` carries the exact immutable agent ID +/// (`docs/vrs/03-message/spec.md`: selection follows one total order, and neither an address nor +/// `ST_AGENT` is heuristically retyped). So the two are not interchangeable strings once a subject +/// declares an explicit address. An ID that names no declared subject keeps its raw bytes, which +/// is what leaves flat, orphan, and `$ST2_EVAL_REQUESTER` mailboxes resolving exactly as they do +/// today; ambiguity cannot arise for a catalog-global ID, so absence is the only miss. +fn acting_route(root: &Path, host: &str, ctx: &MsgCtx) -> Result { + if let Some(address) = ctx.as_id.clone().filter(|value| !value.is_empty()) { + return Ok(address); + } + let id = acting_id(ctx)?; + match resolve_selected(root, host, st2::identity::AgentSelector::Id(id.clone())) { + Ok(selected) => Ok(selected.route), + Err(_) => Ok(id), + } +} + /// Resolve a recipient/identity to its agent folder in the catalog, or a clear error. fn agent_dir_of(root: &Path, id: &str, host: &str) -> Result { message::resolve_agent_dir(root, id, host)? .with_context(|| format!("no agent '{id}' found in catalog {}", root.display())) } +/// The one typed agent selector a CLI reference pair carries (R24). +/// +/// Every agent-selecting command exposes both forms and clap's `conflicts_with` keeps them +/// mutually exclusive, so at most one arrives here: an ordinary address reference, or an exact +/// immutable agent ID. +fn agent_selector( + reference: Option, + id: Option, +) -> Option { + match (reference, id) { + (_, Some(id)) => Some(st2::identity::AgentSelector::Id(id)), + (Some(reference), None) => Some(st2::identity::AgentSelector::Address(reference)), + (None, None) => None, + } +} + +/// [`agent_selector`] for a command that requires a target rather than defaulting to the actor. +fn one_selector( + reference: Option, + id: Option, +) -> Result { + agent_selector(reference, id) + .context("no agent selected: pass an agent reference or the exact `--id` form") +} + +/// The one subject a typed selector names, in both namespaces its consumers accept. +/// +/// These are two different strings once a subject declares an explicit `address`, and handing the +/// wrong one onward is exactly the ID-through-a-mutable-address hop decision 0015 forbids: +/// declaration-selecting commands match the positional key and never look at `address`, while +/// reference resolution for inboxes, status, context, and recipients answers on the address. +struct SelectedAgent { + /// `.` — the positional declaration key, which no address cutover changes. + declaration: String, + /// `.` — the current route. + route: String, +} + +/// Resolve one typed selector against the catalog. +/// +/// The address form stays on st2's existing reference resolution, which DELTA-003 keeps normative +/// until the identity model activates, so it carries the caller's bytes through unchanged. The ID +/// form is a catalog-global exact lookup that never falls through to address lookup — that is what +/// stops a renamed subject's old semantic ID from silently staying alive as a route. +fn resolve_selected( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + let id = match selector { + st2::identity::AgentSelector::Address(reference) => { + return Ok(SelectedAgent { + declaration: reference.clone(), + route: reference, + }); + } + st2::identity::AgentSelector::Id(id) => id, + }; + let found = discover(root); + // Retired subjects are present here and absent from the routable address book on purpose: + // retirement releases the address, never the ID, so an exact-ID selector still names its + // subject and `st2 describe` can still edit a retired declaration. + let entries = found + .specs + .iter() + .map(|spec| st2::identity::AddressBookEntry { + id: spec.effective_id(host), + host: spec.resolved_host(host).to_owned(), + address: spec.effective_address().to_owned(), + }) + .collect::>(); + let resolved = &st2::identity::resolve_id(&entries, &id)?.id; + let spec = found + .specs + .iter() + .find(|spec| &spec.effective_id(host) == resolved) + .context("the resolved agent id left the discovery it was resolved against")?; + Ok(SelectedAgent { + declaration: spec.bus_id(host), + route: spec.bus_address(host), + }) +} + +/// [`resolve_selected`] for a command that selects a declaration to read or author. +fn resolve_declaration( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + resolve_selected(root, host, selector).map(|selected| selected.declaration) +} + +/// [`resolve_selected`] for a command that resolves a route: an inbox, status, or recipient. +fn resolve_route( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + resolve_selected(root, host, selector).map(|selected| selected.route) +} + +/// The declaration selector for one CLI reference pair, or `None` when the command must fall back +/// to the caller's own ambient actor. +fn selected_declaration( + root: &Path, + host: &str, + reference: Option, + id: Option, +) -> Result> { + match agent_selector(reference, id) { + None => Ok(None), + Some(selector) => resolve_declaration(root, host, selector).map(Some), + } +} + +/// [`selected_declaration`] for a command that resolves a route rather than a declaration. +fn selected_route( + root: &Path, + host: &str, + reference: Option, + id: Option, +) -> Result> { + match agent_selector(reference, id) { + None => Ok(None), + Some(selector) => resolve_route(root, host, selector).map(Some), + } +} + /// Resolve ordinary declared messaging authority plus the exact external requester capability /// injected only into canonical eval seats. fn resolve_message_inbox(root: &Path, id: &str, host: &str) -> Result { @@ -2864,11 +3230,17 @@ fn body_or_stdin(body: Option) -> Result { } /// `[identity] ` positionals: if `second` is present, `first` is the identity; otherwise -/// `first` is the filename and the box belongs to the acting identity. -fn box_target(first: String, second: Option, ctx: &MsgCtx) -> Result<(String, String)> { - match second { - Some(filename) => Ok((first, filename)), - None => Ok((acting_id(ctx)?, first)), +/// `first` is the filename and the box belongs to `--id`, else to the acting identity. +fn box_target( + first: String, + second: Option, + owner: Option, + mine: impl FnOnce() -> Result, +) -> Result<(String, String)> { + match (second, owner) { + (Some(thing), _) => Ok((first, thing)), + (None, Some(owner)) => Ok((owner, first)), + (None, None) => Ok((mine()?, first)), } } @@ -2876,6 +3248,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { match cmd { MessageCmd::Send { to, + to_id, body, subject, in_reply_to, @@ -2884,7 +3257,8 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + let to = resolve_route(&root, &host, one_selector(to, to_id)?)?; + let from = acting_route(&root, &host, &ctx)?; let body = body_or_stdin(body)?; let filename = send_resolved_message( &root, @@ -2908,7 +3282,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + let from = acting_route(&root, &host, &ctx)?; let my_inbox = resolve_message_inbox(&root, &from, &host)?; let original = message::read_msg(&my_inbox, &filename) .or_else(|inbox_error| { @@ -2919,10 +3293,14 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { message::read_msg(&my_archive, &filename) }) .with_context(|| format!("no message '{filename}' in {}'s inbox", from))?; - let to = original - .from - .clone() - .with_context(|| format!("message '{filename}' has no `from` to reply to"))?; + // The reply target comes from the message's own provenance, not from re-parsing its + // `from` bytes: a version-2 record names an exact agent ID, and a legacy record's + // reassigned bus identity is attributed against this box's owner or refused outright. + let to = resolve_route( + &root, + &host, + message::reply_recipient(&root, Some(&from), &original)?, + )?; let subject = subject.or_else(|| message::reply_subject(original.subject.as_deref())); let body = body_or_stdin(body)?; let sent = send_resolved_message( @@ -2941,6 +3319,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Sent { identity, + agent_id, count, include_body, to, @@ -2949,7 +3328,10 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = identity.unwrap_or(acting_id(&ctx)?); + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(id) => id, + None => acting_route(&root, &host, &ctx)?, + }; let mut view = message::with_resolved_agent_dir(&root, &id, &host, |agent_dir| { message::list_sent(agent_dir, include_body) })?; @@ -2997,6 +3379,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Ls { identity, + agent_id, archive, orphan, count, @@ -3007,9 +3390,9 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(id) => id, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let dir = message::resolve_list_box(&root, &id, &host, archive, orphan)?; let mut msgs = if archive { @@ -3051,13 +3434,16 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { MessageCmd::Read { first, second, + agent_id, archive, raw, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; + let owner = selected_route(&root, &host, None, agent_id)?; + let (id, filename) = + box_target(first, second, owner, || acting_route(&root, &host, &ctx))?; let dir = if archive { message::resolve_archive(&root, &id, &host) } else { @@ -3086,9 +3472,16 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { print!("{}", m.body); Ok(()) } - MessageCmd::Archive { first, second, ctx } => { + MessageCmd::Archive { + first, + second, + agent_id, + ctx, + } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; + let owner = selected_route(&root, &host, None, agent_id)?; + let (id, filename) = + box_target(first, second, owner, || acting_route(&root, &host, &ctx))?; message::archive_resolved_message(&root, &id, &host, &filename)?; println!("archived"); Ok(()) @@ -3160,6 +3553,7 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { match cmd { EventCmd::Emit { recipient, + recipient_id, stream, event_id, key, @@ -3170,6 +3564,8 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; + let recipient = + resolve_route(&root, &host, one_selector(recipient, recipient_id)?)?; let body = body_or_stdin(body)?; let receipt = st2::event::emit( &root, @@ -3193,10 +3589,11 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { } fn stream_cmd(cmd: StreamCmd) -> Result<()> { - let (name, agent, json, ctx, launch, remove) = match cmd { + let (name, agent, agent_id, json, ctx, launch, remove) = match cmd { StreamCmd::Add { name, agent, + agent_id, command, adapter_argv, json, @@ -3208,14 +3605,15 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { (None, true) => None, (Some(_), false) => anyhow::bail!("stream add got both --command and adapter argv"), }; - (name, agent, json, ctx, launch, false) + (name, agent, agent_id, json, ctx, launch, false) } StreamCmd::Rm { name, agent, + agent_id, json, ctx, - } => (name, agent, json, ctx, None, true), + } => (name, agent, agent_id, json, ctx, None, true), }; let (root, host) = resolve_ctx(&ctx)?; let actor = ctx @@ -3223,9 +3621,12 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { .clone() .or_else(|| std::env::var("ST_AGENT").ok()) .filter(|value| !value.is_empty()); - let target = agent - .or_else(|| actor.clone()) - .context("no stream target: pass --agent, --as, or set $ST_AGENT")?; + let target = match selected_declaration(&root, &host, agent, agent_id)? { + Some(target) => target, + None => actor + .clone() + .context("no stream target: pass --agent, --agent-id, --as, or set $ST_AGENT")?, + }; if remove { let receipt = st2::agent_author::remove_stream(&root, &target, &host, actor.as_deref(), &name)?; @@ -3471,12 +3872,13 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { match cmd { ContextCmd::Read { identity, + agent_id, decisions, full, fresh_within, ctx, } => { - let dir = resolve_context_dir(identity, &ctx)?; + let dir = resolve_context_dir(identity, agent_id, &ctx)?; let view = if full { View::Full } else if decisions { @@ -3494,11 +3896,15 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { print!("{content}"); Ok(()) } - ContextCmd::Write { identity, ctx } => { + ContextCmd::Write { + identity, + agent_id, + ctx, + } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(identity) => identity, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let content = std::io::read_to_string(std::io::stdin()).context("reading context from stdin")?; @@ -3515,14 +3921,15 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { } ContextCmd::Append { identity, + agent_id, decision, why, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(identity) => identity, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let filename = message::with_resolved_state_dir( &root, @@ -3653,12 +4060,13 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { match cmd { ResourceCmd::Ls { identity, + agent_id, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, + let id = match selected_declaration(&root, &host, identity, agent_id)? { + Some(id) => id, None => acting_id(&ctx)?, }; let (identity, bindings) = resource_bindings(&root, &id, &host)?; @@ -3687,11 +4095,13 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { ResourceCmd::Read { first, second, + agent_id, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, name) = box_target(first, second, &ctx)?; + let owner = selected_declaration(&root, &host, None, agent_id)?; + let (id, name) = box_target(first, second, owner, || acting_id(&ctx))?; let (identity, bindings) = resource_bindings(&root, &id, &host)?; let binding = bindings .iter() @@ -3716,6 +4126,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { first, second, agent, + agent_id, wait, json, ctx, @@ -3724,7 +4135,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { let (selector, name) = match second { Some(name) => (first, name), None => { - let selector = match agent { + let selector = match selected_declaration(&root, &host, agent, agent_id)? { Some(agent) => agent, None => acting_id(&ctx)?, }; @@ -3810,6 +4221,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { inactive_reason, selector_json, agent, + agent_id, json, ctx, } => { @@ -3818,7 +4230,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { .map(serde_json::from_str) .transpose() .map_err(|error| anyhow::anyhow!("--selector-json is not valid JSON: {error}"))?; - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::add_resource_with_selector( &root, &target, @@ -3843,10 +4255,11 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { ResourceCmd::Remove { name, agent, + agent_id, json, ctx, } => { - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::remove_resource(&root, &target, &host, actor.as_deref(), &name)?; if json { @@ -3863,10 +4276,11 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { old, new, agent, + agent_id, json, ctx, } => { - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::rename_resource( &root, &target, @@ -3891,6 +4305,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { /// The catalog root, host, acting actor, and authored target for one mediated binding edit. fn resource_author_target( agent: Option, + agent_id: Option, ctx: &MsgCtx, ) -> Result<(PathBuf, String, Option, String)> { let (root, host) = resolve_ctx(ctx)?; @@ -3899,18 +4314,25 @@ fn resource_author_target( .clone() .or_else(|| std::env::var("ST_AGENT").ok()) .filter(|value| !value.is_empty()); - let target = agent - .or_else(|| actor.clone()) - .context("no resource binding target: pass --agent, --as, or set $ST_AGENT")?; + let target = match selected_declaration(&root, &host, agent, agent_id)? { + Some(target) => target, + None => actor.clone().context( + "no resource binding target: pass --agent, --agent-id, --as, or set $ST_AGENT", + )?, + }; Ok((root, host, actor, target)) } /// Resolve an agent's context dir (`/resources/context`). Identity defaults to `$ST_AGENT`. -fn resolve_context_dir(identity: Option, ctx: &MsgCtx) -> Result { +fn resolve_context_dir( + identity: Option, + agent_id: Option, + ctx: &MsgCtx, +) -> Result { let (root, host) = resolve_ctx(ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(ctx)?, + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(identity) => identity, + None => acting_route(&root, &host, ctx)?, }; Ok(st2::context::context_dir(&agent_dir_of(&root, &id, &host)?)) } @@ -4027,7 +4449,9 @@ fn up( let mut found = discover(&catalog_root); let ownership_specs = found.specs.clone(); if let Some(selector) = task.as_deref() { - let (owner, _, _) = st2::reconcile::resolve_task(&found.specs, selector, &this_host)?; + let activation = st2::reconcile::discovered_identity_activation(&catalog_root, &found); + let (owner, _, _) = + st2::reconcile::resolve_task(&found.specs, selector, &this_host, &activation)?; let owner_identity = owner.identity.clone(); let owner_path = owner.path.clone(); found diff --git a/src/materialize.rs b/src/materialize.rs index 913f45cf..ebdc962d 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -380,7 +380,15 @@ fn resolve_driver_render_executable(plan: &mut RenderPlan, agent: &str) -> Resul } /// Add either the typed driver render or the unchanged legacy delivery render. -fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result { +/// +/// `agent` is the runner-owned agent key the caller decided for this pass: the generated +/// `.mcp.json` declaration is an exact-ID consumer, not a human address parser. +fn effective_plan( + root: &Path, + spec: &AgentSpec, + this_host: &str, + agent: &str, +) -> Result { crate::driver::ensure_single_source(spec)?; let mut plan = parse_plan_with_driver(spec, this_host)?; if spec.driver.is_none() && spec.delivery == Some(agent_spec::spec::DeliveryTransport::Mcp) { @@ -390,7 +398,7 @@ fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result Result> { - let plan = effective_plan(root, spec, this_host)?; - let env = render_env(root, spec, this_host); + // A single-subject boundary holds no proof that the whole catalog is migrated, so it stays on + // the legacy projection — exactly as host-scoped socket admission does. The frozen ID equals + // the former bus identity, so this is byte-identical for every migrated subject. + let agent = spec.bus_id(this_host); + let plan = effective_plan(root, spec, this_host, &agent)?; + let env = render_env(root, spec, &agent); let spec_dir = spec.path.parent().unwrap_or(root); let mut inputs = BTreeSet::new(); for operation in plan.ops { @@ -442,8 +454,7 @@ pub(crate) fn catalog_owned_render_inputs( Ok(inputs.into_iter().collect()) } -fn render_env(root: &Path, spec: &AgentSpec, this_host: &str) -> BTreeMap { - let bus_id = spec.bus_id(this_host); +fn render_env(root: &Path, spec: &AgentSpec, agent: &str) -> BTreeMap { let mut env = BTreeMap::from([ ("CATALOG".to_string(), root.display().to_string()), ("ST_ROOT".to_string(), root.display().to_string()), @@ -451,7 +462,7 @@ fn render_env(root: &Path, spec: &AgentSpec, this_host: &str) -> BTreeMap BTreeMap Result>> { - let plan = effective_plan(root, spec, this_host)?; + let plan = effective_plan(root, spec, this_host, agent)?; if plan.ops.is_empty() { return Ok(BTreeMap::new()); } @@ -887,7 +899,7 @@ fn claims_for_agent( .workspace .as_deref() .with_context(|| format!("agent '{}' has render{{}} but no workspace", spec.identity))?; - let env = render_env(root, spec, this_host); + let env = render_env(root, spec, agent); let workspace = PathBuf::from(expand(workspace_raw, &env)); let workspace = workspace .canonicalize() @@ -971,16 +983,22 @@ pub fn render_ownership_conflicts( specs: &[AgentSpec], this_host: &str, ) -> Vec { + // One gate decision for the whole analysis: a per-subject decision would let a partially + // migrated catalog mix two identity models inside one comparison. + let activation = crate::reconcile::identity_activation(root, specs); let mut by_destination = BTreeMap::>>::new(); for spec in specs { if !spec.desired_state.is_running() || spec.resolved_host(this_host) != this_host { continue; } - let Ok(claims) = claims_for_agent(root, spec, this_host) else { + let agent = crate::reconcile::agent_key(spec, this_host, &activation); + let Ok(claims) = claims_for_agent(root, spec, this_host, &agent) else { // The normal per-agent materialization path reports malformed plans and unavailable // inputs. Ownership analysis only compares claims it can resolve without writing. continue; }; + // The report key stays the host-qualified bus identity: it is this report's join key with + // the caller's selection set, not an identity projection. let owner = spec.bus_id(this_host); for (destination, plan) in claims { by_destination @@ -1006,9 +1024,16 @@ pub fn render_ownership_conflicts( } /// Execute one agent's render plan in declaration order. -pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result> { - crate::reconcile::validate_task_identities(std::slice::from_ref(spec), this_host)?; - let plan = effective_plan(root, spec, this_host)?; +/// +/// `agent` is the runner-owned agent key the caller decided for its pass. +pub fn materialize_agent( + root: &Path, + spec: &AgentSpec, + this_host: &str, + agent: &str, +) -> Result> { + crate::reconcile::validate_agent_task_identity(spec, agent)?; + let plan = effective_plan(root, spec, this_host, agent)?; if plan.ops.is_empty() { return Ok(Vec::new()); } @@ -1029,7 +1054,7 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu .workspace .as_deref() .with_context(|| format!("agent '{}' has render{{}} but no workspace", spec.identity))?; - let env = render_env(root, spec, this_host); + let env = render_env(root, spec, agent); let workspace = PathBuf::from(expand(workspace_raw, &env)); if !workspace.is_dir() { anyhow::bail!( @@ -1203,8 +1228,14 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu } /// Validate an agent's render declaration and all catalog-owned inputs without writing its workspace. +/// +/// Host-scoped validation stays on the legacy projection, exactly as unbindable-socket admission +/// does: this boundary sees one subject and cannot prove the whole catalog is migrated. Because +/// migration freezes each live subject's ID to its former bus identity, that is byte-identical to +/// the activated projection for every migrated subject. pub fn validate_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<()> { - crate::reconcile::validate_task_identities(std::slice::from_ref(spec), this_host)?; + let agent = spec.bus_id(this_host); + crate::reconcile::validate_agent_task_identity(spec, &agent)?; let plan = parse_plan_with_driver(spec, this_host)?; if plan.ops.is_empty() { return Ok(()); @@ -1213,7 +1244,7 @@ pub fn validate_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result< .workspace .as_deref() .with_context(|| format!("agent '{}' has render{{}} but no workspace", spec.identity))?; - let env = render_env(root, spec, this_host); + let env = render_env(root, spec, &agent); let workspace = PathBuf::from(expand(workspace_raw, &env)); let spec_dir = spec.path.parent().unwrap_or(root); for op in plan.ops { @@ -1265,7 +1296,11 @@ pub fn materialize_catalog_against( .iter() .map(|spec| spec.bus_id(this_host)) .collect::>(); - if let Err(error) = crate::reconcile::validate_task_identities(ownership_specs, this_host) { + // One gate decision for this whole command, from the complete active fleet it was handed. + let activation = crate::reconcile::identity_activation(root, ownership_specs); + if let Err(error) = + crate::reconcile::validate_task_identities(ownership_specs, this_host, &activation) + { report.failed_agents.extend(selected_ids); report.errors.push(error.to_string()); return report; @@ -1292,7 +1327,8 @@ pub fn materialize_catalog_against( if report.failed_agents.contains(&bus_id) { continue; } - match materialize_agent(root, spec, this_host) { + let agent = crate::reconcile::agent_key(spec, this_host, &activation); + match materialize_agent(root, spec, this_host, &agent) { Ok(notes) => { for note in notes { if note.starts_with("WARN ") { @@ -1531,7 +1567,7 @@ mod tests { .unwrap(); let found = crate::discover(tmp.path()); assert!(found.errors.is_empty(), "{:?}", found.errors); - let plan = effective_plan(tmp.path(), &found.specs[0], "h").unwrap(); + let plan = effective_plan(tmp.path(), &found.specs[0], "h", "h.worker").unwrap(); let settings = plan .ops .iter() @@ -1549,4 +1585,43 @@ mod tests { let rendered: serde_json::Value = serde_json::from_str(settings.0).unwrap(); assert_eq!(rendered, crate::hooks::claude_settings_registration()); } + + /// The generated Claude MCP declaration selects its owner by exact ID, so `--identity` carries + /// whatever agent key the pass decided — the immutable agent ID once the model is activated. + #[test] + fn the_generated_mcp_declaration_carries_the_decided_agent_key() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("agents/h/worker/agent.kdl"); + std::fs::create_dir_all(declaration.parent().unwrap()).unwrap(); + std::fs::write( + &declaration, + r#"agent "worker" { host "h"; command "claude"; deliver "mcp"; workspace "$CATALOG" }"#, + ) + .unwrap(); + let found = crate::discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + for agent in ["h.worker", "01998f3a-2b7c-7c31-9f0e-2a6d4b8e5c10"] { + let plan = effective_plan(tmp.path(), &found.specs[0], "h", agent).unwrap(); + let declared = plan + .ops + .iter() + .find_map(|op| match op { + RenderOp::JsonUpsert { + destination, + content, + .. + } if destination == ".mcp.json" => Some(content), + _ => None, + }) + .expect("legacy mcp seats declare the st2 server"); + let rendered: serde_json::Value = serde_json::from_str(declared).unwrap(); + let args = rendered["mcpServers"]["st2"]["args"].as_array().unwrap(); + let identity = args + .windows(2) + .find(|pair| pair[0] == "--identity") + .expect("the declaration names its owner"); + assert_eq!(identity[1], serde_json::Value::String(agent.to_owned())); + } + } } diff --git a/src/message.rs b/src/message.rs index 4f4af844..d8d2115a 100644 --- a/src/message.rs +++ b/src/message.rs @@ -24,12 +24,16 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use st2_wire::message::{SentCoverage, SentMessageRow, SentMessages}; -/// The version every writer in this build emits for the sender-owned ledger records. +use crate::identity::{AgentSelector, ResolveError}; + +/// The version of every sender-owned ledger record other than the durable Sent record: the head, +/// active pointer, commit node, and key receipt are version 1 under both identity models. const SENT_VERSION: u32 = 1; -/// Reserved next version of the durable Sent *record* only (DELTA-003): identical to version 1 +/// The durable Sent *record* under the target identity model (DELTA-003): identical to version 1 /// except that `from`/`to` of an `agent` endpoint carry the immutable agent ID rather than the bus -/// identity, plus the four nullable endpoint fields below. Readers accept it; nothing writes it yet -/// — the head, active pointer, commit node and key receipt stay strictly version 1. +/// identity, plus the four endpoint fields below. A send emits it only for a catalog +/// [`crate::identity::activation`] proves fully migrated, so a partially migrated catalog keeps +/// writing byte-identical version-1 rows. const SENT_RECORD_VERSION_2: u32 = 2; const SENT_DIR: &str = "sent"; const SENT_HEAD: &str = "index.json"; @@ -87,6 +91,18 @@ enum EndpointKind { External, } +impl EndpointKind { + /// The wire spelling, so the public Sent row carries the kind without the wire crate having to + /// know st2's endpoint vocabulary. + fn as_str(self) -> &'static str { + match self { + Self::Agent => "agent", + Self::Principal => "principal", + Self::External => "external", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct SentRecord { @@ -129,6 +145,8 @@ impl SentRecord { priority: self.priority.clone(), idempotency_key: self.idempotency_key.clone(), body: include_body.then(|| self.body.clone()), + to_address: self.to_address.clone(), + to_kind: self.to_kind.map(|kind| kind.as_str().to_owned()), } } @@ -157,11 +175,10 @@ impl SentRecord { /// are legacy bus identities and the row declares no endpoint kind or address snapshot, so a /// version-1 row carrying those fields is refused rather than read as a version-2 row. /// -/// DELTA-003: still missing here is the collision-aware legacy attribution rule -/// (`docs/vrs/03-message/spec.md` "An archived collision is the one exception"). Retyping a -/// version-1 endpoint as an immutable agent ID needs the migration's durable collision metadata — -/// which subject kept each reassigned bus identity — and lands with that metadata. Until then a -/// version-1 endpoint is left exactly as written: a legacy bus identity, never an ID. +/// What a version-1 legacy endpoint denotes under the target model is decided by +/// [`attribute_endpoint`] against the migration's durable collision metadata, not here: the same +/// bytes are a frozen ID for an untouched subject and an ambiguous historical address for a +/// reassigned one, and only the row's own state owner can tell those apart. fn validate_sent_record_version(record: &SentRecord) -> anyhow::Result<()> { anyhow::ensure!( record.version == SENT_VERSION || record.version == SENT_RECORD_VERSION_2, @@ -191,8 +208,12 @@ pub struct Message { pub filename: String, /// Send time in unix ms (from the filename prefix). pub ts_ms: u64, - /// `from:` — the claimed sender. + /// `from:` — the claimed sender, as a human reads it: the publication-time bus address of an + /// activated sender, else its legacy bus identity. Display only; never a selector. pub from: Option, + /// `from-id:` — the sender's immutable agent ID, the only authority a reader may act on + /// (`MESSAGE-R04`). Absent on a version-1 rendered message, whose `from` is legacy bytes. + pub from_id: Option, /// `subject:`. pub subject: Option, /// `in-reply-to:` — the filename of the message this replies to. @@ -272,11 +293,16 @@ pub fn render_message( tags: &[String], body: &str, ) -> String { - render_message_with_idempotency(from, subject, in_reply_to, tags, body, None) + render_message_with_idempotency(from, None, subject, in_reply_to, tags, body, None) } +/// `from` is what a human reads — the sender's current bus address once the identity model is +/// active — and `from_id` is the immutable ID a reader resolves. A legacy sender passes `None` and +/// renders byte-identical version-1 frontmatter. +#[allow(clippy::too_many_arguments)] fn render_message_with_idempotency( from: &str, + from_id: Option<&str>, subject: Option<&str>, in_reply_to: Option<&str>, tags: &[String], @@ -285,6 +311,9 @@ fn render_message_with_idempotency( ) -> String { let mut s = String::from("---\n"); s.push_str(&format!("from: {from}\n")); + if let Some(id) = from_id { + s.push_str(&format!("from-id: {id}\n")); + } if let Some(subj) = subject { s.push_str(&format!("subject: {subj}\n")); } @@ -316,6 +345,7 @@ pub(crate) fn parse_message(filename: &str, contents: &str) -> Message { filename: filename.to_string(), ts_ms, from: None, + from_id: None, subject: None, in_reply_to: None, tags: Vec::new(), @@ -345,6 +375,7 @@ pub(crate) fn parse_message(filename: &str, contents: &str) -> Message { let v = v.trim(); match k.trim() { "from" => msg.from = Some(v.to_string()), + "from-id" => msg.from_id = Some(v.to_string()), "subject" => msg.subject = Some(v.to_string()), "in-reply-to" => msg.in_reply_to = Some(v.to_string()), "tags" => { @@ -383,9 +414,23 @@ pub fn send_to_inbox( in_reply_to: Option<&str>, tags: &[String], body: &str, +) -> anyhow::Result { + send_to_inbox_as(inbox_dir, from, None, subject, in_reply_to, tags, body) +} + +/// [`send_to_inbox`] with an explicit sender authority: `from` is the bus address a human reads and +/// `from_id` the immutable agent ID a reader may act on. `None` keeps version-1 frontmatter. +pub(crate) fn send_to_inbox_as( + inbox_dir: &Path, + from: &str, + from_id: Option<&str>, + subject: Option<&str>, + in_reply_to: Option<&str>, + tags: &[String], + body: &str, ) -> anyhow::Result { fs::create_dir_all(inbox_dir)?; - let contents = render_message(from, subject, in_reply_to, tags, body); + let contents = render_message_with_idempotency(from, from_id, subject, in_reply_to, tags, body, None); // This deliberately cannot match `is_message_filename`, so a concurrent scan ignores it. let tmp = inbox_dir.join(tmp_name()); if let Err(error) = fs::write(&tmp, &contents) { @@ -1028,17 +1073,18 @@ pub fn resolve_list_box( }); } let discovered = crate::discover(root); - if let Some(agent_dir) = discovered - .specs - .iter() - .find(|spec| spec.bus_id(host) == id || spec.identity == id) - .and_then(|spec| spec.path.parent()) - { - return Ok(if archive { - archive_dir(agent_dir) - } else { - inbox_dir(agent_dir) - }); + match select_spec(&discovered.specs, &AgentSelector::Address(id.to_owned()), host) { + Ok(spec) => { + if let Some(agent_dir) = spec.path.parent() { + return Ok(if archive { + archive_dir(agent_dir) + } else { + inbox_dir(agent_dir) + }); + } + } + Err(ResolveError::Unknown { .. }) => {} + Err(error) => return Err(error.into()), } if discovered.specs.is_empty() && discovered.errors.is_empty() { @@ -1047,14 +1093,30 @@ pub fn resolve_list_box( anyhow::bail!("no agent '{id}' found in catalog {}", root.display()) } -/// Resolve a recipient (a bus id `.` or a bare identity) to its agent folder in the -/// catalog, via content discovery. Returns `None` if no agent matches. +/// Resolve an ordinary agent reference — a bare or host-qualified address — to its agent folder in +/// the catalog, via content discovery. `None` when no subject carries that address; an ambiguous +/// reference is an error, never a silent absence. pub fn resolve_agent_dir( catalog_root: &Path, recipient: &str, this_host: &str, ) -> anyhow::Result> { - Ok(resolve_agent_handle(catalog_root, recipient, this_host)?.map(|agent| agent.path)) + resolve_selected_agent_dir( + catalog_root, + &AgentSelector::Address(recipient.to_owned()), + this_host, + ) +} + +/// [`resolve_agent_dir`] for either selector form. A command that defaults its subject from +/// `ST_AGENT` must pass [`AgentSelector::Id`]: an exact ID performs only ID lookup and never falls +/// through to address lookup, so a renamed subject's released address cannot resolve as its ID. +pub fn resolve_selected_agent_dir( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result> { + Ok(optional_agent_handle(catalog_root, selector, this_host)?.map(|agent| agent.path)) } pub fn with_resolved_agent_dir( @@ -1074,8 +1136,30 @@ pub fn with_resolved_state_dir( create: bool, operation: impl FnOnce(&Path) -> anyhow::Result, ) -> anyhow::Result { - match resolve_agent_handle(catalog_root, identity, this_host)? { - Some(agent) => { + with_selected_state_dir( + catalog_root, + &AgentSelector::Address(identity.to_owned()), + this_host, + components, + create, + operation, + ) +} + +/// [`with_resolved_state_dir`] for either selector form. An exact ID performs only ID lookup, so a +/// subject whose address differs from its ID — or whose ID is a UUIDv7 that is in no address +/// namespace at all — still reaches its own state. +pub fn with_selected_state_dir( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, + components: &[&str], + create: bool, + operation: impl FnOnce(&Path) -> anyhow::Result, +) -> anyhow::Result { + let identity = selector_reference(selector); + match find_agent_handle(catalog_root, selector, this_host)? { + Ok(agent) => { test_capability_checkpoint(); let path = match agent.capability.as_ref() { Some(capability) if components.is_empty() => { @@ -1093,14 +1177,18 @@ pub fn with_resolved_state_dir( }; operation(&path) } - None => { + Err(error) => { + // The one caller that reads absence as a decision rather than a fault: a provably + // fresh root is the legacy flat bus, whose state directory is created on first use. + // Ambiguity is never that decision, so it keeps the address diagnostic. let discovered = crate::discover(catalog_root); anyhow::ensure!( - crate::catalog_transaction::catalog_transition(catalog_root)?.is_none() + matches!(error, ResolveError::Unknown { .. }) + && crate::catalog_transaction::catalog_transition(catalog_root)?.is_none() && !catalog_root.join(crate::catalog_lock::CONTROL_DIR).exists() && discovered.specs.is_empty() && discovered.errors.is_empty(), - "no agent '{identity}' found in catalog {}", + "no agent '{identity}' found in catalog {}: {error}", catalog_root.display() ); operation( @@ -1119,18 +1207,16 @@ pub fn with_resolved_state_dir( /// Both directories are opened relative to the resolved agent capability, so replacing the /// declaration directory or either message-box ancestor with a symlink cannot redirect the /// operation outside the catalog after recipient resolution. -pub(crate) fn with_resolved_message_boxes( +/// +/// Every caller has already selected its recipient — by exact ID under the active identity model, +/// by ordinary address otherwise — so this takes the selector directly. +pub(crate) fn with_selected_message_boxes( catalog_root: &Path, - identity: &str, + selector: &AgentSelector, this_host: &str, operation: impl FnOnce(&Path, &Path) -> anyhow::Result, ) -> anyhow::Result { - let agent = resolve_agent_handle(catalog_root, identity, this_host)?.with_context(|| { - format!( - "no agent '{identity}' found in catalog {}", - catalog_root.display() - ) - })?; + let agent = require_agent_handle(catalog_root, selector, this_host)?; let capability = agent .capability .as_ref() @@ -1145,26 +1231,133 @@ pub(crate) fn with_resolved_message_boxes( ) } -fn resolve_agent_handle( +/// Locate one agent on a single coherent address book, reporting why an ordinary reference did not +/// name exactly one subject (`R24`). +/// +/// The catalog-generation and transition fence is sampled before and after the walk, and the walk +/// is retried when it moved, so the answer always comes from one before-or-after snapshot of the +/// address book. That is exactly what an atomic address cutover needs: a lookup sees the old +/// address book or the new one, never a torn mixture in which a cut-over address resolves twice or +/// not at all. +fn find_agent_handle( catalog_root: &Path, - recipient: &str, + selector: &AgentSelector, this_host: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { for _ in 0..3 { let before = address_fence(catalog_root)?; - let mut candidates = addressable_agent_dirs(catalog_root, this_host, before.1.as_ref())? - .into_iter() - .filter(|candidate| candidate.bus_id == recipient || candidate.identity == recipient) - .collect::>(); + let candidates = addressable_agent_dirs(catalog_root, this_host, before.1.as_ref())?; let after = address_fence(catalog_root)?; if before != after { continue; } - candidates.sort_by(|left, right| left.path.cmp(&right.path)); - candidates.dedup_by(|left, right| left.path == right.path); - return Ok((candidates.len() == 1).then(|| candidates.remove(0))); + return Ok(select_agent(candidates, selector)); + } + anyhow::bail!("catalog address book changed repeatedly while resolving {selector:?}") +} + +/// Resolve or fail with the address-specific diagnostic attached to the caller's own message. +fn require_agent_handle( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result { + find_agent_handle(catalog_root, selector, this_host)? + .map_err(anyhow::Error::new) + .with_context(|| { + format!( + "no agent '{}' found in catalog {}", + selector_reference(selector), + catalog_root.display() + ) + }) +} + +/// Absence-tolerant lookup for callers whose next step depends on "no such subject" — an external +/// requester mailbox, a flat compat box. An ambiguous reference is not absence and stays an error. +fn optional_agent_handle( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result> { + match find_agent_handle(catalog_root, selector, this_host)? { + Ok(agent) => Ok(Some(agent)), + Err(ResolveError::Unknown { .. }) => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// The literal bytes a caller named, for diagnostics only. +fn selector_reference(selector: &AgentSelector) -> &str { + match selector { + AgentSelector::Id(id) => id, + AgentSelector::Address(reference) => reference, } - anyhow::bail!("catalog address book changed repeatedly while resolving {recipient:?}") +} + +fn select_agent( + candidates: Vec, + selector: &AgentSelector, +) -> std::result::Result { + let index = select_index( + &candidates, + selector, + AddressableAgent::entry, + |candidate| candidate.retired, + )?; + Ok(candidates.into_iter().nth(index).expect("selected index")) +} + +/// Resolve an ordinary reference against a discovered catalog's specs. +fn select_spec<'a>( + specs: &'a [crate::AgentSpec], + selector: &AgentSelector, + this_host: &str, +) -> std::result::Result<&'a crate::AgentSpec, ResolveError> { + let index = select_index( + specs, + selector, + |spec| crate::identity::AddressBookEntry { + id: spec.effective_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + }, + |spec| spec.desired_state.is_retired(), + )?; + Ok(&specs[index]) +} + +/// Pick the one candidate a selector names, through the address algorithm rather than a precedence +/// rule (`R24`). +/// +/// Two books, not a tie-break: retirement releases the address, so a retired subject must not make +/// a live claimant's reference ambiguous. It answers to its own declaration address only when no +/// routable subject answers at all, which keeps its retained state — status, context, message +/// boxes — reachable by name exactly as it is today. +fn select_index( + candidates: &[T], + selector: &AgentSelector, + entry: impl Fn(&T) -> crate::identity::AddressBookEntry, + retired: impl Fn(&T) -> bool, +) -> std::result::Result { + let book = candidates.iter().map(&entry).collect::>(); + let routable = candidates + .iter() + .zip(&book) + .filter(|(candidate, _)| !retired(candidate)) + .map(|(_, entry)| entry.clone()) + .collect::>(); + let resolved = match crate::identity::resolve(&routable, selector, None) { + Err(ResolveError::Unknown { .. }) if routable.len() != book.len() => { + crate::identity::resolve(&book, selector, None) + } + other => other, + }; + let id = &resolved?.id; + Ok(book + .iter() + .position(|candidate| &candidate.id == id) + .expect("the resolved entry came from this candidate set")) } fn address_fence( @@ -1210,12 +1403,36 @@ fn test_address_fence_checkpoint() {} #[derive(Debug)] struct AddressableAgent { + /// The immutable catalog-global agent ID — the legacy bus identity migration freezes, until + /// this subject is migrated. + id: String, + /// Today's legacy `.` bus identity. Still the normative canonical endpoint of + /// a durable record while [`crate::identity::activation`] reports `Legacy`. bus_id: String, - identity: String, + host: String, + /// The effective mutable address: declared `address`, else the positional identity. + address: String, + /// A retired subject is non-routable and has released its address. + retired: bool, path: PathBuf, capability: Option, } +impl AddressableAgent { + fn entry(&self) -> crate::identity::AddressBookEntry { + crate::identity::AddressBookEntry { + id: self.id.clone(), + host: self.host.clone(), + address: self.address.clone(), + } + } + + /// The publication-time bus address snapshot `.
`: display only. + fn bus_address(&self) -> String { + format!("{}.{}", self.host, self.address) + } +} + fn addressable_agent_dirs( catalog_root: &Path, this_host: &str, @@ -1233,8 +1450,11 @@ fn addressable_agent_dirs( .to_path_buf(); let capability = crate::catalog_transaction::open_dir_beneath(catalog_root, &path)?; Ok(AddressableAgent { + id: spec.effective_id(this_host), bus_id: spec.bus_id(this_host), - identity: spec.identity, + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + retired: spec.desired_state.is_retired(), path, capability: Some(capability), }) @@ -1268,9 +1488,18 @@ fn addressable_agent_dirs( let retained_state = transition.original_agents.contains(&key) && marker_state_exists(&retained)?; if current_spec || retained_state { + // Keyed on the legacy `/` pair, not on a declared address: mid + // transition the declaration bytes under this directory are not readable, so the + // positional pair is the only coherent key available. This branch locates a + // retained *state* directory for a catalog being applied; it is not a route being + // resolved, and a subject whose declaration is mid-apply has no observable + // desired state to call retired. result.push(AddressableAgent { + id: format!("{}.{}", key.host, key.identity), bus_id: format!("{}.{}", key.host, key.identity), - identity: key.identity, + host: key.host, + address: key.identity, + retired: false, path, capability: Some(capability), }); @@ -1400,6 +1629,86 @@ pub fn reply_subject(original: Option<&str>) -> Option { }) } +/// What a version-1 endpoint's legacy bytes denote under the target identity model +/// (`MESSAGE-R04`, `docs/vrs/03-message/spec.md`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LegacyAttribution { + /// Migration froze these bytes as the immutable ID of the subject that already held them, so + /// they still name exactly one subject and remain a usable ordinary reference. + Frozen { reference: String }, + /// A reassigned colliding bus identity at the record's own state owner — the sender of a + /// sender-owned row, the recipient of an inbox row. That owner proves which subject the bytes + /// denote, so the migrated ID carries reply and automation authority. + Owned { id: String }, + /// A reassigned colliding bus identity at any other endpoint. Two subjects claimed these + /// bytes, so they are a historical address and nothing more: rendering them as the keeping + /// subject's ID would silently address the live replacement. + Historical { address: String }, +} + +/// Read one version-1 legacy endpoint against the migration's durable collision metadata. +/// +/// `state_owner` is the legacy bus identity of the subject that owns the record's state; `None` +/// when the caller cannot prove one, which makes every colliding endpoint historical. +pub fn attribute_endpoint( + migration: Option<&crate::catalog_migrate_ids::MigrationRecord>, + endpoint: &str, + state_owner: Option<&str>, +) -> LegacyAttribution { + let reassigned = migration.is_some_and(|record| { + record + .reassigned + .iter() + .any(|entry| entry.legacy_bus_identity == endpoint) + }); + if !reassigned { + return LegacyAttribution::Frozen { + reference: endpoint.to_owned(), + }; + } + match crate::catalog_migrate_ids::attribute_legacy_endpoint(migration, endpoint, state_owner) { + Some(id) => LegacyAttribution::Owned { id }, + None => LegacyAttribution::Historical { + address: endpoint.to_owned(), + }, + } +} + +/// The selector a reply to `message` must use (`MESSAGE-R04`). +/// +/// A version-2 rendered message carries its sender's immutable ID as authority, so the reply is an +/// exact-ID send that survives any later address cutover. A version-1 one carries legacy bytes: +/// they stay an ordinary address reference unless migration reassigned them, in which case only +/// this box's own owner is attributable and any other colliding endpoint is refused rather than +/// pointed at the live replacement. +/// +/// `inbox_owner` is the legacy bus identity of the box `message` was read from — an inbox row's +/// state owner is its recipient. +pub fn reply_recipient( + catalog_root: &Path, + inbox_owner: Option<&str>, + message: &Message, +) -> anyhow::Result { + if let Some(id) = &message.from_id { + return Ok(AgentSelector::Id(id.clone())); + } + let from = message + .from + .as_deref() + .with_context(|| format!("message '{}' has no `from` to reply to", message.filename))?; + let migration = crate::catalog_migrate_ids::read_migration_record(catalog_root)?; + match attribute_endpoint(migration.as_ref(), from, inbox_owner) { + LegacyAttribution::Frozen { reference } => Ok(AgentSelector::Address(reference)), + LegacyAttribution::Owned { id } => Ok(AgentSelector::Id(id)), + LegacyAttribution::Historical { address } => anyhow::bail!( + "message '{}' names the legacy bus identity '{address}', which catalog id migration reassigned: \ +it is a historical address with no attributable subject, so replying to it is refused rather than \ +addressed to the subject that kept those bytes", + message.filename + ), + } +} + /// One line of a message thread: the message plus its reply depth (0 = the thread root). #[derive(Debug, Clone)] pub struct ThreadEntry { @@ -1508,10 +1817,18 @@ enum DeliveryEndpoint { } impl DeliveryEndpoint { - fn bus_id(&self) -> &str { + /// This endpoint in the two namespaces DELTA-003 separates. + fn endpoint(&self, activated: bool) -> Endpoint { match self { - Self::Agent(agent) => &agent.bus_id, - Self::External { bus_id, .. } | Self::Flat { bus_id, .. } => bus_id, + Self::Agent(agent) => Endpoint::agent(agent, activated), + // An external requester mailbox and a flat compat box are not declared Agents: their + // canonical address *is* the endpoint, under either identity model. + Self::External { bus_id, .. } | Self::Flat { bus_id, .. } => Endpoint { + canonical: bus_id.clone(), + address: None, + id: None, + kind: EndpointKind::External, + }, } } @@ -1531,6 +1848,70 @@ impl DeliveryEndpoint { } } +/// One endpoint of a publication, in the two namespaces DELTA-003 separates. +struct Endpoint { + /// What the durable record persists in `from`/`to`: the immutable agent ID once the identity + /// model is active, else today's legacy bus identity. A non-Agent endpoint keeps its canonical + /// address here under both models. + canonical: String, + /// The publication-time bus address snapshot: display only, never a selector. + address: Option, + /// The immutable agent ID this endpoint publishes as authority. `Some` only once the identity + /// model is active, so a legacy publication's bytes stay byte-for-byte identical. + id: Option, + kind: EndpointKind, +} + +impl Endpoint { + fn agent(agent: &AddressableAgent, activated: bool) -> Self { + if activated { + return Self { + canonical: agent.id.clone(), + address: Some(agent.bus_address()), + id: Some(agent.id.clone()), + kind: EndpointKind::Agent, + }; + } + Self { + canonical: agent.bus_id.clone(), + address: None, + id: None, + kind: EndpointKind::Agent, + } + } + + /// What a human reads for this endpoint: its current bus address, falling back to the canonical + /// bytes when the subject has no resolvable address. + fn display(&self) -> &str { + self.address.as_deref().unwrap_or(&self.canonical) + } +} + +/// Whether this catalog's durable writers use the target identity model. +/// +/// Consulted once per publication, never per endpoint: a partially migrated catalog has no +/// coherent ID namespace, so the gate is all-or-nothing. An unreadable catalog, an unexplained +/// archive, or a root with no declared subject at all — the legacy flat compat bus — is not a +/// migrated catalog, so it keeps every current invariant normative. +fn identity_activated(catalog_root: &Path) -> bool { + let found = crate::discover_strict(catalog_root); + if !found.errors.is_empty() || found.specs.is_empty() { + return false; + } + let Ok(observation) = crate::catalog_archive::observe(catalog_root) else { + return false; + }; + if !observation.issues.is_empty() { + return false; + } + crate::identity::activation_from( + &found.specs, + &observation.archived, + crate::catalog_migrate_ids::marker_path(catalog_root).exists(), + ) + .is_activated() +} + fn catalogless(root: &Path) -> bool { let discovered = crate::discover(root); crate::catalog_transaction::catalog_transition(root) @@ -1543,13 +1924,14 @@ fn catalogless(root: &Path) -> bool { fn resolve_delivery_endpoint( root: &Path, - recipient: &str, + recipient: &AgentSelector, host: &str, external: Option<&ExternalInbox>, ) -> anyhow::Result { - if let Some(agent) = resolve_agent_handle(root, recipient, host)? { + if let Some(agent) = optional_agent_handle(root, recipient, host)? { return Ok(DeliveryEndpoint::Agent(agent)); } + let recipient = selector_reference(recipient); if let Some(external) = external && external.root == root && external.identity == recipient @@ -1571,6 +1953,7 @@ fn resolve_delivery_endpoint( anyhow::bail!("no agent '{recipient}' found in catalog {}", root.display()) } +/// Send by ordinary address references for both endpoints. #[allow(clippy::too_many_arguments)] pub fn send_to_resolved_inbox( catalog_root: &Path, @@ -1583,12 +1966,45 @@ pub fn send_to_resolved_inbox( body: &str, idempotency_key: Option<&str>, external: Option<&ExternalInbox>, +) -> anyhow::Result { + send_selected_to_resolved_inbox( + catalog_root, + &AgentSelector::Address(recipient.to_owned()), + this_host, + &AgentSelector::Address(from.to_owned()), + subject, + in_reply_to, + tags, + body, + idempotency_key, + external, + ) +} + +/// [`send_to_resolved_inbox`] for either selector form at either endpoint. +/// +/// An exact-ID endpoint is the form a reply to a version-2 message and an `ST_AGENT`-defaulted +/// sender use: both name a subject that must not be re-resolved through a mutable address. +#[allow(clippy::too_many_arguments)] +pub fn send_selected_to_resolved_inbox( + catalog_root: &Path, + recipient: &AgentSelector, + this_host: &str, + sender: &AgentSelector, + subject: Option<&str>, + in_reply_to: Option<&str>, + tags: &[String], + body: &str, + idempotency_key: Option<&str>, + external: Option<&ExternalInbox>, ) -> anyhow::Result { if let Some(key) = idempotency_key { validate_idempotency_key(key)?; } + let from = selector_reference(sender); let recipient = resolve_delivery_endpoint(catalog_root, recipient, this_host, external)?; - let sender = resolve_agent_handle(catalog_root, from, this_host)?; + let sender = optional_agent_handle(catalog_root, sender, this_host)?; + let activated = identity_activated(catalog_root); let external_sender = external.is_some_and(|external| external.root == catalog_root && external.identity == from); if matches!(&recipient, DeliveryEndpoint::External { .. }) || external_sender { @@ -1596,27 +2012,38 @@ pub fn send_to_resolved_inbox( idempotency_key.is_none(), "external requester messages do not own an ordinary sent-message index" ); - let canonical_from = sender + let sender_endpoint = sender .as_ref() - .map(|agent| agent.bus_id.as_str()) - .unwrap_or(from); + .map(|agent| Endpoint::agent(agent, activated)); anyhow::ensure!( sender.is_some() || external_sender, "no agent '{from}' found in catalog {}", catalog_root.display() ); let (inbox, _) = recipient.boxes()?; - return send_to_inbox(&inbox, canonical_from, subject, in_reply_to, tags, body); + let (display, id) = match sender_endpoint.as_ref() { + Some(endpoint) => (endpoint.display(), endpoint.id.as_deref()), + None => (from, None), + }; + return send_to_inbox_as(&inbox, display, id, subject, in_reply_to, tags, body); } - let (canonical_from, sender_root) = match sender.as_ref() { + let (sender_endpoint, sender_root) = match sender.as_ref() { Some(agent) => { let path = match agent.capability.as_ref() { Some(capability) => crate::catalog_transaction::retained_dir_path(capability)?, None => agent.path.clone(), }; - (agent.bus_id.clone(), path) + (Endpoint::agent(agent, activated), path) } - None if catalogless(catalog_root) => (from.to_string(), catalog_root.join(from)), + None if catalogless(catalog_root) => ( + Endpoint { + canonical: from.to_string(), + address: None, + id: None, + kind: EndpointKind::External, + }, + catalog_root.join(from), + ), None => anyhow::bail!( "no agent '{from}' found in catalog {}", catalog_root.display() @@ -1628,8 +2055,9 @@ pub fn send_to_resolved_inbox( this_host, external, &sender_root, - &canonical_from, + &sender_endpoint, recipient, + activated, subject, in_reply_to, tags, @@ -1644,8 +2072,9 @@ fn send_with_ledger( this_host: &str, external: Option<&ExternalInbox>, sender_root: &Path, - from: &str, + sender: &Endpoint, recipient: DeliveryEndpoint, + activated: bool, subject: Option<&str>, in_reply_to: Option<&str>, tags: &[String], @@ -1660,15 +2089,31 @@ fn send_with_ledger( let recovered = recover_active(catalog_root, this_host, external, &root, &mut head)?; let filename = new_filename(); - let rendered_message = - render_message_with_idempotency(from, subject, in_reply_to, tags, body, idempotency_key); + let to = recipient.endpoint(activated); + let rendered_message = render_message_with_idempotency( + sender.display(), + sender.id.as_deref(), + subject, + in_reply_to, + tags, + body, + idempotency_key, + ); let parsed = parse_message(&filename, &rendered_message); let candidate = SentRecord { - version: SENT_VERSION, + // The gate: a fully migrated catalog persists immutable IDs with their publication-time + // address snapshots; anything else emits byte-identical version-1 bytes, whose pending + // filename is the digest of its canonical JSON — one stray key would break retry and + // recovery of an interrupted legacy publication. + version: if activated { + SENT_RECORD_VERSION_2 + } else { + SENT_VERSION + }, filename, ts: parsed.ts_ms, - from: from.to_string(), - to: recipient.bus_id().to_string(), + from: sender.canonical.clone(), + to: to.canonical.clone(), subject: parsed.subject, in_reply_to: parsed.in_reply_to, tags: parsed.tags, @@ -1676,12 +2121,10 @@ fn send_with_ledger( idempotency_key: parsed.idempotency_key, body: parsed.body, rendered_message, - // Reader-first rollout: this writer stays on version 1, so `from`/`to` remain legacy bus - // identities and the version-2 endpoint fields stay absent from the written bytes. - from_address: None, - to_address: None, - from_kind: None, - to_kind: None, + from_address: activated.then(|| sender.address.clone()).flatten(), + to_address: activated.then(|| to.address.clone()).flatten(), + from_kind: activated.then_some(sender.kind), + to_kind: activated.then_some(to.kind), }; if let Some(existing) = keyed_record(&root, &candidate)? { return Ok(existing.filename); @@ -1847,9 +2290,19 @@ fn recover_active( (Some(_), []) => anyhow::bail!("active sent intent has no recoverable pending record"), _ => unreachable!(), }; - let recipient = resolve_delivery_endpoint(catalog_root, &record.to, this_host, external)?; + // The interrupted record's own version says which namespace its recipient endpoint is in, so a + // version-2 intent is recovered by immutable ID: an address cutover between the pending write + // and the retry is a nondisruptive cutover, not a changed recipient. A version-1 intent stays + // bound to the exact legacy bus identity it was written with. + let activated = record.version == SENT_RECORD_VERSION_2; + let selector = if activated { + AgentSelector::Id(record.to.clone()) + } else { + AgentSelector::Address(record.to.clone()) + }; + let recipient = resolve_delivery_endpoint(catalog_root, &selector, this_host, external)?; anyhow::ensure!( - recipient.bus_id() == record.to, + recipient.endpoint(activated).canonical == record.to, "pending recipient identity changed" ); deliver_record(&recipient, &record)?; @@ -2250,7 +2703,11 @@ pub fn archive_resolved_message( is_message_filename(filename), "invalid message filename {filename:?}" ); - let agent = match resolve_agent_handle(catalog_root, identity, this_host)? { + let agent = match optional_agent_handle( + catalog_root, + &AgentSelector::Address(identity.to_owned()), + this_host, + )? { Some(agent) => agent, None => { let discovered = crate::discover(catalog_root); diff --git a/src/omp_session.rs b/src/omp_session.rs index fd494a67..c5843c64 100644 --- a/src/omp_session.rs +++ b/src/omp_session.rs @@ -88,9 +88,11 @@ pub fn run( let executable = std::env::current_exe().context("resolving st2 executable for the omp channel")?; let session = harness_state::session_token(); + // One gate decision per driver process, taken before the claim it keys. + let actor = harness_state::RecordIdentity::for_driver(catalog_root, &identity); // The claim is written: it supersedes whatever the predecessor left — including a // still-fresh live record — before the channel or terminal writer act under it. - let seq = harness_state::claim(&agent_dir, identity.clone(), "omp", &session)?; + let seq = harness_state::claim(&agent_dir, actor.clone(), "omp", &session)?; // Every fallible step past the claim must end the record honestly on failure — the claim // placeholder standing as the last word would read as a takeover, not a launch that never // ran. @@ -116,7 +118,7 @@ pub fn run( Err(error) => { let mut writer = harness_state::Writer::new( &agent_dir, - identity.clone(), + actor.clone(), "omp", Some(runtime_id.clone()), ) @@ -139,7 +141,7 @@ pub fn run( // record fences exactly this session's live records. let observer = crate::provider_session::SessionObserver::terminal_only( &agent_dir, - &identity, + actor.clone(), "omp", &runtime_id, &session, @@ -156,7 +158,7 @@ pub fn run( Some(&observer), ) .with_context(|| format!("running omp driver '{runtime_id}'"))?; - record_session_end(&agent_dir, &identity, &runtime_id, &session, seq, &outcome); + record_session_end(&agent_dir, &actor, &runtime_id, &session, seq, &outcome); match outcome { ProviderOutcome::Exited(exit) => { anyhow::ensure!(exit.success(), "omp provider exited with {exit}"); @@ -195,7 +197,7 @@ fn verify_supported_version(binary: &str) -> Result<()> { /// that the provider process is gone. fn record_session_end( agent_dir: &Path, - identity: &str, + actor: &harness_state::RecordIdentity, runtime_id: &str, session: &str, seq: u64, @@ -206,7 +208,7 @@ fn record_session_end( ProviderOutcome::Stopped(None) => "stopped".to_string(), }; let mut writer = - harness_state::Writer::new(agent_dir, identity, "omp", Some(runtime_id.to_string())) + harness_state::Writer::new(agent_dir, actor.clone(), "omp", Some(runtime_id.to_string())) .with_ownership(session, seq); if let Err(error) = writer.ended(label) { eprintln!("st2 omp driver: recording session end failed: {error}"); @@ -506,4 +508,29 @@ mod tests { assert!(argv[2].ends_with("omp-channel.ts")); assert_eq!(argv[3], "--model"); } + + /// The omp wrapper's terminal write under an activated catalog: the actor `run` resolved once + /// at start decides the record's `agent` bytes and its version together. + #[test] + fn an_activated_wrapper_ends_the_session_under_version_2() { + use std::os::unix::process::ExitStatusExt as _; + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + + let tmp = tempfile::tempdir().unwrap(); + record_session_end( + tmp.path(), + &harness_state::RecordIdentity::activated(AGENT_ID), + "h.worker", + "session-test", + 1, + &ProviderOutcome::Exited(ExitStatus::from_raw(0)), + ); + let bytes = + std::fs::read_to_string(harness_state::harness_state_path(tmp.path())).unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-state.v2""#), + "{bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + } } diff --git a/src/opencode_session.rs b/src/opencode_session.rs index 056c18f8..c082c140 100644 --- a/src/opencode_session.rs +++ b/src/opencode_session.rs @@ -138,9 +138,11 @@ pub fn run( // The written claim comes BEFORE the provider spawns: a claim that cannot be written aborts // the launch while there is still nothing to leak, and it supersedes whatever a predecessor // left — a still-fresh live record included — before this wrapper's first observation. + // One gate decision per driver process, taken before the claim it keys. + let actor = harness_state::RecordIdentity::for_driver(catalog_root, &identity); let mut session = { let session = harness_state::session_token(); - let seq = harness_state::claim(&agent_dir, identity.clone(), "opencode", &session)?; + let seq = harness_state::claim(&agent_dir, actor.clone(), "opencode", &session)?; let mut diagnostics = DiagnosticPublisher::new(&agent_dir, DiagnosticDriver::OpenCode, producer_version, support); if let Some(reason) = version_failure { @@ -160,7 +162,7 @@ pub fn run( // names the registry entry, and only aliases the identity on driver-expanded seats. writer: Writer::new( &agent_dir, - identity.clone(), + actor.clone(), "opencode", Some(runtime_id.clone()), ) @@ -168,7 +170,7 @@ pub fn run( // The same incarnation token as the state record beside it, carried as provenance // only (HC-R15): nothing on this record is fenced on it. The claim above already // removed any predecessor's context record, so no second removal belongs here. - context: match ContextProducer::new(&agent_dir, &identity, &session) { + context: match ContextProducer::new(&agent_dir, &actor, &session) { Ok(producer) => Some(producer), Err(error) => { tracing::warn!( @@ -1083,11 +1085,15 @@ struct ContextProducer { } impl ContextProducer { - fn new(agent_dir: &Path, identity: &str, session: &str) -> Result { + fn new( + agent_dir: &Path, + actor: &harness_state::RecordIdentity, + session: &str, + ) -> Result { Ok(Self { writer: harness_context::Writer::new( agent_dir, - identity, + actor.clone(), harness_context::Harness::OpenCode, )? .with_session(session), @@ -2693,7 +2699,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let agent_dir = tmp.path().join("agents").join("hetz").join("seat"); std::fs::create_dir_all(&agent_dir).unwrap(); - let producer = ContextProducer::new(&agent_dir, "hetz.seat", "incarnation-1").unwrap(); + let producer = ContextProducer::new( + &agent_dir, + &harness_state::RecordIdentity::legacy("hetz.seat"), + "incarnation-1", + ) + .unwrap(); Self { _tmp: tmp, agent_dir, @@ -2722,6 +2733,33 @@ mod tests { } } + /// The OpenCode producer under an activated catalog: the actor `run` resolved once at start is + /// handed to the state writer and this context writer alike, so both records name the + /// immutable agent ID under version 2 and cannot be versioned apart. + #[test] + fn an_activated_context_producer_writes_version_2() { + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + let tmp = tempfile::tempdir().unwrap(); + let agent_dir = tmp.path().join("agents").join("hetz").join("seat"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let mut producer = ContextProducer::new( + &agent_dir, + &harness_state::RecordIdentity::activated(AGENT_ID), + "incarnation-1", + ) + .unwrap(); + producer.apply(&event(OC_TURN_1_FINAL)); + producer.apply(&event(OC_TURN_1_SESSION)); + assert!(producer.publish(), "a fresh numerator must publish"); + let bytes = std::fs::read_to_string(harness_context::harness_context_path(&agent_dir)) + .unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-context.v2""#), + "{bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + } + /// The version the fixtures are pinned to, read out of the frames themselves. fn captured_version(raw: &str) -> String { event(raw) diff --git a/src/pi_channel.rs b/src/pi_channel.rs index 11d2859e..9e394108 100644 --- a/src/pi_channel.rs +++ b/src/pi_channel.rs @@ -170,8 +170,10 @@ fn run_for(catalog_root: &Path, identity: &str, kind: &ChannelKind) -> Result<() let context_session = wrapper_session .clone() .unwrap_or_else(harness_state::session_token); + // One gate decision per channel process, from the catalog it was launched against. + let actor = harness_state::RecordIdentity::for_driver(catalog_root, identity); let mut writer = - harness_state::Writer::new(&agent_dir, identity, kind.label, Some(pty_session)); + harness_state::Writer::new(&agent_dir, actor.clone(), kind.label, Some(pty_session)); if let Some(session) = wrapper_session { // Full adopted ownership when the wrapper exported it: the claimed sequence gives the // token a direction, so a straggler channel from a superseded session is refused. @@ -192,7 +194,7 @@ fn run_for(catalog_root: &Path, identity: &str, kind: &ChannelKind) -> Result<() // Failing to construct it must not cost the seat its mail. Delivery never depends on // observability anywhere else in this loop, and this is the one fallible construction here — // an agent directory with no parent has nowhere safe to stage a temporary file. - let mut context_writer = match harness_context::Writer::new(&agent_dir, identity, kind.harness) + let mut context_writer = match harness_context::Writer::new(&agent_dir, actor, kind.harness) { Ok(writer) => Some(writer.with_session(context_session)), Err(error) => { @@ -942,6 +944,53 @@ mod tests { .expect("a record must have been written") } + /// The channel producer under an activated catalog: `run_for` resolves its actor once from the + /// catalog and hands the same value to the state writer and this context writer, so the pair + /// of records a seat carries can never be versioned apart. + #[test] + fn an_activated_channel_writes_both_records_under_version_2() { + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + let tmp = tempfile::tempdir().unwrap(); + let agent_dir = tmp.path().join("agents").join("h").join("worker"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let actor = harness_state::RecordIdentity::activated(AGENT_ID); + + let mut writer = harness_state::Writer::new( + &agent_dir, + actor.clone(), + PI_KIND.label, + Some(AGENT_ID.to_string()), + ); + writer + .observe(harness_state::Observation::new( + harness_state::Activity::Active, + harness_state::BlockedOn::None, + harness_state::InputBuffer::Unknown, + )) + .unwrap(); + let mut context = harness_context::Writer::new(&agent_dir, actor, PI_KIND.harness).unwrap(); + context + .compacted(harness_context::Compaction::new( + harness_context::CompactionTrigger::Auto, + )) + .unwrap(); + + for (path, schema) in [ + ( + harness_state::harness_state_path(&agent_dir), + "st2.harness-state.v2", + ), + ( + harness_context::harness_context_path(&agent_dir), + "st2.harness-context.v2", + ), + ] { + let bytes = std::fs::read_to_string(&path).unwrap(); + assert!(bytes.contains(&format!(r#""schema":"{schema}""#)), "{bytes}"); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + } + } + /// HC-R13, pinned to pi 0.84.2. The payload is verbatim from the credential-free pi lab: one /// `message_end` for an assistant message, with `getContextUsage()` and the message's own /// `usage` side by side. @@ -1311,6 +1360,7 @@ mod tests { filename: "1787042542238-xex2t4.md".into(), ts_ms: 1_787_042_542_238, from: Some("h.supervisor".into()), + from_id: None, subject: Some("deploy check".into()), in_reply_to: None, tags: Vec::new(), diff --git a/src/pi_session.rs b/src/pi_session.rs index bb59c25c..a9d256fd 100644 --- a/src/pi_session.rs +++ b/src/pi_session.rs @@ -73,9 +73,11 @@ pub fn run( let executable = std::env::current_exe().context("resolving st2 executable for the pi channel")?; let session = harness_state::session_token(); + // One gate decision per driver process, taken before the claim it keys. + let actor = harness_state::RecordIdentity::for_driver(catalog_root, &identity); // The claim is written: it supersedes whatever the predecessor left — including a // still-fresh live record — before the channel or terminal writer act under it. - let seq = harness_state::claim(&agent_dir, identity.clone(), "pi", &session)?; + let seq = harness_state::claim(&agent_dir, actor.clone(), "pi", &session)?; // Every fallible step past the claim must end the record honestly on failure — the claim // placeholder standing as the last word would read as a takeover, not a launch that never // ran. @@ -101,7 +103,7 @@ pub fn run( Err(error) => { let mut writer = harness_state::Writer::new( &agent_dir, - identity.clone(), + actor.clone(), "pi", Some(runtime_id.clone()), ) @@ -125,7 +127,7 @@ pub fn run( // record fences exactly this session's live records. let observer = crate::provider_session::SessionObserver::terminal_only( &agent_dir, - &identity, + actor.clone(), "pi", &runtime_id, &session, @@ -142,7 +144,7 @@ pub fn run( Some(&observer), ) .with_context(|| format!("running pi driver '{runtime_id}'"))?; - record_session_end(&agent_dir, &identity, &runtime_id, &session, seq, &outcome); + record_session_end(&agent_dir, &actor, &runtime_id, &session, seq, &outcome); match outcome { ProviderOutcome::Exited(exit) => { anyhow::ensure!(exit.success(), "pi provider exited with {exit}"); @@ -160,7 +162,7 @@ pub fn run( /// wrapper has reaped pi the extension (and with it the channel) is already gone. fn record_session_end( agent_dir: &Path, - identity: &str, + actor: &harness_state::RecordIdentity, runtime_id: &str, session: &str, seq: u64, @@ -171,7 +173,7 @@ fn record_session_end( ProviderOutcome::Stopped(None) => "stopped".to_string(), }; let mut writer = - harness_state::Writer::new(agent_dir, identity, "pi", Some(runtime_id.to_string())) + harness_state::Writer::new(agent_dir, actor.clone(), "pi", Some(runtime_id.to_string())) .with_ownership(session, seq); if let Err(error) = writer.ended(label) { tracing::warn!("st2 pi driver: recording session end failed: {error}"); @@ -295,7 +297,7 @@ mod tests { record_session_end( agent_dir, - "h.worker", + &crate::harness_state::RecordIdentity::legacy("h.worker"), "h.worker", "session-test", 1, @@ -314,7 +316,7 @@ mod tests { record_session_end( agent_dir, - "h.worker", + &crate::harness_state::RecordIdentity::legacy("h.worker"), "h.worker", "session-test", 1, @@ -324,6 +326,32 @@ mod tests { assert_eq!(observed.exit.as_deref(), Some("signal 9")); } + /// The pi wrapper's terminal write under an activated catalog: the actor `run` resolved once + /// at start decides the record's `agent` bytes and its version together, and the channel's own + /// records of the same session carry the same pair. + #[test] + fn an_activated_wrapper_ends_the_session_under_version_2() { + use std::os::unix::process::ExitStatusExt as _; + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + + let tmp = tempfile::tempdir().unwrap(); + record_session_end( + tmp.path(), + &crate::harness_state::RecordIdentity::activated(AGENT_ID), + "h.worker", + "session-test", + 1, + &ProviderOutcome::Exited(ExitStatus::from_raw(0)), + ); + let bytes = fs::read_to_string(crate::harness_state::harness_state_path(tmp.path())) + .unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-state.v2""#), + "{bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + } + /// The observed variant reports a nonzero exit instead of judging it, which is what lets the /// wrapper record the terminal state before failing the launch. #[test] diff --git a/src/provider_session.rs b/src/provider_session.rs index 09648ff3..c26381a5 100644 --- a/src/provider_session.rs +++ b/src/provider_session.rs @@ -57,7 +57,7 @@ pub(crate) enum ProviderOutcome { /// whatever state a hook process wrote in between and never clobbers a fresher observation. pub(crate) struct SessionObserver { agent_dir: PathBuf, - identity: String, + actor: harness_state::RecordIdentity, harness: &'static str, pty_session: String, session: String, @@ -77,16 +77,17 @@ impl SessionObserver { /// ownership would silently produce a writer every record refuses. pub(crate) fn new( agent_dir: &Path, - identity: &str, + actor: impl Into, harness: &'static str, pty_session: &str, ) -> anyhow::Result { + let actor = actor.into(); let session = harness_state::session_token(); - let seq = harness_state::claim(agent_dir, identity, harness, &session)?; + let seq = harness_state::claim(agent_dir, actor.clone(), harness, &session)?; Ok(Self { seq, agent_dir: agent_dir.to_path_buf(), - identity: identity.to_string(), + actor, harness, pty_session: pty_session.to_string(), session, @@ -99,7 +100,7 @@ impl SessionObserver { /// the terminal record fences exactly this session's records. pub(crate) fn terminal_only( agent_dir: &Path, - identity: &str, + actor: impl Into, harness: &'static str, pty_session: &str, session: &str, @@ -107,7 +108,7 @@ impl SessionObserver { ) -> Self { Self { agent_dir: agent_dir.to_path_buf(), - identity: identity.to_string(), + actor: actor.into(), harness, pty_session: pty_session.to_string(), session: session.to_string(), @@ -129,7 +130,7 @@ impl SessionObserver { fn writer(&self) -> harness_state::Writer { harness_state::Writer::new( &self.agent_dir, - &self.identity, + self.actor.clone(), self.harness, Some(self.pty_session.clone()), ) @@ -348,4 +349,32 @@ mod tests { assert_eq!(record.exit.as_deref(), Some("exit unknown")); assert_eq!(record.reason.as_deref(), Some("launch-error")); } + + /// The wrapper half every interactive driver shares: whichever actor the driver resolved at + /// start decides both the record's `agent` bytes and its version, through the claim and the + /// terminal record alike. Under activation that is the immutable agent ID under version 2. + #[test] + fn an_activated_observer_claims_and_ends_under_version_2() { + let tmp = tempfile::tempdir().unwrap(); + const AGENT_ID: &str = "0199c0de-7000-7000-8000-00000000abcd"; + let observer = SessionObserver::new( + tmp.path(), + harness_state::RecordIdentity::activated(AGENT_ID), + "claude", + "hetz.worker", + ) + .unwrap(); + observer.ended("exit 0"); + let path = harness_state::harness_state_path(tmp.path()); + let bytes = String::from_utf8(std::fs::read(&path).unwrap()).unwrap(); + assert!( + bytes.contains(r#""schema":"st2.harness-state.v2""#), + "{bytes}" + ); + assert!(bytes.contains(&format!(r#""agent":"{AGENT_ID}""#)), "{bytes}"); + assert_eq!( + harness_state::read(&path, None).unwrap().state, + harness_state::Activity::Ended + ); + } } diff --git a/src/reconcile.rs b/src/reconcile.rs index 1fa33140..eb68be09 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -20,11 +20,17 @@ use agent_spec::spec::{ }; use kdl::KdlValue; +use crate::identity::{IdentityActivation, LegacyReason}; + /// Immutable inputs captured once before generated tasks are compiled. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskCompileContext { catalog_root: PathBuf, st2_executable: PathBuf, + /// This pass's DELTA-003 identity-activation decision, or `None` while the caller has not + /// decided one. Undecided compiles legacy bytes: the gate is all-or-nothing, so a caller that + /// has not proved catalog migration must not emit target-model identity. + identity_activation: Option, } impl TaskCompileContext { @@ -57,6 +63,7 @@ impl TaskCompileContext { Ok(Self { catalog_root, st2_executable, + identity_activation: None, }) } @@ -80,6 +87,27 @@ impl TaskCompileContext { pub fn catalog_root(&self) -> &Path { &self.catalog_root } + + /// Bind this pass's identity-activation decision (DELTA-003 step 5). + pub fn with_identity_activation(mut self, activation: IdentityActivation) -> Self { + self.identity_activation = Some(activation); + self + } + + /// Whether this compilation writes target-model identity. + fn activated(&self) -> bool { + self.identity_activation + .as_ref() + .is_some_and(IdentityActivation::is_activated) + } + + /// The runner-owned agent key this compilation writes for `spec`. + fn agent_key(&self, spec: &AgentSpec, this_host: &str) -> String { + match &self.identity_activation { + Some(activation) => agent_key(spec, this_host, activation), + None => spec.bus_id(this_host), + } + } } /// Compile every runner-owned launch marker into an exact invocation of this st2 binary. @@ -123,6 +151,15 @@ pub fn compile_driver_agent_tasks( continue; }; let bus_id = spec.bus_id(this_host); + let agent_key = context.agent_key(spec, this_host); + // The shared expansion in `driver` is host-qualified because it cannot see the activation + // gate. Both runner-owned values are captured here, before the canonical task is borrowed + // mutably, so an activated pass can re-key them below. + let canonical_task_id = spec + .tasks + .iter() + .find(|task| !task.derived && task.name == "agent") + .map(|task| task_id_parts(&agent_key, &bus_id, task, context.activated())); let expansion = crate::driver::expand_driver(spec, this_host)?; let argv_nodes = expansion .nodes() @@ -188,6 +225,15 @@ pub fn compile_driver_agent_tasks( task.kind == TaskKind::Pty, "agent '{bus_id}' driver canonical task is not a PTY" ); + if let Some(canonical_task_id) = canonical_task_id.filter(|_| context.activated()) { + anyhow::ensure!( + argv.get(5).map(String::as_str) == Some("--identity") + && argv.get(7).map(String::as_str) == Some("--runtime-id"), + "agent '{bus_id}' driver expansion has an unexpected {wrapper} identity prefix" + ); + argv[6] = agent_key; + argv[8] = canonical_task_id; + } task.command = None; task.argv = Some(argv); } @@ -253,6 +299,8 @@ fn compile_session_wrapped_agent_tasks( } let selected = transport.as_str(); let bus_id = spec.bus_id(this_host); + let agent_key = context.agent_key(spec, this_host); + let activated = context.activated(); let mut candidates = spec .tasks .iter_mut() @@ -284,10 +332,7 @@ fn compile_session_wrapped_agent_tasks( !provider.is_empty(), "agent '{bus_id}' selects `deliver \"{selected}\"` with an empty canonical argv" ); - let runtime_id = task - .id - .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let runtime_id = task_id_parts(&agent_key, &bus_id, task, activated); let mut argv = vec![ st2_executable.clone(), "--catalog".to_string(), @@ -295,7 +340,7 @@ fn compile_session_wrapped_agent_tasks( "driver".to_string(), wrapper.to_string(), "--identity".to_string(), - bus_id, + agent_key, "--runtime-id".to_string(), runtime_id, "--".to_string(), @@ -325,7 +370,8 @@ pub fn compile_generated_ding_tasks( .context("running st2 executable path is not UTF-8")? .to_owned(); for spec in specs { - let bus_id = spec.bus_id(this_host); + // `st2 ding --identity` is a generated exact-ID consumer, not a human address parser. + let agent_key = context.agent_key(spec, this_host); for task in &mut spec.tasks { if !task.derived { continue; @@ -359,7 +405,7 @@ pub fn compile_generated_ding_tasks( st2_executable.clone(), "ding".to_string(), "--identity".to_string(), - bus_id.clone(), + agent_key.clone(), "--root".to_string(), effective_root, ]); @@ -398,6 +444,8 @@ pub fn compile_app_server_agent_tasks( continue; } let bus_id = spec.bus_id(this_host); + let agent_key = context.agent_key(spec, this_host); + let activated = context.activated(); let mut candidates = spec .tasks .iter_mut() @@ -430,17 +478,14 @@ pub fn compile_app_server_agent_tasks( .any(|arg| arg == "--remote" || arg.starts_with("--remote=")), "agent '{bus_id}' selects `deliver \"app-server\"` but its canonical argv already declares `--remote`" ); - let runtime_id = task - .id - .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let runtime_id = task_id_parts(&agent_key, &bus_id, task, activated); let mut argv = vec![ st2_executable.clone(), "--catalog".to_string(), catalog_root.clone(), "codex-app-server".to_string(), "--identity".to_string(), - bus_id, + agent_key, "--runtime-id".to_string(), runtime_id, "--".to_string(), @@ -479,10 +524,15 @@ pub struct ObservedPtyPresentation { pub struct TaskTarget { /// `pty` (terminal) or `exec` (terminal-free) — selects the backend. pub kind: TaskKind, - /// Resolved task id (the spec's `id`, or `.` fallback). + /// Resolved task id: the explicit authored `id`, else `.` (R26). pub pty_id: String, - /// The agent bus id this task belongs to (`.`). - pub bus_id: String, + /// The runner-owned agent key this task belongs to — the immutable agent ID once the identity + /// model is activated, and the host-qualified bus identity `.` while legacy. + /// Migration froze every live subject's ID to those same bytes, so activation moves nothing. + pub agent_key: String, + /// The subject's human-routable bus address `.
`. Equal to `agent_key` for a + /// subject with no explicit `address`, which is every subject before activation. + pub bus_address: String, /// The task name (`agent`, `ding`, …). pub name: String, /// Generated from another task rather than authored as an independent sibling. @@ -514,16 +564,132 @@ pub struct PtyPresentation { } pub const AGENT_PRESENTATION_SCHEMA_TAG: &str = "agent.presentation.schema"; +/// Schema 1's owned actor tag. Schema 2 replaces it with [`AGENT_ACTOR_ID_TAG`] plus +/// [`AGENT_ACTOR_ADDRESS_TAG`] and emits it as a removal, so a PTY that started under schema 1 +/// does not keep a stale owned key after the cutover. pub const AGENT_ACTOR_PATH_TAG: &str = "agent.actor.path"; +/// Schema 2: the immutable, catalog-global agent ID. +pub const AGENT_ACTOR_ID_TAG: &str = "agent.actor.id"; +/// Schema 2: the current, mutable bus address. +pub const AGENT_ACTOR_ADDRESS_TAG: &str = "agent.actor.address"; pub const AGENT_DESCRIPTION_TAG: &str = "agent.presentation.description"; /// Compatibility role owned by st2 only on the canonical agent PTY. pub const COMPATIBILITY_ROLE_TAG: &str = "role"; +/// The runner-owned key one subject's identity is derived from. +/// +/// Legacy: the host-qualified bus identity. Activated: the explicit immutable agent ID. Migration +/// froze every live subject's ID to its former bus identity, so the two agree byte-for-byte for a +/// migrated subject and no `ST_AGENT`, task ID, socket path, or ownership key moves at activation. +/// +/// Under activation an explicit `id` is always present — proving exactly that is what the gate +/// does — so its absence is unreachable rather than a silent fallback to the legacy projection. +pub fn agent_key(spec: &AgentSpec, this_host: &str, activation: &IdentityActivation) -> String { + match activation { + IdentityActivation::Activated => spec.id.clone().unwrap_or_else(|| { + unreachable!( + "identity activation admits only fully migrated catalogs, but '{}' carries no explicit agent id", + spec.path.display() + ) + }), + IdentityActivation::Legacy(_) => spec.bus_id(this_host), + } +} + +/// One task's runtime ID (R26) from values captured before the task may be borrowed mutably. +fn task_id_parts( + agent_key: &str, + legacy_bus_id: &str, + task: &crate::spec::Task, + activated: bool, +) -> String { + // Lowering runs in the Agent Spec crate, which cannot see the activation gate, so every task ID + // it synthesizes carries the legacy host-qualified prefix: `` for the compact canonical + // agent task, `.` for everything else it names. Under activation those two shapes + // are re-keyed onto the agent ID, which is what R26 requires. This is byte-for-byte identical + // for every migrated subject — its frozen ID *is* the legacy bus identity — and correct for a + // subject created after activation. An independently authored ID stays authoritative. + if activated && let Some(id) = task.id.as_deref() { + if !task.derived && task.name == "agent" && id == legacy_bus_id { + return agent_key.to_owned(); + } + if id == format!("{legacy_bus_id}.{}", task.name) { + return format!("{agent_key}.{}", task.name); + } + } + resolve_task_id(agent_key, &task.name, task.id.as_deref()) +} + +/// One task's runtime ID: the explicit authored ID, else `.` (R26). +/// +/// Every consumer that needs a task's on-disk identity — reconciliation, socket admission, task +/// inventory, resync seats — must resolve it through here so they cannot disagree about ownership. +pub fn default_task_id( + spec: &AgentSpec, + task: &crate::spec::Task, + this_host: &str, + activation: &IdentityActivation, +) -> String { + task_id_parts( + &agent_key(spec, this_host, activation), + &spec.bus_id(this_host), + task, + activation.is_activated(), + ) +} + +/// Decide the DELTA-003 identity gate once, for a caller that already holds a complete local +/// catalog view. +/// +/// Anything st2 cannot *prove* migrated answers legacy: the gate is all-or-nothing, so an +/// incomplete migration transaction or a structural archive st2 cannot observe is never an +/// optimistic yes. +pub fn identity_activation(root: &Path, specs: &[AgentSpec]) -> IdentityActivation { + if specs.is_empty() { + // `activation_from` answers a vacuous yes for an empty subject set. Nothing has been + // proved migrated, and a decision that outlives its (empty) view would then key a later + // unmigrated subject as if it had an ID, so keep the gate closed. + return IdentityActivation::Legacy(LegacyReason::CatalogNotMigrated { + unmigrated: 0, + first: "no local subjects".to_owned(), + }); + } + if crate::catalog_migrate_ids::marker_path(root).exists() { + return IdentityActivation::Legacy(LegacyReason::MigrationIncomplete); + } + match crate::catalog_archive::observe(root) { + Ok(observation) if observation.issues.is_empty() => { + crate::identity::activation_from(specs, &observation.archived, false) + } + _ => IdentityActivation::Legacy(LegacyReason::CatalogNotMigrated { + unmigrated: 1, + first: crate::catalog_archive::archive_root(root).display().to_string(), + }), + } +} + +/// [`identity_activation`] for a pass that discovered the catalog itself. +/// +/// An unreadable declaration may itself be the unmigrated subject, so a catalog whose discovery is +/// incomplete keeps the pass legacy. +pub fn discovered_identity_activation( + root: &Path, + found: &crate::Discovered, +) -> IdentityActivation { + match found.errors.first() { + Some(error) => IdentityActivation::Legacy(LegacyReason::CatalogNotMigrated { + unmigrated: found.errors.len(), + first: error.path.display().to_string(), + }), + None => identity_activation(root, &found.specs), + } +} + /// Fail-closed admission errors for runner-owned task identity. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskIdentityAdmissionError { Conflict { - bus_id: String, + agent: String, task: String, declared: String, }, @@ -533,12 +699,12 @@ impl fmt::Display for TaskIdentityAdmissionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Conflict { - bus_id, + agent, task, declared, } => write!( formatter, - "agent '{bus_id}' task '{task}' declares conflicting ST_AGENT '{declared}'; expected runner-owned value '{bus_id}'" + "agent '{agent}' task '{task}' declares conflicting ST_AGENT '{declared}'; expected runner-owned value '{agent}'" ), } } @@ -546,39 +712,52 @@ impl fmt::Display for TaskIdentityAdmissionError { impl std::error::Error for TaskIdentityAdmissionError {} -/// Reject local active tasks whose authored identity conflicts with the runner-derived bus ID. +/// Reject local active tasks whose authored identity conflicts with the runner-owned agent key. pub fn validate_task_identities( specs: &[AgentSpec], this_host: &str, + activation: &IdentityActivation, ) -> Result<(), TaskIdentityAdmissionError> { for spec in specs { if !spec.desired_state.is_running() || spec.resolved_host(this_host) != this_host { continue; } - let bus_id = spec.bus_id(this_host); - for task in &spec.tasks { - if let Some(declared) = task.env.get("ST_AGENT") - && declared != &bus_id - { - return Err(TaskIdentityAdmissionError::Conflict { - bus_id, - task: task.name.clone(), - declared: declared.clone(), - }); - } + validate_agent_task_identity(spec, &agent_key(spec, this_host, activation))?; + } + Ok(()) +} + +/// The same rule for one subject whose runner-owned key the caller already decided. +pub fn validate_agent_task_identity( + spec: &AgentSpec, + agent: &str, +) -> Result<(), TaskIdentityAdmissionError> { + for task in &spec.tasks { + if let Some(declared) = task.env.get("ST_AGENT") + && declared != agent + { + return Err(TaskIdentityAdmissionError::Conflict { + agent: agent.to_owned(), + task: task.name.clone(), + declared: declared.clone(), + }); } } Ok(()) } /// Project runner-owned identity and the supervisor source of truth into one launch target. +/// +/// `ST_SUPERVISOR` is passed through verbatim: the migration transaction already rewrote every +/// supervisor reference to the parent's migrated ID, so under activation the declared value *is* +/// the agent ID and re-resolving it here would reintroduce address parsing on an exact selector. fn runner_task_env( spec: &AgentSpec, task: &crate::spec::Task, - bus_id: &str, + agent_key: &str, ) -> BTreeMap { let mut env = task.env.clone(); - env.insert("ST_AGENT".to_owned(), bus_id.to_owned()); + env.insert("ST_AGENT".to_owned(), agent_key.to_owned()); if let Some(supervisor) = &spec.supervisor { env.insert("ST_SUPERVISOR".to_owned(), supervisor.clone()); } else { @@ -591,24 +770,54 @@ fn pty_presentation( spec: &AgentSpec, task: &crate::spec::Task, pty_id: &str, - bus_id: &str, + agent_key: &str, + bus_address: &str, + activation: &IdentityActivation, ) -> Option { if task.kind != TaskKind::Pty { return None; } - let canonical_agent = task.name == "agent" && pty_id == bus_id; + let canonical_agent = task.name == "agent" && pty_id == agent_key; + let name = || match spec.name.as_ref() { + Some(name) if name == pty_id => None, + _ => spec.name.clone(), + }; + if !activation.is_activated() { + return Some(PtyPresentation { + pty_id: pty_id.to_owned(), + display_name: (task.name == "agent").then(name), + tags: BTreeMap::from([ + ( + AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), + Some("1".to_owned()), + ), + (AGENT_ACTOR_PATH_TAG.to_owned(), Some(agent_key.to_owned())), + (AGENT_DESCRIPTION_TAG.to_owned(), spec.description.clone()), + ( + COMPATIBILITY_ROLE_TAG.to_owned(), + canonical_agent.then(|| "agent".to_owned()), + ), + ]), + }); + } Some(PtyPresentation { pty_id: pty_id.to_owned(), - display_name: (task.name == "agent").then(|| match spec.name.as_ref() { - Some(name) if name == pty_id => None, - _ => spec.name.clone(), - }), + // R26 restricts native display metadata to the canonical compact agent task; every other + // PTY keeps its own task-specific display convention. + display_name: canonical_agent.then(name), tags: BTreeMap::from([ ( AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), - Some("1".to_owned()), + Some("2".to_owned()), ), - (AGENT_ACTOR_PATH_TAG.to_owned(), Some(bus_id.to_owned())), + (AGENT_ACTOR_ID_TAG.to_owned(), Some(agent_key.to_owned())), + ( + AGENT_ACTOR_ADDRESS_TAG.to_owned(), + Some(bus_address.to_owned()), + ), + // Schema 1's owned actor tag is retired, not inherited: leaving it behind would keep a + // stale host-qualified value on a PTY that outlived the cutover. + (AGENT_ACTOR_PATH_TAG.to_owned(), None), (AGENT_DESCRIPTION_TAG.to_owned(), spec.description.clone()), ( COMPATIBILITY_ROLE_TAG.to_owned(), @@ -685,23 +894,23 @@ pub struct ReconcilePlan<'a> { pub live: Vec, } -/// Resolve one exact local task selector (`host.agent.task` or explicit task id) without mutation. +/// Resolve one exact local task selector (`.` or explicit task id) without +/// mutation. pub fn resolve_task<'a>( specs: &'a [AgentSpec], selector: &str, this_host: &str, + activation: &IdentityActivation, ) -> anyhow::Result<(&'a AgentSpec, &'a crate::spec::Task, String)> { let mut matches = Vec::new(); for spec in specs { if spec.resolved_host(this_host) != this_host { continue; } + let key = agent_key(spec, this_host, activation); for task in &spec.tasks { - let runtime = task - .id - .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name)); - let qualified = format!("{}.{}", spec.bus_id(this_host), task.name); + let runtime = default_task_id(spec, task, this_host, activation); + let qualified = format!("{key}.{}", task.name); if selector == runtime || selector == qualified { matches.push((spec, task, runtime)); } @@ -720,9 +929,10 @@ pub fn reconcile_selected<'a>( sessions: &[Session], this_host: &str, selector: &str, + activation: &IdentityActivation, ) -> anyhow::Result> { - validate_task_identities(specs, this_host)?; - let (owner, task, runtime) = resolve_task(specs, selector, this_host)?; + validate_task_identities(specs, this_host, activation)?; + let (owner, task, runtime) = resolve_task(specs, selector, this_host, activation)?; let mut plan = ReconcilePlan::default(); if owner.desired_state.is_retired() { plan.settle_retirement.push(owner); @@ -756,12 +966,14 @@ pub fn reconcile_selected<'a>( unreachable!("discovery rejects tasks carrying both command and argv") } }; - let bus_id = owner.bus_id(this_host); - let env = runner_task_env(owner, task, &bus_id); + let key = agent_key(owner, this_host, activation); + let bus_address = owner.bus_address(this_host); + let env = runner_task_env(owner, task, &key); let target = TaskTarget { kind: task.kind, pty_id: runtime.clone(), - bus_id: bus_id.clone(), + agent_key: key.clone(), + bus_address: bus_address.clone(), name: task.name.clone(), derived: task.derived, launch, @@ -770,7 +982,7 @@ pub fn reconcile_selected<'a>( tags: task.tags.clone(), env, keep: task.keep || owner.keep, - presentation: pty_presentation(owner, task, &runtime, &bus_id), + presentation: pty_presentation(owner, task, &runtime, &key, &bus_address, activation), }; match actual { Some(s) if s.alive => { @@ -820,23 +1032,28 @@ fn session_state(by_id: &HashMap<&str, bool>, pty_id: &str) -> SessionState { } } -/// Resolve a task's on-disk id: the explicit `id`, else `.`. This is the session +/// Resolve a task's on-disk id: the explicit `id`, else `.`. This is the session /// name `pty` binds a socket for, so admission checks resolve it through here rather than -/// re-deriving the format. -pub(crate) fn resolve_task_id(bus_id: &str, name: &str, explicit: Option<&str>) -> String { +/// re-deriving the format. Prefer [`default_task_id`], which also applies the activated compact +/// re-key; this is the raw shape rule for a caller that already holds the key. +pub(crate) fn resolve_task_id(agent_key: &str, name: &str, explicit: Option<&str>) -> String { match explicit { Some(id) => id.to_string(), - None => format!("{bus_id}.{name}"), + None => format!("{agent_key}.{name}"), } } /// Compute the reconcile plan for `specs` given observed `sessions`, filtering to `this_host`. +/// +/// `activation` is decided once per pass by the caller and is never re-derived per subject: a +/// partially migrated catalog has no coherent ID namespace, so the gate is all-or-nothing. pub fn reconcile<'a>( specs: &'a [AgentSpec], sessions: &[Session], this_host: &str, + activation: &IdentityActivation, ) -> Result, TaskIdentityAdmissionError> { - validate_task_identities(specs, this_host)?; + validate_task_identities(specs, this_host, activation)?; let by_id: HashMap<&str, bool> = sessions .iter() .map(|s| (s.pty_id.as_str(), s.alive)) @@ -852,7 +1069,8 @@ pub fn reconcile<'a>( plan.other_host.push(spec); continue; } - let bus_id = spec.bus_id(this_host); + let key = agent_key(spec, this_host, activation); + let bus_address = spec.bus_address(this_host); if !spec.desired_state.is_running() { if spec.desired_state.is_retired() { @@ -860,7 +1078,7 @@ pub fn reconcile<'a>( } let mut teardown_ids = Vec::new(); for t in &spec.tasks { - let id = resolve_task_id(&bus_id, &t.name, t.id.as_deref()); + let id = default_task_id(spec, t, this_host, activation); let retain_dead = spec.desired_state.is_suspended() && (t.keep || spec.keep); match session_state(&by_id, &id) { SessionState::Alive => teardown_ids.push(id), @@ -894,13 +1112,14 @@ pub fn reconcile<'a>( unreachable!("discovery rejects tasks carrying both command and argv") } }; - let env = runner_task_env(spec, t, &bus_id); - let pty_id = resolve_task_id(&bus_id, &t.name, t.id.as_deref()); + let env = runner_task_env(spec, t, &key); + let pty_id = default_task_id(spec, t, this_host, activation); Some(( TaskTarget { kind: t.kind, pty_id: pty_id.clone(), - bus_id: bus_id.clone(), + agent_key: key.clone(), + bus_address: bus_address.clone(), name: t.name.clone(), derived: t.derived, launch, @@ -909,7 +1128,14 @@ pub fn reconcile<'a>( tags: t.tags.clone(), env, keep: t.keep || spec.keep, - presentation: pty_presentation(spec, t, &pty_id, &bus_id), + presentation: pty_presentation( + spec, + t, + &pty_id, + &key, + &bus_address, + activation, + ), }, t.lifecycle, )) diff --git a/src/resync.rs b/src/resync.rs index 2be9f9ff..4b0fb970 100644 --- a/src/resync.rs +++ b/src/resync.rs @@ -24,6 +24,7 @@ use agent_spec::profile::{ }; use agent_spec::spec::{AgentSpec, Resource, decode_percent_path}; +use crate::identity::IdentityActivation; use crate::resource_profile::{MAX_FACTS, MAX_FACT_KEY_BYTES, ResourceFact}; /// The reserved stream used only by the supervisor's crate-internal resync publisher. @@ -142,6 +143,11 @@ fn update_digest_field(digest: &mut Sha256, value: &[u8]) { } /// The watchable carriers of one agent, keyed by its declaration path with current routing IDs. +/// +/// `bus_id` and `seat_id` are the subscription's ownership keys, so both follow DELTA-003's +/// activation gate: legacy bus identity and `.` while the catalog is unmigrated, +/// the immutable agent ID and the ID-keyed task ID once it is. For a migrated legacy subject the +/// two are byte-identical, because migration froze `id` at the former bus identity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentWatchSet { pub declaration_path: PathBuf, @@ -160,8 +166,15 @@ pub fn watch_set_for( spec: &AgentSpec, this_host: &str, profiles: &ResourceProfileRegistry, + activation: &IdentityActivation, ) -> AgentWatchSet { - watch_set_for_in_catalog(spec, std::slice::from_ref(spec), this_host, profiles) + watch_set_for_in_catalog( + spec, + std::slice::from_ref(spec), + this_host, + profiles, + activation, + ) } /// [`watch_set_for`] with the catalog view a `notify-chain` profile needs to reach the carriers @@ -172,9 +185,10 @@ pub fn watch_set_for_in_catalog( specs: &[AgentSpec], this_host: &str, profiles: &ResourceProfileRegistry, + activation: &IdentityActivation, ) -> AgentWatchSet { let refresh = profiles.begin_refresh(); - resolve_watch_set(spec, specs, this_host, &refresh).0 + resolve_watch_set(spec, specs, this_host, &refresh, activation).0 } fn resolve_watch_set( @@ -182,6 +196,7 @@ fn resolve_watch_set( specs: &[AgentSpec], this_host: &str, profiles: &ResourceProfileRefresh<'_>, + activation: &IdentityActivation, ) -> (AgentWatchSet, Vec) { let declaration_path = lexical_clean(&spec.path); let agent_dir = declaration_path.parent().unwrap_or(Path::new(".")); @@ -250,18 +265,20 @@ fn resolve_watch_set( &mut carriers, &mut diagnostics, ); - // The supervisor's resolved logical host — not the OS hostname — decides the bus id, so an - // agent supervised under `st2 up --host ` without an explicit declaration host still - // produces a recipient `resolve_stream` can resolve. + // The supervisor's resolved logical host — not the OS hostname — decides the legacy bus id, + // so an agent supervised under `st2 up --host ` without an explicit declaration host + // still produces a recipient `resolve_stream` can resolve. Both keys come from + // reconciliation's own rules rather than a local copy, so a subscription and the task that + // owns it can never disagree about one identity. ( AgentWatchSet { declaration_path, - bus_id: spec.bus_id(this_host), - seat_id: spec.tasks.iter().find(|task| task.name == "agent").map(|task| { - task.id - .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name)) - }), + bus_id: crate::reconcile::agent_key(spec, this_host, activation), + seat_id: spec + .tasks + .iter() + .find(|task| task.name == "agent") + .map(|task| crate::reconcile::default_task_id(spec, task, this_host, activation)), carriers, declaration_summary: Some(declaration_summary(spec)), }, @@ -576,6 +593,10 @@ impl ResyncSupervisor { /// proven live by this pass and is the only source of active subscriptions. A malformed /// declaration retains its prior subscription only while its canonical seat is observed alive; /// contained profile failures reach the reconcile report. + /// + /// `activation` is the pass's ONE identity decision (DELTA-003), supplied by the reconcile + /// caller rather than taken here: an install and the deactivation that follows it must key + /// ownership identically, and only the pass knows which catalog view it decided over. #[must_use = "resolver diagnostics must be surfaced by the reconcile caller"] pub fn refresh( &self, @@ -584,6 +605,7 @@ impl ResyncSupervisor { this_host: &str, sessions: &[crate::reconcile::Session], malformed_declarations: &[PathBuf], + activation: &IdentityActivation, ) -> Vec { let profiles = self .profiles @@ -596,6 +618,7 @@ impl ResyncSupervisor { this_host, sessions, malformed_declarations, + activation, ) } @@ -612,6 +635,7 @@ impl ResyncSupervisor { this_host: &str, sessions: &[crate::reconcile::Session], malformed_declarations: &[PathBuf], + activation: &IdentityActivation, ) -> Vec { let mut current = self .profiles @@ -625,6 +649,7 @@ impl ResyncSupervisor { this_host, sessions, malformed_declarations, + activation, ) } @@ -636,6 +661,7 @@ impl ResyncSupervisor { this_host: &str, sessions: &[crate::reconcile::Session], malformed_declarations: &[PathBuf], + activation: &IdentityActivation, ) -> Vec { let mut diagnostics = Vec::new(); let refresh_profiles = profiles.begin_refresh(); @@ -644,8 +670,13 @@ impl ResyncSupervisor { .filter(|spec| spec.resolved_host(this_host) == this_host) .filter(|spec| spec.desired_state.is_running()) .map(|spec| { - let (set, mut failures) = - resolve_watch_set(spec, catalog_specs, this_host, &refresh_profiles); + let (set, mut failures) = resolve_watch_set( + spec, + catalog_specs, + this_host, + &refresh_profiles, + activation, + ); diagnostics.append(&mut failures); set }) @@ -679,6 +710,7 @@ impl ResyncSupervisor { spec: &AgentSpec, specs: &[AgentSpec], this_host: &str, + activation: &IdentityActivation, ) -> Vec { if spec.resolved_host(this_host) != this_host || !spec.desired_state.is_running() { return Vec::new(); @@ -688,7 +720,13 @@ impl ResyncSupervisor { .profiles .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - resolve_watch_set(spec, specs, this_host, &profiles.begin_refresh()) + resolve_watch_set( + spec, + specs, + this_host, + &profiles.begin_refresh(), + activation, + ) }; let (ack_tx, ack_rx) = channel(); if self @@ -703,12 +741,13 @@ impl ResyncSupervisor { /// Synchronously remove a canonical seat's active subscriptions before relaunch work begins. /// Sequence floors remain retained so a later successful install cannot reuse an occurrence. - pub fn deactivate(&self, spec: &AgentSpec, this_host: &str) { + pub fn deactivate(&self, spec: &AgentSpec, this_host: &str, activation: &IdentityActivation) { + let key = crate::reconcile::agent_key(spec, this_host, activation); let (ack_tx, ack_rx) = channel(); if self .tx .as_ref() - .is_some_and(|tx| tx.send(Msg::Deactivate(spec.bus_id(this_host), ack_tx)).is_ok()) + .is_some_and(|tx| tx.send(Msg::Deactivate(key, ack_tx)).is_ok()) { let _ = ack_rx.recv(); } @@ -2101,6 +2140,19 @@ mod tests { } } + /// The gate closed: what every pre-DELTA-003 expectation below is stated against. + fn legacy() -> IdentityActivation { + IdentityActivation::Legacy(crate::identity::LegacyReason::MigrationIncomplete) + } + + fn legacy_watch_set( + spec: &AgentSpec, + this_host: &str, + profiles: &ResourceProfileRegistry, + ) -> AgentWatchSet { + watch_set_for(spec, this_host, profiles, &legacy()) + } + #[test] fn resync_subject_uses_the_shared_three_fact_and_96_scalar_renderer() { let facts = vec![ @@ -2247,7 +2299,7 @@ mod tests { }"#; std::fs::write(&declaration, valid).unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); - let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "host", &Default::default()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "host".to_owned(), @@ -2300,7 +2352,7 @@ mod tests { ) .unwrap(); let spec = discover(tmp.path()); - let set = watch_set_for(&spec, "hetz", &Default::default()); + let set = legacy_watch_set(&spec, "hetz", &Default::default()); assert_eq!(set.bus_id, "hetz.worker"); let mut labels: Vec<&str> = set.carriers.iter().map(|c| c.label.as_str()).collect(); labels.sort(); @@ -2333,15 +2385,87 @@ mod tests { .unwrap(); let spec = discover(tmp.path()); assert_eq!( - watch_set_for(&spec, "alias", &Default::default()).bus_id, + legacy_watch_set(&spec, "alias", &Default::default()).bus_id, "alias.worker" ); assert_eq!( - watch_set_for(&spec, "other", &Default::default()).bus_id, + legacy_watch_set(&spec, "other", &Default::default()).bus_id, "other.worker" ); } + /// Migration freezes a live subject's ID at its former bus identity, so opening the gate must + /// not move that subject's subscription: both ownership keys stay byte-identical. Asserted + /// rather than assumed — the two derivations are different code paths. + #[test] + fn activation_leaves_a_migrated_subjects_watch_set_keys_byte_identical() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("agents/hetz/worker"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("agent.kdl"), + r#"agent "worker" { + host "hetz" + id "hetz.worker" + command "true" +}"#, + ) + .unwrap(); + let spec = discover(tmp.path()); + + let before = watch_set_for(&spec, "hetz", &Default::default(), &legacy()); + let after = watch_set_for( + &spec, + "hetz", + &Default::default(), + &IdentityActivation::Activated, + ); + assert_eq!(before.bus_id, "hetz.worker"); + assert_eq!(before.seat_id.as_deref(), Some("hetz.worker")); + assert_eq!( + after, before, + "activation moved a migrated subject's ownership keys" + ); + } + + /// A subject created after activation has an ID unrelated to its address, and the keys follow + /// the ID: the subscription is owned by the immutable subject, not by its mutable route. + #[test] + fn an_activated_watch_set_is_keyed_by_the_agent_id_not_the_address() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("agents/hetz/worker"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("agent.kdl"), + r#"agent "worker" { + host "hetz" + id "0199c0de-7000-7000-8000-00000000abcd" + address "chat" + command "true" +}"#, + ) + .unwrap(); + let spec = discover(tmp.path()); + + let set = watch_set_for( + &spec, + "hetz", + &Default::default(), + &IdentityActivation::Activated, + ); + assert_eq!(set.bus_id, "0199c0de-7000-7000-8000-00000000abcd"); + assert_eq!( + set.seat_id.as_deref(), + Some("0199c0de-7000-7000-8000-00000000abcd"), + "the canonical agent task's id IS the agent id (R26)" + ); + // The same declaration under a closed gate keys by the legacy bus identity, which is + // what every runtime record on an unmigrated catalog is keyed by. + let legacy_set = watch_set_for(&spec, "hetz", &Default::default(), &legacy()); + assert_eq!(legacy_set.bus_id, "hetz.worker"); + assert_eq!(legacy_set.seat_id.as_deref(), Some("hetz.worker")); + } + #[test] fn lexical_paths_drive_store_classification_and_containment() { let agent_dir = Path::new("/catalog/agents/host/worker"); @@ -2504,7 +2628,7 @@ mod tests { std::fs::write(&goal, "current bytes").unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "alias").unwrap(); - let mut current = watch_set_for( + let mut current = legacy_watch_set( &discover(root.path()), "alias", &ResourceProfileRegistry::empty(), @@ -2625,7 +2749,7 @@ mod tests { ) .unwrap(); let current = - watch_set_for(&discover(root.path()), "alias", &ResourceProfileRegistry::empty()); + legacy_watch_set(&discover(root.path()), "alias", &ResourceProfileRegistry::empty()); worker.apply_watch_sets(refresh_for(vec![current])); worker.flush_due_publishing(Instant::now() + IMMEDIATE_WINDOW + Duration::from_secs(1)); @@ -2867,7 +2991,7 @@ mod tests { std::fs::write(&carrier, "newer live bytes").unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "hetz").unwrap(); - let set = watch_set_for(&discover(root.path()), "hetz", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "hetz", &Default::default()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "hetz".to_owned(), @@ -3020,7 +3144,7 @@ mod tests { watcher: None, emit: Arc::new(EmitQueue::default()), }; - worker.apply_watch_sets(refresh_for(vec![watch_set_for( + worker.apply_watch_sets(refresh_for(vec![legacy_watch_set( &discover(root.path()), "hetz", &Default::default(), @@ -3084,7 +3208,7 @@ mod tests { .iter() .find(|spec| spec.path.starts_with(root.path().join("agents/alias").join(identity))) .expect("both declarations are valid"); - watch_set_for(spec, "alias", &ResourceProfileRegistry::empty()) + legacy_watch_set(spec, "alias", &ResourceProfileRegistry::empty()) }; let live_set = set_for("live"); let joining_set = set_for("joining"); @@ -3270,7 +3394,7 @@ mod tests { panic!("regular carrier has a digest"); }; crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); - let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "host", &Default::default()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "host".to_owned(), @@ -3340,7 +3464,7 @@ mod tests { let carrier = resources.join("goal.md"); std::fs::write(&carrier, "before").unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); - let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "host", &Default::default()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "host".to_owned(), @@ -3393,7 +3517,7 @@ mod tests { std::fs::write(&carrier, "baseline").unwrap(); let original_permissions = std::fs::metadata(&carrier).unwrap().permissions(); std::fs::set_permissions(&carrier, std::fs::Permissions::from_mode(0)).unwrap(); - let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "host", &Default::default()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "host".to_owned(), @@ -3444,7 +3568,7 @@ mod tests { let carrier = resources.join("goal.md"); std::fs::write(&carrier, "A").unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); - let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let set = legacy_watch_set(&discover(root.path()), "host", &Default::default()); let seen_subscription_count = set.carriers.len(); let mut worker = Worker { root: root.path().to_path_buf(), @@ -3524,7 +3648,7 @@ mod tests { std::fs::write(&original_carrier, "A").unwrap(); crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); let set = - watch_set_for(&discover(root.path()), "host", &ResourceProfileRegistry::empty()); + legacy_watch_set(&discover(root.path()), "host", &ResourceProfileRegistry::empty()); let mut worker = Worker { root: root.path().to_path_buf(), this_host: "host".to_owned(), @@ -3903,7 +4027,7 @@ mod tests { "agent \"worker\" {\n host \"host\"\n command \"agent\"\n resource \"goal\" uri=\"resources/goal.md\" reason=\"Mission.\"\n}", ) .unwrap(); - let resumed = watch_set_for( + let resumed = legacy_watch_set( &discover(root.path()), "host", &ResourceProfileRegistry::empty(), @@ -4306,8 +4430,13 @@ mod tests { ); let refresh = profiles.begin_refresh(); let spec = discover(tmp.path()); - let (set, diagnostics) = - resolve_watch_set(&spec, std::slice::from_ref(&spec), "hetz", &refresh); + let (set, diagnostics) = resolve_watch_set( + &spec, + std::slice::from_ref(&spec), + "hetz", + &refresh, + &legacy(), + ); assert!(!set.carriers.iter().any(|c| c.label == "goal")); assert!(set.carriers.iter().any(|c| c.label == "declaration")); assert!(!set.carriers.iter().any(|c| c.label == "issue")); @@ -4344,8 +4473,13 @@ mod tests { ); let refresh = profiles.begin_refresh(); let spec = discover(tmp.path()); - let (set, diagnostics) = - resolve_watch_set(&spec, std::slice::from_ref(&spec), "hetz", &refresh); + let (set, diagnostics) = resolve_watch_set( + &spec, + std::slice::from_ref(&spec), + "hetz", + &refresh, + &legacy(), + ); assert!(!set.carriers.iter().any(|carrier| carrier.label == "goal")); assert!( diagnostics.is_empty(), diff --git a/src/run.rs b/src/run.rs index 784c007a..723b712a 100644 --- a/src/run.rs +++ b/src/run.rs @@ -547,11 +547,11 @@ impl PtyCli { cmd.arg("--no-display-name"); } // Secondary tasks retain the established task-specific presentation convention. - _ if target.pty_id == target.bus_id => { + _ if target.pty_id == target.agent_key => { cmd.arg("--no-display-name"); } _ => { - cmd.args(["--name", &target.bus_id]); + cmd.args(["--name", &target.bus_address]); } } cmd.arg("--cwd").arg(&cwd); @@ -1691,6 +1691,7 @@ fn live_resync_specs( this_host: &str, sessions: &[Session], report: &UpReport, + activation: &crate::identity::IdentityActivation, ) -> Vec { let live_task_ids = sessions .iter() @@ -1709,10 +1710,8 @@ fn live_resync_specs( if task.name != "agent" { return false; } - let task_id = task - .id - .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name)); + let task_id = + crate::reconcile::default_task_id(spec, task, this_host, activation); live_task_ids.contains(task_id.as_str()) }) }) @@ -1777,7 +1776,18 @@ fn reconcile_pass( ..Default::default() }; - if let Err(error) = crate::reconcile::validate_task_identities(&found.specs, this_host) { + // The DELTA-003 identity gate, decided exactly once for this pass from the catalog it just + // discovered under the shared lock. Every writer below reads this one value; nothing + // re-derives it per subject, because a partially migrated catalog has no coherent ID + // namespace to key ownership on. + let activation = crate::reconcile::discovered_identity_activation(root, &found); + let task_context = &task_context + .clone() + .with_identity_activation(activation.clone()); + + if let Err(error) = + crate::reconcile::validate_task_identities(&found.specs, this_host, &activation) + { report.errors.push(error.to_string()); return report; } @@ -1914,7 +1924,7 @@ fn reconcile_pass( }; let now = Instant::now(); debounce.observe(&sessions, now); - let mut plan = match crate::reconcile(&eligible_specs, &sessions, this_host) { + let mut plan = match crate::reconcile(&eligible_specs, &sessions, this_host, &activation) { Ok(plan) => plan, Err(error) => { report.errors.push(error.to_string()); @@ -1925,7 +1935,7 @@ fn reconcile_pass( if let Some(resync) = resync { for launch in &plan.launch { if launch.tasks.iter().any(|task| task.name == "agent") { - resync.deactivate(launch.spec, this_host); + resync.deactivate(launch.spec, this_host, &activation); } } } @@ -1945,16 +1955,21 @@ fn reconcile_pass( // complete catalog-aware sets synchronously before unrelated repairs can block. The // targeted upsert retains unchanged baselines and pending transitions, so this is // idempotent across steady-state passes. - for spec in live_resync_specs(&compiled_specs, this_host, &sessions, &report) { + for spec in live_resync_specs(&compiled_specs, this_host, &sessions, &report, &activation) { report .warnings - .extend(resync.install_live(&spec, &found.specs, this_host)); + .extend(resync.install_live(&spec, &found.specs, this_host, &activation)); } } let mut boundary_warnings = Vec::new(); let mut install_new_live_seat = |spec: &agent_spec::spec::AgentSpec| { if let Some(resync) = resync { - boundary_warnings.extend(resync.install_live(spec, &found.specs, this_host)); + boundary_warnings.extend(resync.install_live( + spec, + &found.specs, + this_host, + &activation, + )); } }; execute_reconcile( @@ -1989,7 +2004,7 @@ fn reconcile_pass( } }; let live_subscription_specs = - live_resync_specs(&compiled_specs, this_host, &sessions, &report); + live_resync_specs(&compiled_specs, this_host, &sessions, &report, &activation); if let Some(resource_profiles) = resource_profiles { let generation = match crate::catalog_lock::read_generation_token(root) { Ok(generation) => generation, @@ -2023,6 +2038,7 @@ fn reconcile_pass( this_host, &sessions, &malformed_declarations, + &activation, )); } } @@ -2395,7 +2411,10 @@ pub(crate) fn reconcile_pass_specs_with_cursor( let mut report = UpReport::default(); { let _entered = span.enter(); - if let Err(error) = crate::reconcile::validate_task_identities(specs, this_host) { + let activation = spec_team_activation(specs); + if let Err(error) = + crate::reconcile::validate_task_identities(specs, this_host, &activation) + { report.errors.push(error.to_string()); } else { let observe_span = runtime_observe_span(); @@ -2415,6 +2434,7 @@ pub(crate) fn reconcile_pass_specs_with_cursor( cap, debounce, presentation_cursor, + &activation, &mut report, ), Err(error) => { @@ -2431,6 +2451,26 @@ pub(crate) fn reconcile_pass_specs_with_cursor( report } +/// The identity gate for an in-memory spec team (`st2 up `). +/// +/// A spec team is a self-contained subject set with no structural archive and no migration +/// transaction, so its own declarations decide the gate. No spec team declares an explicit `id` +/// today, which makes this exactly the current legacy behavior. +pub(crate) fn spec_team_activation( + specs: &[agent_spec::spec::AgentSpec], +) -> crate::identity::IdentityActivation { + if specs.is_empty() { + // An empty team proves nothing; `activation_from` would answer a vacuous yes. + return crate::identity::IdentityActivation::Legacy( + crate::identity::LegacyReason::CatalogNotMigrated { + unmigrated: 0, + first: "no team members".to_owned(), + }, + ); + } + crate::identity::activation_from(specs, &[], false) +} + /// Reconcile an in-memory team against an already captured session snapshot. Eval supervision uses /// this so crash classification and reconciliation see the same terminal state: otherwise a clean /// process can exit between two `pty list` calls, be reaped by the second call, then look like a @@ -2458,6 +2498,7 @@ pub(crate) fn reconcile_pass_specs_with_sessions( cap, debounce, presentation_cursor, + &spec_team_activation(specs), &mut report, ); finish_reconcile_pass(&span, &report); @@ -2474,11 +2515,12 @@ fn reconcile_specs_with_sessions_in_span( cap: &mut FlappingCap, debounce: &mut LivenessDebounce, presentation_cursor: &mut PresentationPatchCursor, + activation: &crate::identity::IdentityActivation, report: &mut UpReport, ) { let now = Instant::now(); debounce.observe(sessions, now); - match crate::reconcile(specs, sessions, this_host) { + match crate::reconcile(specs, sessions, this_host, activation) { Ok(mut plan) => { report.deferred = debounce.defer_flickers(&mut plan, now); execute_reconcile( @@ -2580,7 +2622,11 @@ pub fn up_once_selected( drop(discover_entered); found }; - let (owner, _, _) = crate::reconcile::resolve_task(&found.specs, selector, this_host)?; + // One gate decision for this command, before `found.errors` is consumed below. + let activation = + crate::reconcile::discovered_identity_activation(catalog_root, &found); + let (owner, _, _) = + crate::reconcile::resolve_task(&found.specs, selector, this_host, &activation)?; let mut report = UpReport::default(); report.warnings.extend(found.warnings); report.errors.extend( @@ -2589,7 +2635,8 @@ pub fn up_once_selected( .into_iter() .map(|e| format!("{}: {}", e.path.display(), e.message)), ); - if let Err(error) = crate::reconcile::validate_task_identities(&found.specs, this_host) + if let Err(error) = + crate::reconcile::validate_task_identities(&found.specs, this_host, &activation) { report.errors.push(error.to_string()); return Ok(report); @@ -2672,9 +2719,12 @@ fn up_once_selected_specs_with_gates( where V: FnOnce(Option<&'static str>) -> anyhow::Result<()>, { - crate::reconcile::resolve_task(specs, selector, this_host)?; - crate::reconcile::validate_task_identities(specs, this_host)?; - let task_context = TaskCompileContext::current(catalog_root.to_path_buf())?; + // One gate decision for this command, from the catalog view the caller handed in. + let activation = crate::reconcile::identity_activation(catalog_root, specs); + crate::reconcile::resolve_task(specs, selector, this_host, &activation)?; + crate::reconcile::validate_task_identities(specs, this_host, &activation)?; + let task_context = TaskCompileContext::current(catalog_root.to_path_buf())? + .with_identity_activation(activation.clone()); let mut compiled_specs = specs.to_vec(); compile_generated_tasks(&mut compiled_specs, this_host, &task_context)?; let sessions = { @@ -2688,8 +2738,13 @@ where drop(observe_entered); sessions.map_err(|e| anyhow::anyhow!("list sessions: {e}"))? }; - let mut plan = - crate::reconcile::reconcile_selected(&compiled_specs, &sessions, this_host, selector)?; + let mut plan = crate::reconcile::reconcile_selected( + &compiled_specs, + &sessions, + this_host, + selector, + &activation, + )?; let mut report = UpReport::default(); gate_harness_launches_on_hooks(&mut plan, catalog_root, &mut report, verify_hooks); execute_reconcile( @@ -3260,7 +3315,8 @@ mod tests { TaskTarget { kind: TaskKind::Pty, pty_id: id.to_string(), - bus_id: "hetz.demo".to_string(), + agent_key: "hetz.demo".to_string(), + bus_address: "hetz.demo".to_string(), name: "agent".to_string(), derived: false, launch: TaskLaunch::Shell(cmd.to_string()), @@ -4137,7 +4193,7 @@ mod tests { ..UpReport::default() }; - let eligible = live_resync_specs(&specs, "hetz", &sessions, &report) + let eligible = live_resync_specs(&specs, "hetz", &sessions, &report, &spec_team_activation(&specs)) .into_iter() .map(|spec| spec.identity) .collect::>(); @@ -4191,7 +4247,13 @@ mod tests { sess("hetz.retired.agent", true), sess("hetz.suspended.agent", true), ]; - let eligible = live_resync_specs(&specs, "hetz", &sessions, &UpReport::default()) + let eligible = live_resync_specs( + &specs, + "hetz", + &sessions, + &UpReport::default(), + &spec_team_activation(&specs), + ) .into_iter() .map(|spec| spec.identity) .collect::>(); @@ -4891,17 +4953,22 @@ mod tests { &mut report, &mut |spec| { install_count += 1; - assert!(resync.install_live(spec, specs, "hetz").is_empty()); + assert!( + resync + .install_live(spec, specs, "hetz", &spec_team_activation(specs)) + .is_empty() + ); }, ); assert!( resync .refresh( specs, - &live_resync_specs(specs, "hetz", &[], &report), + &live_resync_specs(specs, "hetz", &[], &report, &spec_team_activation(specs)), "hetz", &[], &[], + &spec_team_activation(specs), ) .is_empty() ); @@ -5302,7 +5369,11 @@ mod tests { &mut report, &mut |spec| { installs.fetch_add(1, AtomicOrdering::SeqCst); - assert!(resync.install_live(spec, &specs, "hetz").is_empty()); + assert!( + resync + .install_live(spec, &specs, "hetz", &spec_team_activation(&specs)) + .is_empty() + ); }, ); }); @@ -5310,10 +5381,11 @@ mod tests { resync .refresh( &specs, - &live_resync_specs(&specs, "hetz", &[], &report), + &live_resync_specs(&specs, "hetz", &[], &report, &spec_team_activation(&specs)), "hetz", &[], &[], + &spec_team_activation(&specs), ) .is_empty() ); @@ -5585,7 +5657,7 @@ mod tests { let cli = PtyCli::default(); let mut t = target("hetz.demo", "codex"); - t.bus_id = "hetz.demo".to_owned(); + t.agent_key = "hetz.demo".to_owned(); t.tags .insert("unrelated".to_owned(), "preserved".to_owned()); t.presentation = Some(PtyPresentation { @@ -6195,7 +6267,7 @@ mod tests { fn build_run_command_omits_an_alias_equal_to_the_lifecycle_id() { let cli = PtyCli::default(); let mut t = target("hetz.demo", "exec codex 'boot'"); - t.bus_id = t.pty_id.clone(); + t.agent_key = t.pty_id.clone(); t.presentation = Some(PtyPresentation { pty_id: t.pty_id.clone(), display_name: Some(Some(t.pty_id.clone())), diff --git a/src/task_inventory.rs b/src/task_inventory.rs index 237ce2a4..a856a267 100644 --- a/src/task_inventory.rs +++ b/src/task_inventory.rs @@ -198,7 +198,16 @@ impl TaskInventory { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct TaskRow { + /// The owning agent: its immutable agent ID once the catalog is migrated, its legacy bus + /// identity while DELTA-003's gate is closed. agent: String, + /// The agent's routable bus address, and `null` for a retired subject — retirement releases + /// the address while the ID and this row survive. Absent entirely while the gate is closed, + /// because there `agent` already IS the bus identity and an address column would only + /// restate it. Both readings keep every row of the completeness contract: absence here is + /// never absence of a task. + #[serde(skip_serializing_if = "Option::is_none")] + address: Option>, task: String, runtime_id: String, kind: &'static str, @@ -245,6 +254,7 @@ struct RuntimeJson { #[derive(Debug)] struct DesiredTask { agent: String, + address: Option>, task: String, runtime_id: String, kind: TaskKind, @@ -290,27 +300,38 @@ pub fn inventory( compiled_specs.clear(); } + // One activation decision for the whole inventory, from the discovery this command already + // performed: the gate is all-or-nothing per catalog, and a row keyed by one identity model + // beside a row keyed by the other would be an incoherent ownership report. An unmigrated, + // unreadable, or unprovable catalog keeps legacy keys, which is also what every runtime + // record on disk is keyed by there. + let activation = crate::reconcile::discovered_identity_activation(catalog, found); for spec in &compiled_specs { if spec.resolved_host(host) != host { continue; } - let bus_id = spec.bus_id(host); + // The ownership key and the task-ID rule come from reconciliation, so the inventory + // reports exactly the ids the supervisor owns rather than a second derivation of them. + let agent_key = crate::reconcile::agent_key(spec, host, &activation); + let address = activation.is_activated().then(|| { + (!spec.desired_state.is_retired()).then(|| spec.effective_address().to_owned()) + }); for task in &spec.tasks { // Active declaration-only metadata has no desired runtime. Retired tasks remain in the // inventory even without launch material so stale generations stay visible. if spec.desired_state.is_running() && task.command.is_none() && task.argv.is_none() { continue; } - let runtime_id = task - .id - .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let runtime_id = crate::reconcile::default_task_id(spec, task, host, &activation); + // Fail closed on a collision however the ids were derived: two declarations sharing + // one runtime id have no owner, and the envelope must say so rather than pick one. runtime_owners .entry(runtime_id.clone()) .or_default() - .push(format!("{bus_id}/{}", task.name)); + .push(format!("{agent_key}/{}", task.name)); desired.push(DesiredTask { - agent: bus_id.clone(), + agent: agent_key.clone(), + address: address.clone(), task: task.name.clone(), runtime_id, kind: task.kind, @@ -463,6 +484,7 @@ pub fn inventory( }; TaskRow { agent: task.agent, + address: task.address, task: task.task, runtime_id: task.runtime_id, kind: match task.kind { @@ -862,6 +884,140 @@ mod tests { assert_eq!(value["tasks"][0]["runtime"]["pid"], 42); } + /// R23 under DELTA-003: rows are keyed by the owning agent's immutable ID once the catalog is + /// migrated and carry its bus address, `null` once retirement released it — without weakening + /// completeness. Every declared task still appears, a migrated subject's ids do not move, and + /// the duplicate-runtime-id refusal still fails the envelope closed. + #[test] + fn activated_rows_are_id_keyed_with_a_nullable_address_and_still_refuse_duplicates() { + let tmp = tempfile::tempdir().unwrap(); + // A migrated live subject: its frozen id IS its former bus identity, so nothing moves. + write_agent( + tmp.path(), + "h", + "worker", + r#"id "h.worker"; pty "agent" { id "h.worker"; argv "agent-bin" }"#, + ); + // A subject whose id and address are unrelated — the shape creation mints after + // activation. The row is owned by the id; the address is only where it is reachable. + write_agent( + tmp.path(), + "h", + "chat", + r#"id "0199c0de-7000-7000-8000-00000000abcd"; address "support"; pty "agent" { id "h.chat"; argv "agent-bin" }"#, + ); + // Retirement releases the address and makes the subject non-routable; the id, the row, + // and the stale generation it may still carry all survive. + write_agent( + tmp.path(), + "h", + "old", + r#"id "0199c0de-7000-7000-8000-0000000000ff"; retired #true; pty "agent" { id "h.old" }"#, + ); + let value = json( + tmp.path(), + "h", + ObservationBatch { + complete: true, + observations: vec![running("h.worker", 11)], + errors: vec![], + }, + ); + assert_eq!(value["errors"], Value::Array(vec![])); + assert_eq!(value["complete"], true); + let rows = value["tasks"].as_array().unwrap(); + assert_eq!(rows.len(), 3, "no declared task may be dropped: {rows:?}"); + let row = |agent: &str| { + rows.iter() + .find(|row| row["agent"] == agent) + .unwrap_or_else(|| panic!("no row for {agent} in {rows:?}")) + .clone() + }; + + let migrated = row("h.worker"); + assert_eq!(migrated["address"], "worker"); + assert_eq!( + migrated["runtimeId"], "h.worker", + "a migrated subject's task id must not move at activation" + ); + assert_eq!(migrated["runtime"]["pid"], 11); + + let created = row("0199c0de-7000-7000-8000-00000000abcd"); + assert_eq!(created["address"], "support"); + assert_eq!( + created["runtimeId"], "0199c0de-7000-7000-8000-00000000abcd", + "the canonical agent task's id IS the agent id (R26)" + ); + assert_eq!(created["runtime"]["state"], "absent"); + + let retired = row("0199c0de-7000-7000-8000-0000000000ff"); + assert_eq!(retired["retired"], true); + assert_eq!(retired["desiredState"], "absent"); + assert_eq!( + retired["address"], + Value::Null, + "a retired subject holds no address, and that is a value rather than a gap" + ); + + // The same declarations under a closed gate: legacy keys, and no address column at all — + // there `agent` already IS the bus identity. + let unmigrated = tempfile::tempdir().unwrap(); + write_agent( + unmigrated.path(), + "h", + "worker", + r#"pty "agent" { id "h.worker"; argv "agent-bin" }"#, + ); + let legacy = json( + unmigrated.path(), + "h", + ObservationBatch { + complete: true, + observations: vec![running("h.worker", 11)], + errors: vec![], + }, + ); + assert_eq!(legacy["tasks"][0]["agent"], "h.worker"); + assert!( + legacy["tasks"][0].get("address").is_none(), + "the closed gate must not add a column: {}", + legacy["tasks"][0] + ); + + // Completeness is not weakened by ID keying: two declarations claiming one runtime id + // still have no owner, and the envelope says so and exits non-zero. + let collided = tempfile::tempdir().unwrap(); + write_agent( + collided.path(), + "h", + "one", + r#"id "0199c0de-7000-7000-8000-000000000001"; pty "agent" { id "shared"; argv "agent-bin" }"#, + ); + write_agent( + collided.path(), + "h", + "two", + r#"id "0199c0de-7000-7000-8000-000000000002"; pty "agent" { id "shared"; argv "agent-bin" }"#, + ); + let clash = json( + collided.path(), + "h", + ObservationBatch { + complete: true, + observations: vec![running("shared", 7)], + errors: vec![], + }, + ); + assert_eq!(clash["complete"], false); + let errors = clash["errors"].as_array().unwrap(); + assert!( + errors.iter().any(|error| error + .as_str() + .is_some_and(|error| error.starts_with(r#"duplicate runtime id "shared" is declared by "#))), + "the duplicate refusal must survive ID keying: {errors:?}" + ); + } + #[test] fn duplicate_observation_is_indeterminate_and_non_complete() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/validate.rs b/src/validate.rs index 69ffd298..0f69ba99 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -269,11 +269,10 @@ pub(crate) fn validate_discovered( // legacy/legacy, explicit/explicit across different hosts, and an explicit `id` that // collides with another subject's still-unmigrated frozen identity. // - // DELTA-003: the structurally archived subject set joins this check once - // `st2 catalog migrate-ids` lands (PR D2) — migration may freeze an archived subject's - // legacy bytes only while they remain unique across the combined live-and-archived set, - // and `st2 catalog unarchive` validates ID uniqueness against that prospective - // live-and-archived set rather than the live catalog alone. + // The structurally archived subject set joins this check through + // `catalog_archive::prospective_identities`, which `st2 catalog unarchive` uses to validate + // ID uniqueness against the prospective live-and-archived set rather than the live catalog + // alone. Whole-catalog ID migration owns the combined index while it assigns. let bid = s.effective_id(collision_host); let duplicate_id = seen.insert(bid.clone(), s.path.clone()); if let Some(prev) = &duplicate_id { @@ -570,6 +569,32 @@ pub(crate) fn validate_discovered( } } + // A catalog is either fully migrated to explicit agent IDs or not migrated at all. A mixed + // catalog has no coherent ID namespace: `effective_id` answers with a frozen legacy bus + // identity for one subject and an authored ID for its neighbour, so ownership, provenance, and + // task identity would be keyed two different ways in one pass, and `identity::activation` + // would keep every writer on legacy behavior while some declarations already claimed IDs. + // + // Only `st2 catalog migrate-ids` may hold a mixed state, and only inside its own transaction: + // it validates an all-legacy plane before its writes and an all-migrated plane after them, + // never a mixture. So this refusal is what stops a new declaration from entering a migrated + // catalog without an `id` — including one published through a digest-bound path that cannot + // have an ID injected into its exact bytes. + let migrated = d.specs.iter().filter(|spec| spec.id.is_some()).count(); + if migrated > 0 && migrated < d.specs.len() { + for spec in d.specs.iter().filter(|spec| spec.id.is_none()) { + issues.push(Issue::error( + "agent-id-missing", + rel(root, &spec.path), + Some(spec.identity.clone()), + format!( + "this catalog has migrated to explicit agent ids ({migrated} of {} declarations carry one), so every declaration must declare `id`; run `st2 catalog migrate-ids` or author the id", + d.specs.len() + ), + )); + } + } + if let Some(host) = this_host { for conflict in crate::materialize::render_ownership_conflicts(root, &d.specs, host) { issues.push(Issue::error( diff --git a/tests/agent_address.rs b/tests/agent_address.rs new file mode 100755 index 00000000..23bea715 --- /dev/null +++ b/tests/agent_address.rs @@ -0,0 +1,415 @@ +#![cfg(unix)] +//! `st2 agent address` — the mutable agent address (R24/R25) as one atomic address-book cutover. +//! +//! Address is the third authoring sibling of `st2 rename` and `st2 describe`: it holds the same +//! catalog-authoring lock, resolves exactly one declaration, applies the same `ST_AGENT` +//! self/descendant guardrail, refuses Nix-owned declarations and non-KDL formats, and never +//! touches the subject's immutable `id`. What it adds is host-local address uniqueness, decided +//! against the complete prospective catalog rather than the one declaration being edited. + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +/// One canonical declaration. `extra` carries the address/supervisor/id lines under test. +fn declaration(identity: &str, host: &str, managed_by: &str, extra: &str) -> String { + format!( + "// unrelated comment\nagent {identity:?} {{\n host {host:?}\n meta {{ managed-by {managed_by:?}; keep \"unchanged\" }}\n{extra} command \"sleep 300\"\n}}\n" + ) +} + +fn run(root: &Path, args: &[&str], actor: Option<&str>) -> Output { + let mut process = Command::new(env!("CARGO_BIN_EXE_st2")); + process + .args(["--catalog", root.to_str().unwrap()]) + .args(args) + .env_remove("ST_AGENT") + .env_remove("CATALOG"); + if let Some(actor) = actor { + process.env("ST_AGENT", actor); + } + process.output().unwrap() +} + +fn address(root: &Path, args: &[&str], actor: Option<&str>) -> Output { + let mut full = vec!["agent", "address"]; + full.extend_from_slice(args); + run(root, &full, actor) +} + +fn receipt(output: &Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "stdout is not JSON ({error}):\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +#[test] +fn address_is_set_changed_and_cleared_with_a_classified_receipt() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + + let set = address( + root, + &["h.alpha", "ops.alpha", "--host", "h", "--json"], + None, + ); + assert!( + set.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&set.stderr) + ); + let set = receipt(&set); + assert_eq!(set["result"], "changed"); + assert_eq!(set["id"], "h.alpha", "the immutable ID is what did not change"); + assert_eq!(set["identity"], "h.alpha"); + assert_eq!(set["address"], "ops.alpha"); + assert_eq!(set["busAddress"], "h.ops.alpha"); + assert_eq!(set["retired"], false); + + // Restating the same address is a proven no-op, not a rewrite. + let same = receipt(&address( + root, + &["h.alpha", "ops.alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(same["result"], "unchanged"); + assert_eq!(same["address"], "ops.alpha"); + + let changed = receipt(&address( + root, + &["h.alpha", "ops.beta", "--host", "h", "--json"], + None, + )); + assert_eq!(changed["result"], "changed"); + assert_eq!(changed["busAddress"], "h.ops.beta"); + + // Clearing restores the positional identity fallback as the effective address. + let cleared = receipt(&address( + root, + &["h.alpha", "--clear", "--host", "h", "--json"], + None, + )); + assert_eq!(cleared["result"], "changed"); + assert!(cleared["address"].is_null()); + assert_eq!(cleared["busAddress"], "h.alpha"); + + let again = receipt(&address( + root, + &["h.alpha", "--clear", "--host", "h", "--json"], + None, + )); + assert_eq!(again["result"], "unchanged"); + assert!(again["address"].is_null()); +} + +#[test] +fn a_cutover_rewrites_only_the_address_and_preserves_every_other_byte() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let original = declaration("alpha", "h", "catalog", " id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\"\n"); + write(root, "h/alpha/agent.kdl", &original); + + assert!( + address(root, &["h.alpha", "ops.alpha", "--host", "h"], None) + .status + .success() + ); + + let after = fs::read_to_string(root.join("h/alpha/agent.kdl")).unwrap(); + assert!( + after.contains("address \"ops.alpha\""), + "the cutover landed:\n{after}" + ); + assert!( + after.contains("id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\""), + "the immutable id survives an address change:\n{after}" + ); + // Removing exactly the inserted node must reproduce the original bytes: the comment, the + // `meta` block, the host, and the command all survive untouched. + let restored = after + .lines() + .filter(|line| line.trim() != "address \"ops.alpha\"") + .map(|line| format!("{line}\n")) + .collect::(); + assert_eq!(restored, original); +} + +#[test] +fn a_colliding_address_refuses_on_the_same_host_and_is_admitted_on_another() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + write(root, "h/beta/agent.kdl", &declaration("beta", "h", "catalog", "")); + write(root, "g/gamma/agent.kdl", &declaration("gamma", "g", "catalog", "")); + + // `alpha` has no explicit address, so its positional identity is its effective address — an + // explicit-vs-fallback collision is the same collision. + let refused = address( + root, + &["h.beta", "alpha", "--host", "h", "--json"], + None, + ); + assert!(!refused.status.success()); + let refused = receipt(&refused); + assert_eq!(refused["result"], "error"); + assert_eq!(refused["code"], "address-conflict"); + assert!( + !fs::read_to_string(root.join("h/beta/agent.kdl")) + .unwrap() + .contains("address"), + "a refused cutover writes nothing" + ); + + // The same address on another logical host is legal: addresses are unique per host. + let admitted = receipt(&address( + root, + &["g.gamma", "alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(admitted["result"], "changed"); + assert_eq!(admitted["busAddress"], "g.alpha"); +} + +#[test] +fn clearing_refuses_when_the_identity_fallback_would_collide() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + // `beta` claimed the bytes that are `alpha`'s identity fallback, which is legal while alpha + // carries an explicit address. Clearing alpha's address would put two subjects on one route. + write( + root, + "h/alpha/agent.kdl", + &declaration("alpha", "h", "catalog", " address \"ops\"\n"), + ); + write( + root, + "h/beta/agent.kdl", + &declaration("beta", "h", "catalog", " address \"alpha\"\n"), + ); + + let refused = address(root, &["h.alpha", "--clear", "--host", "h", "--json"], None); + assert!(!refused.status.success()); + let refused = receipt(&refused); + assert_eq!(refused["code"], "address-conflict"); + assert!( + fs::read_to_string(root.join("h/alpha/agent.kdl")) + .unwrap() + .contains("address \"ops\""), + "a refused clear leaves the explicit address in place" + ); +} + +#[test] +fn address_refuses_invalid_grammar_nix_ownership_non_kdl_and_ambiguity() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + write(root, "h/nix/agent.kdl", &declaration("nix", "h", "nix", "")); + write( + root, + "h/legacy/agent.toml", + "identity = \"legacy\"\nhost = \"h\"\ncommand = \"sleep 300\"\n", + ); + // One bare identity declared on two hosts: an ordinary reference cannot name one subject. + write(root, "h/twin/agent.kdl", &declaration("twin", "h", "catalog", "")); + write(root, "g/twin/agent.kdl", &declaration("twin", "g", "catalog", "")); + + for (target, value, code) in [ + ("h.alpha", "Ops Alpha", "invalid-address"), + ("h.alpha", "ops..alpha", "invalid-address"), + ("h.nix", "ops.nix", "nix-managed-declaration"), + ("h.legacy", "ops.legacy", "unsupported-declaration-format"), + ("twin", "ops.twin", "target-ambiguous"), + ] { + let refused = address(root, &[target, value, "--host", "h", "--json"], None); + assert!( + !refused.status.success(), + "{target} {value} was admitted: {}", + String::from_utf8_lossy(&refused.stdout) + ); + assert_eq!(receipt(&refused)["code"], code, "for {target} {value}"); + } +} + +#[test] +fn the_actor_guardrail_admits_a_descendant_and_refuses_a_stranger() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/root/agent.kdl", &declaration("root", "h", "catalog", "")); + write( + root, + "h/child/agent.kdl", + &declaration("child", "h", "catalog", " supervisor \"h.root\"\n"), + ); + write(root, "h/stranger/agent.kdl", &declaration("stranger", "h", "catalog", "")); + + let refused = address( + root, + &["h.stranger", "ops.stranger", "--host", "h", "--json"], + Some("h.root"), + ); + assert!(!refused.status.success()); + assert_eq!(receipt(&refused)["code"], "address-not-authorized"); + + let admitted = address( + root, + &["h.child", "ops.child", "--host", "h", "--json"], + Some("h.root"), + ); + assert!( + admitted.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&admitted.stderr) + ); + assert_eq!(receipt(&admitted)["address"], "ops.child"); +} + +#[test] +fn the_exact_id_form_selects_by_id_and_never_falls_through_to_address_lookup() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + // The two subjects cross their bytes: `one`'s address is `two`, and `two`'s address is `one`. + // An exact-ID selector is catalog-global ID lookup only, so `--id h.two` must name the + // declaration whose positional identity is `two` — never the subject that owns the address + // bytes `two`. + write( + root, + "h/one/agent.kdl", + &declaration("one", "h", "catalog", " address \"two\"\n"), + ); + write( + root, + "h/two/agent.kdl", + &declaration("two", "h", "catalog", " address \"one\"\n"), + ); + + let renamed = run( + root, + &["rename", "--id", "h.two", "Renamed", "--host", "h", "--json"], + None, + ); + assert!( + renamed.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&renamed.stderr) + ); + assert_eq!(receipt(&renamed)["identity"], "h.two"); + assert!( + fs::read_to_string(root.join("h/two/agent.kdl")) + .unwrap() + .contains("name \"Renamed\""), + "the ID named its own subject" + ); + assert!( + !fs::read_to_string(root.join("h/one/agent.kdl")) + .unwrap() + .contains("name \"Renamed\""), + "the subject holding the address bytes was not touched" + ); + + // The same form drives the address cutover. + let cutover = receipt(&address( + root, + &["--id", "h.one", "ops.one", "--host", "h", "--json"], + None, + )); + assert_eq!(cutover["identity"], "h.one"); + assert_eq!(cutover["busAddress"], "h.ops.one"); + + // `two` is a live effective address, but it is not an agent ID, and an exact-ID selector + // never retries its input as an address — so this refuses instead of naming `h/one`. + let unknown = run( + root, + &["rename", "--id", "two", "Nope", "--host", "h", "--json"], + None, + ); + assert!(!unknown.status.success()); + let stderr = String::from_utf8_lossy(&unknown.stderr); + assert!(stderr.contains("'two'"), "stderr:\n{stderr}"); + for identity in ["one", "two"] { + assert!( + !fs::read_to_string(root.join(format!("h/{identity}/agent.kdl"))) + .unwrap() + .contains("Nope"), + "an exact-ID miss must not fall through to address lookup" + ); + } + + // Supplying both forms is a clap conflict, not a precedence rule: with `--id`, the reference + // is off the positional list, so a second positional is exactly what the exclusion catches. + let both = run( + root, + &["rename", "h.one", "Nope", "--id", "h.one", "--host", "h"], + None, + ); + assert!(!both.status.success()); + assert!( + String::from_utf8_lossy(&both.stderr).contains("cannot be used with"), + "stderr:\n{}", + String::from_utf8_lossy(&both.stderr) + ); +} + +/// One exact-ID selector feeds two different resolvers: authoring matches the positional +/// declaration key, while inbox/status resolution answers on the current address. Handing either +/// resolver the other's string is the ID-through-a-mutable-address hop decision 0015 forbids, so +/// the same `--id` must work on both sides once a subject's address diverges from its identity. +#[test] +fn the_exact_id_form_serves_declaration_and_route_resolution_alike() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/one/agent.kdl", + &declaration("one", "h", "catalog", " address \"chat\"\n"), + ); + fs::create_dir_all(root.join("h/one/resources/inbox")).unwrap(); + + // Declaration side: the authoring receipt names the declaration, not the address. + let described = run( + root, + &["describe", "--id", "h.one", "Owns chat", "--host", "h", "--json"], + None, + ); + assert!( + described.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&described.stderr) + ); + assert_eq!(receipt(&described)["identity"], "h.one"); + + // Route side: the same ID resolves the subject's inbox through its current address. + let listed = run( + root, + &["message", "ls", "--id", "h.one", "--count", "--host", "h"], + None, + ); + assert!( + listed.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&listed.stderr) + ); + assert_eq!(String::from_utf8_lossy(&listed.stdout).trim(), "0"); + + let status = run( + root, + &["status", "--id", "h.one", "--host", "h"], + None, + ); + assert!( + status.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&status.stderr) + ); +} diff --git a/tests/catalog_archive.rs b/tests/catalog_archive.rs index 1bd34038..74432364 100755 --- a/tests/catalog_archive.rs +++ b/tests/catalog_archive.rs @@ -848,3 +848,111 @@ fn unarchive_refuses_when_the_tombstone_and_the_declaration_disagree_on_the_agen ); assert!(!root.join(".st2/archive/h/gone.tombstone.json").exists()); } + +// ---- DELTA-003: activation makes an unmigrated archive non-restorable ------------------------ + +const KEEPER_ID: &str = "0199b8f4-8d3a-7c21-9a44-111111111111"; + +/// A live declaration ID migration already reached, so the catalog reads as activated. +fn migrated_keeper(root: &Path, id: &str) { + write( + root, + "agents/h/keeper/agent.kdl", + &format!("agent \"keeper\" {{\n id \"{id}\"\n host \"h\"\n command \"true\"\n}}\n"), + ); +} + +/// Freeze an archived subject's ID in its declaration and its tombstone — what `migrate-ids` +/// writes for a structurally archived subject. +fn migrate_archived(root: &Path, identity: &str, id: &str) { + let declaration = root.join(format!(".st2/archive/h/{identity}/agent.kdl")); + let text = fs::read_to_string(&declaration).unwrap(); + fs::write( + &declaration, + text.replace(" host \"h\"\n", &format!(" id \"{id}\"\n host \"h\"\n")), + ) + .unwrap(); + let tombstone_path = root.join(format!(".st2/archive/h/{identity}.tombstone.json")); + let mut tombstone: serde_json::Value = + serde_json::from_slice(&fs::read(&tombstone_path).unwrap()).unwrap(); + tombstone["agentId"] = serde_json::Value::String(id.to_owned()); + fs::write(&tombstone_path, serde_json::to_vec(&tombstone).unwrap()).unwrap(); +} + +fn unarchive_gone(root: &Path, bin: &Path) -> Output { + st2( + root, + bin, + &["catalog", "unarchive", "gone", "--host", "h", "--json"], + ) +} + +#[test] +fn unarchive_refuses_an_unmigrated_archive_once_the_live_catalog_is_migrated() { + let temporary = tempfile::tempdir().unwrap(); + let (catalog, bin) = fixture(&temporary); + let root = catalog.as_path(); + pty_shim(&bin, "[]"); + retired_seat(root, "gone", RETIRED); + migrated_keeper(root, KEEPER_ID); + assert!(archive_gone(root, &bin).status.success()); + + // Every subject except the one under decision carries an explicit ID, so the target identity + // model is active and an unmigrated declaration has no ID to re-enter the catalog under. + let refused = unarchive_gone(root, &bin); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("st2 catalog migrate-ids"), + "the refusal must name the migration verb:\n{stderr}" + ); + assert!( + root.join(".st2/archive/h/gone/agent.kdl").is_file() + && !root.join("agents/h/gone").exists(), + "a refused unarchive moves nothing" + ); + + // Migrating the archived subject admits the exact same restore. + migrate_archived(root, "gone", MIGRATED_ID); + let restored = unarchive_gone(root, &bin); + assert!( + restored.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&restored.stderr) + ); + assert!(root.join("agents/h/gone/agent.kdl").is_file()); + assert!(!root.join(".st2/archive/h/gone.tombstone.json").exists()); +} + +#[test] +fn unarchive_refuses_an_id_the_prospective_live_and_archived_set_already_holds() { + let temporary = tempfile::tempdir().unwrap(); + let (catalog, bin) = fixture(&temporary); + let root = catalog.as_path(); + pty_shim(&bin, "[]"); + migrated_retired_seat(root, "gone", MIGRATED_ID); + migrated_keeper(root, KEEPER_ID); + assert!(archive_gone(root, &bin).status.success()); + + // A live subject took the archived subject's immutable ID while it was away. Validating + // against the live catalog alone would have missed this until the restore landed. + migrated_keeper(root, MIGRATED_ID); + let refused = unarchive_gone(root, &bin); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains(MIGRATED_ID) && stderr.contains("already held by the live declaration"), + "stderr:\n{stderr}" + ); + assert!(!root.join("agents/h/gone").exists()); + + // Releasing the ID admits the restore. + migrated_keeper(root, KEEPER_ID); + let restored = unarchive_gone(root, &bin); + assert!( + restored.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&restored.stderr) + ); + assert!(root.join("agents/h/gone/agent.kdl").is_file()); +} diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index ef1fbf54..05413eb0 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -130,7 +130,7 @@ fn pi_deliver_wraps_the_authored_launch_without_rendering_anything() { ); // No derived DING companion, and no rendered channel configuration of any kind. assert!(spec.tasks.iter().all(|task| !task.derived)); - let rendered = materialize_agent(&catalog, &spec, "h").unwrap(); + let rendered = materialize_agent(&catalog, &spec, "h", &spec.bus_id("h")).unwrap(); assert!( !format!("{rendered:?}").contains("pi-channel"), "{rendered:?}" @@ -163,7 +163,7 @@ fn opaque_session_driver_materializes_without_rewriting_or_adding_launch_tasks() compile_generated_tasks(std::slice::from_mut(&mut spec), "h", &context).unwrap(); assert_eq!(spec.tasks, [original_task]); - assert!(materialize_agent(&catalog, &spec, "h").unwrap().is_empty()); + assert!(materialize_agent(&catalog, &spec, "h", &spec.bus_id("h")).unwrap().is_empty()); } #[test] @@ -272,7 +272,7 @@ fn claude_driver_uses_the_packaged_channel_without_project_mcp_state() { let hooks = tempfile::tempdir().unwrap().keep(); st2::hooks::install_at(&hooks, false).unwrap(); unsafe { std::env::set_var("ST_HOOKS", &hooks) }; - materialize_agent(&catalog, &driver, "h").unwrap(); + materialize_agent(&catalog, &driver, "h", &driver.bus_id("h")).unwrap(); assert!(!driver_workspace.join(".mcp.json").exists()); assert!( driver_workspace @@ -419,7 +419,7 @@ fn ambiguous_driver_source_neither_compiles_nor_materializes() { .contains("choose one launch source") ); assert_eq!(spec, before); - let materialize_error = materialize_agent(&catalog, &spec, "h").unwrap_err(); + let materialize_error = materialize_agent(&catalog, &spec, "h", &spec.bus_id("h")).unwrap_err(); assert!( materialize_error .to_string() diff --git a/tests/exec_backend.rs b/tests/exec_backend.rs index dbbb20db..fb7c4f78 100644 --- a/tests/exec_backend.rs +++ b/tests/exec_backend.rs @@ -16,7 +16,8 @@ fn exec_target(id: &str, command: &str) -> TaskTarget { TaskTarget { kind: TaskKind::Exec, pty_id: id.to_string(), - bus_id: "hetz.demo".to_string(), + agent_key: "hetz.demo".to_string(), + bus_address: "hetz.demo".to_string(), name: "ding".to_string(), derived: false, launch: TaskLaunch::Shell(command.to_string()), diff --git a/tests/materialize.rs b/tests/materialize.rs index 475edd57..c610f3a4 100644 --- a/tests/materialize.rs +++ b/tests/materialize.rs @@ -1174,3 +1174,51 @@ fn up_materialize_only_writes_the_overlay_without_needing_pty() { ); assert!(String::from_utf8_lossy(&output.stdout).contains("materialized 1 operation")); } + +/// The `$ST_AGENT` render variable is a materialization writer, so it moves with the gate: a +/// catalog whose every subject carries an explicit ID renders the immutable agent ID, and an +/// unmigrated one keeps rendering the host-qualified bus identity. +/// +/// The generated Claude MCP declaration is the other identity writer here; it is proved at the +/// unit boundary by `src/materialize.rs::the_generated_mcp_declaration_carries_the_decided_agent_key`, +/// which needs no installed hook set. +#[test] +fn activated_materialization_renders_the_agent_id_into_the_render_environment() { + const MINTED_ID: &str = "01998f3a-2b7c-7c31-9f0e-2a6d4b8e5c10"; + + for (declared_id, expected_agent) in [(None, "Silber.cos"), (Some(MINTED_ID), MINTED_ID)] { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + let id_line = declared_id + .map(|id| format!(" id \"{id}\"\n")) + .unwrap_or_default(); + write( + &catalog.join("agents/Silber/cos/agent.kdl"), + format!( + r#"agent "cos" {{ + host "Silber" +{id_line} workspace "{}" + command "true" + render {{ + file "ACTOR" "$ST_AGENT" + }} +}} +"#, + workspace.display() + ), + ); + + let specs = discover(&catalog).specs; + assert_eq!(specs.len(), 1); + let report = materialize_catalog(&catalog, &specs, "Silber"); + assert!(report.is_clean(), "{:?}", report.errors); + + assert_eq!( + fs::read_to_string(workspace.join("ACTOR")).unwrap(), + expected_agent, + "$ST_AGENT expands to the runner-owned agent key" + ); + } +} diff --git a/tests/message.rs b/tests/message.rs index 44dc8009..71bbff3f 100644 --- a/tests/message.rs +++ b/tests/message.rs @@ -6,9 +6,12 @@ use std::fs; use std::path::Path; +use st2::identity::AgentSelector; use st2::message::{ - archive_dir, archive_msg, collect_thread, inbox_dir, list_dir, read_msg, reply_subject, - resolve_agent_dir, send_to_inbox, + LegacyAttribution, archive_dir, archive_msg, attribute_endpoint, collect_thread, inbox_dir, + list_dir, list_sent, read_msg, reply_recipient, reply_subject, resolve_agent_dir, + resolve_selected_agent_dir, send_selected_to_resolved_inbox, send_to_inbox, + send_to_resolved_inbox, }; fn write(root: &Path, rel: &str, contents: &str) { @@ -31,6 +34,429 @@ fn agent_kdl(identity: &str, host: &str) -> String { ) } +/// `agent_kdl` plus extra declaration fields, one per line, already indented. +fn agent_kdl_with(identity: &str, host: &str, extra: &str) -> String { + format!( + r#"agent "{identity}" {{ + identity "{identity}" + host "{host}" +{extra} type "service" + pty "agent" {{ + command "exec claude boot" + }} +}} +"# + ) +} + +const SENDER_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; +const RECIPIENT_ID: &str = "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3"; + +/// The one committed Sent row of a sender, as raw JSON. +fn sent_row(agent_dir: &Path, filename: &str) -> serde_json::Value { + let path = agent_dir + .join("resources/sent/messages") + .join(format!("{filename}.json")); + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap() +} + +/// An ordinary reference is an *address*: a dotted bare address and its host-qualified spelling +/// name the same subject, and a declared address replaces the positional identity fallback. +#[test] +fn an_ordinary_reference_resolves_through_the_address_algorithm() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "dev3/verifier/agent.kdl", + &agent_kdl_with( + "verifier", + "dev3", + " address \"dotfiles.fractal.keymap.verifier\"\n", + ), + ); + + let expected = root.join("dev3/verifier"); + assert_eq!( + resolve_agent_dir(root, "dotfiles.fractal.keymap.verifier", "dev3") + .unwrap() + .expect("the dotted bare address resolves"), + expected + ); + assert_eq!( + resolve_agent_dir(root, "dev3.dotfiles.fractal.keymap.verifier", "dev3") + .unwrap() + .expect("the host-qualified spelling resolves to the same subject"), + expected + ); + assert!( + resolve_agent_dir(root, "verifier", "dev3").unwrap().is_none(), + "the positional identity is only the fallback address, not a second route" + ); +} + +/// Absence and ambiguity are different answers. Two subjects sharing one address make an +/// unqualified reference undecidable, and the refusal names them instead of reporting "not found". +#[test] +fn an_ambiguous_address_refuses_and_names_the_surviving_subjects() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "dev3/chat/agent.kdl", &agent_kdl("chat", "dev3")); + write(root, "dev4/chat/agent.kdl", &agent_kdl("chat", "dev4")); + + let error = resolve_agent_dir(root, "chat", "dev3").unwrap_err(); + let rendered = format!("{error:#}"); + assert!(rendered.contains("is ambiguous"), "{rendered}"); + assert!(rendered.contains("dev3.chat"), "{rendered}"); + assert!(rendered.contains("dev4.chat"), "{rendered}"); + + // Qualifying it is exact, and an absent address is still a plain absence. + assert!( + resolve_agent_dir(root, "dev3.chat", "dev3") + .unwrap() + .is_some() + ); + assert!(resolve_agent_dir(root, "ghost", "dev3").unwrap().is_none()); +} + +/// An exact ID selector performs only ID lookup: equal bytes in the two namespaces do not collide, +/// so an address never answers an ID and an ID never answers an address. +#[test] +fn an_exact_agent_id_bypasses_address_lookup() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "dev3/keymap/agent.kdl", + &agent_kdl_with( + "keymap", + "dev3", + &format!(" id \"{SENDER_ID}\"\n address \"chat\"\n"), + ), + ); + let expected = root.join("dev3/keymap"); + + assert_eq!( + resolve_selected_agent_dir(root, &AgentSelector::Id(SENDER_ID.to_owned()), "dev3") + .unwrap() + .expect("the exact id resolves"), + expected + ); + assert!( + resolve_selected_agent_dir(root, &AgentSelector::Id("chat".to_owned()), "dev3") + .unwrap() + .is_none(), + "an address must not be readable as an id" + ); + assert_eq!( + resolve_selected_agent_dir(root, &AgentSelector::Address("chat".to_owned()), "dev3") + .unwrap() + .expect("the address resolves"), + expected + ); +} + +/// An address cutover takes effect immediately, and the released address is reusable by another +/// subject: the old address stops resolving to the subject that gave it up. +#[test] +fn an_address_cutover_is_immediate_and_the_old_address_is_reusable() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let first = |address: &str| { + agent_kdl_with( + "first", + "dev3", + &format!(" id \"{SENDER_ID}\"\n address \"{address}\"\n"), + ) + }; + let second = |address: &str| { + agent_kdl_with( + "second", + "dev3", + &format!(" id \"{RECIPIENT_ID}\"\n address \"{address}\"\n"), + ) + }; + write(root, "dev3/first/agent.kdl", &first("chat")); + write(root, "dev3/second/agent.kdl", &second("notes")); + assert_eq!( + resolve_agent_dir(root, "chat", "dev3").unwrap().unwrap(), + root.join("dev3/first") + ); + + write(root, "dev3/first/agent.kdl", &first("chat-v2")); + write(root, "dev3/second/agent.kdl", &second("chat")); + + assert_eq!( + resolve_agent_dir(root, "chat", "dev3").unwrap().unwrap(), + root.join("dev3/second"), + "the released address routes to its new holder, with no ambiguity from the old one" + ); + assert_eq!( + resolve_agent_dir(root, "chat-v2", "dev3").unwrap().unwrap(), + root.join("dev3/first") + ); + // The immutable ID is unaffected by either subject's address change. + assert_eq!( + resolve_selected_agent_dir(root, &AgentSelector::Id(SENDER_ID.to_owned()), "dev3") + .unwrap() + .unwrap(), + root.join("dev3/first") + ); + + // Both endpoints selected by exact ID: delivery follows the subject, not its address, and the + // rendered sender shows the address it holds *now*. + let filename = send_selected_to_resolved_inbox( + root, + &AgentSelector::Id(SENDER_ID.to_owned()), + "dev3", + &AgentSelector::Id(RECIPIENT_ID.to_owned()), + Some("by id"), + None, + &[], + "hi", + None, + None, + ) + .unwrap(); + let delivered = + fs::read_to_string(inbox_dir(&root.join("dev3/first")).join(&filename)).unwrap(); + assert!( + delivered.starts_with(&format!("---\nfrom: dev3.chat\nfrom-id: {RECIPIENT_ID}\n")), + "{delivered}" + ); +} + +/// A fully migrated catalog publishes version-2 rows: `from`/`to` are immutable IDs, the address +/// fields are publication-time display snapshots, and the endpoint kinds are explicit. The +/// rendered message carries the address for humans and the ID as authority. +#[test] +fn a_migrated_catalog_publishes_version_two_rows_with_ids_and_address_snapshots() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "dev3/sender/agent.kdl", + &agent_kdl_with( + "sender", + "dev3", + &format!(" id \"{SENDER_ID}\"\n address \"chat\"\n"), + ), + ); + write( + root, + "dev3/recipient/agent.kdl", + &agent_kdl_with("recipient", "dev3", &format!(" id \"{RECIPIENT_ID}\"\n")), + ); + + let filename = send_to_resolved_inbox( + root, + "recipient", + "dev3", + "chat", + Some("hello"), + None, + &[], + "body", + None, + None, + ) + .unwrap(); + + let delivered = fs::read_to_string( + inbox_dir(&root.join("dev3/recipient")).join(&filename), + ) + .unwrap(); + assert!( + delivered.starts_with(&format!("---\nfrom: dev3.chat\nfrom-id: {SENDER_ID}\n")), + "{delivered}" + ); + + let row = sent_row(&root.join("dev3/sender"), &filename); + assert_eq!(row["version"], 2); + assert_eq!(row["from"], SENDER_ID); + assert_eq!(row["to"], RECIPIENT_ID); + assert_eq!(row["fromAddress"], "dev3.chat"); + assert_eq!(row["toAddress"], "dev3.recipient"); + assert_eq!(row["fromKind"], "agent"); + assert_eq!(row["toKind"], "agent"); + + // The ledger reader verifies every content, row, and node digest, so this also proves the + // version-2 row's digest relations hold. The public `message sent --json` row projects the + // canonical recipient ID plus the display-only address snapshot and the explicit kind. + let view = list_sent(&root.join("dev3/sender"), false).unwrap(); + assert_eq!(view.messages.len(), 1); + assert_eq!(view.messages[0].to, RECIPIENT_ID); + assert_eq!(view.messages[0].to_address.as_deref(), Some("dev3.recipient")); + assert_eq!(view.messages[0].to_kind.as_deref(), Some("agent")); +} + +/// One unmigrated subject keeps legacy behavior normative for the whole catalog: the row stays +/// version 1, carries legacy bus identities, and grows no version-2 key — the pending record's +/// filename is the digest of its canonical JSON, so a stray key would break retry and recovery. +#[test] +fn a_partially_migrated_catalog_still_publishes_version_one_rows() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "dev3/sender/agent.kdl", + &agent_kdl_with( + "sender", + "dev3", + &format!(" id \"{SENDER_ID}\"\n address \"chat\"\n"), + ), + ); + write( + root, + "dev3/recipient/agent.kdl", + &agent_kdl("recipient", "dev3"), + ); + + let filename = send_to_resolved_inbox( + root, + "recipient", + "dev3", + "chat", + Some("hello"), + None, + &[], + "body", + None, + None, + ) + .unwrap(); + + let delivered = + fs::read_to_string(inbox_dir(&root.join("dev3/recipient")).join(&filename)).unwrap(); + assert!( + delivered.starts_with("---\nfrom: dev3.sender\nsubject: hello\n"), + "{delivered}" + ); + + let row = sent_row(&root.join("dev3/sender"), &filename); + assert_eq!(row["version"], 1); + assert_eq!(row["from"], "dev3.sender"); + assert_eq!(row["to"], "dev3.recipient"); + let mut keys = row + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "body", + "filename", + "from", + "idempotencyKey", + "inReplyTo", + "priority", + "renderedMessage", + "subject", + "tags", + "to", + "ts", + "version", + ] + ); + // The digest chain still verifies, so the legacy record's bytes are unchanged. + assert_eq!( + list_sent(&root.join("dev3/sender"), false) + .unwrap() + .messages + .len(), + 1 + ); + // A version-1 row declared neither, and the public row says so rather than defaulting. + let view = list_sent(&root.join("dev3/sender"), false).unwrap(); + assert_eq!(view.messages[0].to_address, None); + assert_eq!(view.messages[0].to_kind, None, "absent means absent"); +} + +/// A reassigned legacy bus identity denotes two subjects. Only the record's own state owner proves +/// which one, so every other endpoint is a historical address with no reply or automation +/// authority — never the ID of the subject that kept the bytes (`MESSAGE-R04`). +#[test] +fn a_reassigned_legacy_endpoint_is_attributed_only_for_its_state_owner() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "dev3/chat/agent.kdl", &agent_kdl("chat", "dev3")); + write(root, "dev3/reader/agent.kdl", &agent_kdl("reader", "dev3")); + write( + root, + ".st2/agent-id-migration.json", + &format!( + r#"{{"schema":"st2.agent-id-migration.v1","migratedAtMs":1,"reassigned":[{{"legacyBusIdentity":"dev3.chat","keptByAgentId":"dev3.chat","keptByPlane":"live","reassignedAgentId":"{RECIPIENT_ID}","reassignedHost":"dev3","reassignedIdentity":"chat","reassignedPlane":"archived"}}]}}"# + ), + ); + let migration = st2::catalog_migrate_ids::read_migration_record(root) + .unwrap() + .expect("the durable collision record"); + + // The sender of a sender-owned row is its state owner, so those bytes are attributable. + assert_eq!( + attribute_endpoint(Some(&migration), "dev3.chat", Some("dev3.chat")), + LegacyAttribution::Owned { + id: "dev3.chat".to_owned() + } + ); + // The same bytes at any other endpoint are not. + assert_eq!( + attribute_endpoint(Some(&migration), "dev3.chat", Some("dev3.reader")), + LegacyAttribution::Historical { + address: "dev3.chat".to_owned() + } + ); + // An untouched legacy identity is its own frozen ID and needs no attribution at all. + assert_eq!( + attribute_endpoint(Some(&migration), "dev3.reader", Some("dev3.reader")), + LegacyAttribution::Frozen { + reference: "dev3.reader".to_owned() + } + ); + + // An inbox row's state owner is its recipient, so a colliding sender is unattributable and a + // reply to it is refused rather than delivered to the subject that kept the bytes. + let reader_inbox = inbox_dir(&root.join("dev3/reader")); + let colliding = + send_to_inbox(&reader_inbox, "dev3.chat", Some("ping"), None, &[], "hi").unwrap(); + let colliding = read_msg(&reader_inbox, &colliding).unwrap(); + let refusal = reply_recipient(root, Some("dev3.reader"), &colliding).unwrap_err(); + assert!( + format!("{refusal:#}").contains("historical address"), + "{refusal:#}" + ); + + // An untouched sender still replies as an ordinary address, exactly as before migration. + let plain = + send_to_inbox(&reader_inbox, "dev3.other", Some("ping"), None, &[], "hi").unwrap(); + let plain = read_msg(&reader_inbox, &plain).unwrap(); + assert_eq!( + reply_recipient(root, Some("dev3.reader"), &plain).unwrap(), + AgentSelector::Address("dev3.other".to_owned()) + ); + + // A version-2 message states its sender's ID, so the reply is an exact-ID send and no + // attribution is needed. + let published = st2::message::send_to_inbox( + &reader_inbox, + "dev3.chat", + Some("ping"), + None, + &[], + "hi", + ) + .unwrap(); + let mut published = read_msg(&reader_inbox, &published).unwrap(); + published.from_id = Some(SENDER_ID.to_owned()); + assert_eq!( + reply_recipient(root, Some("dev3.reader"), &published).unwrap(), + AgentSelector::Id(SENDER_ID.to_owned()) + ); +} + /// A two-agent catalog on host `hetz`. Send to one by bus id → lands in *its* `resources/inbox`, /// never the other's. List, then archive. #[test] diff --git a/tests/message_cli.rs b/tests/message_cli.rs index 545d7cda..5bf02239 100644 --- a/tests/message_cli.rs +++ b/tests/message_cli.rs @@ -28,12 +28,17 @@ fn write_message(inbox: &Path, ts_ms: u64, suffix: &str, from: &str) { } fn write_agent(root: &Path, identity: &str) { + write_agent_with(root, identity, ""); +} + +/// [`write_agent`] plus extra declaration fields, one per line, already indented. +fn write_agent_with(root: &Path, identity: &str, extra: &str) { let directory = root.join("h").join(identity); fs::create_dir_all(&directory).unwrap(); fs::write( directory.join("agent.kdl"), format!( - "agent \"{identity}\" {{\n identity \"{identity}\"\n host \"h\"\n type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" + "agent \"{identity}\" {{\n identity \"{identity}\"\n host \"h\"\n{extra} type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" ), ) .unwrap(); @@ -1422,3 +1427,102 @@ fn malformed_catalog_declarations_disable_implicit_flat_fallback() { ); } } + +/// `$ST_AGENT` is the acting subject's *exact immutable agent ID*, never an address +/// (`docs/vrs/03-message/spec.md` "Selection follows one total order"). An activated subject whose +/// declared `address` differs from its identity is therefore only reachable through ID resolution: +/// resolving those bytes as an address would find nothing, so `send` and `sent` would refuse the +/// caller its own identity. The rendered sender still shows the address a human reads, with the ID +/// as the authority beside it. +#[test] +fn the_acting_subject_comes_from_st_agent_as_an_exact_id_and_publishes_its_current_address() { + const SENDER_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + const RECIPIENT_ID: &str = "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3"; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_agent_with(root, "sender", &format!(" id \"{SENDER_ID}\"\n address \"chat\"\n")); + write_agent_with(root, "recipient", &format!(" id \"{RECIPIENT_ID}\"\n")); + + let send = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "send", "recipient", "--root"]) + .arg(root) + .args(["--host", "h", "-m", "body"]) + .env("ST_AGENT", SENDER_ID) + .output() + .unwrap(); + assert!( + send.status.success(), + "{}", + String::from_utf8_lossy(&send.stderr) + ); + let filename = String::from_utf8_lossy(&send.stdout).trim().to_string(); + + let delivered = + fs::read_to_string(root.join("h/recipient/resources/inbox").join(&filename)).unwrap(); + assert!( + delivered.starts_with(&format!("---\nfrom: h.chat\nfrom-id: {SENDER_ID}\n")), + "{delivered}" + ); + + // The same ambient ID selects the sender's own durable index, whose public row is ID-keyed and + // carries the recipient's display-only address snapshot. + let listed = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "sent", "--root"]) + .arg(root) + .args(["--host", "h", "--json"]) + .env("ST_AGENT", SENDER_ID) + .output() + .unwrap(); + assert!( + listed.status.success(), + "{}", + String::from_utf8_lossy(&listed.stderr) + ); + let view: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(view["messages"][0]["to"], RECIPIENT_ID); + assert_eq!(view["messages"][0]["toAddress"], "h.recipient"); + assert_eq!(view["messages"][0]["toKind"], "agent"); +} + +/// The same ambient path on an unmigrated catalog stays byte-for-byte legacy: `$ST_AGENT` holds +/// `.`, whose frozen-ID and address spellings coincide, and the published record +/// grows no version-2 field. +#[test] +fn an_unmigrated_catalog_keeps_the_ambient_legacy_bus_identity_normative() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_agent(root, "sender"); + write_agent(root, "recipient"); + + let send = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "send", "recipient", "--root"]) + .arg(root) + .args(["--host", "h", "-m", "body"]) + .env("ST_AGENT", "h.sender") + .output() + .unwrap(); + assert!( + send.status.success(), + "{}", + String::from_utf8_lossy(&send.stderr) + ); + let filename = String::from_utf8_lossy(&send.stdout).trim().to_string(); + + let delivered = + fs::read_to_string(root.join("h/recipient/resources/inbox").join(&filename)).unwrap(); + assert_eq!(delivered, "---\nfrom: h.sender\n---\nbody\n", "{delivered}"); + + let listed = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "sent", "--root"]) + .arg(root) + .args(["--host", "h", "--json"]) + .env("ST_AGENT", "h.sender") + .output() + .unwrap(); + assert!(listed.status.success()); + let view: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(view["messages"][0]["to"], "h.recipient"); + assert!(view["messages"][0].get("toAddress").is_none()); + assert!(view["messages"][0].get("toKind").is_none()); +} diff --git a/tests/reconcile.rs b/tests/reconcile.rs index d083dc81..570e0e05 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -3,18 +3,40 @@ use std::collections::BTreeMap; use std::path::PathBuf; +use st2::identity::{IdentityActivation, LegacyReason}; use st2::reconcile::ObservedPtyPresentation; -use st2::reconcile::reconcile_selected; -use st2::reconcile::resolve_task; use st2::spec::{AgentDesiredState, AgentSpec, JobType, Resource, Task, TaskKind, TaskLifecycle}; use st2::{Session, reconcile as reconcile_result}; +/// Every case below this line pins the pre-activation projection, which DELTA-003 keeps normative +/// until the whole catalog is migrated. The activated projection has its own cases at the bottom. +fn legacy() -> IdentityActivation { + IdentityActivation::Legacy(LegacyReason::MigrationIncomplete) +} + fn reconcile<'a>( specs: &'a [AgentSpec], sessions: &[Session], host: &str, ) -> st2::ReconcilePlan<'a> { - reconcile_result(specs, sessions, host).unwrap() + reconcile_result(specs, sessions, host, &legacy()).unwrap() +} + +fn resolve_task<'a>( + specs: &'a [AgentSpec], + selector: &str, + host: &str, +) -> anyhow::Result<(&'a AgentSpec, &'a Task, String)> { + st2::reconcile::resolve_task(specs, selector, host, &legacy()) +} + +fn reconcile_selected<'a>( + specs: &'a [AgentSpec], + sessions: &[Session], + host: &str, + selector: &str, +) -> anyhow::Result> { + st2::reconcile::reconcile_selected(specs, sessions, host, selector, &legacy()) } #[test] @@ -512,7 +534,7 @@ fn suspended_agents_teardown_live_tasks_reap_dead_nonkeep_and_never_launch() { let mut running = specs[0].clone(); running.desired_state = AgentDesiredState::Running; - let error = reconcile_result(&[running], &[], "host").unwrap_err(); + let error = reconcile_result(&[running], &[], "host", &legacy()).unwrap_err(); assert!(error.to_string().contains("conflicting ST_AGENT")); } @@ -890,7 +912,7 @@ fn retired_agent_keeps_resources_and_still_reconciles_to_zero_live_tasks() { // Retired with a live seat: admits (no TaskIdentityAdmissionError), tears down the seat, // settles retirement, launches nothing. The declared resources do not block any of this. let retired = [with_resources()]; - let plan = reconcile_result(&retired, &[live("hetz.worker")], HOST) + let plan = reconcile_result(&retired, &[live("hetz.worker")], HOST, &legacy()) .expect("retired specs with resources are admitted"); assert_eq!(plan.teardown.len(), 1); assert_eq!(plan.teardown[0].pty_ids, vec!["hetz.worker"]); @@ -898,7 +920,7 @@ fn retired_agent_keeps_resources_and_still_reconciles_to_zero_live_tasks() { assert!(plan.launch.is_empty()); // Once the seat is gone, retirement is complete: nothing to tear down, gc, or launch. - let plan = reconcile_result(&retired, &[], HOST).expect("retired specs with resources admit"); + let plan = reconcile_result(&retired, &[], HOST, &legacy()).expect("retired specs with resources admit"); assert!(plan.teardown.is_empty()); assert!(plan.launch.is_empty()); assert!(plan.gc.is_empty()); @@ -909,7 +931,7 @@ fn retired_agent_keeps_resources_and_still_reconciles_to_zero_live_tasks() { unretired.desired_state = AgentDesiredState::Running; assert_eq!(unretired.resources.len(), 2); let specs = [unretired]; - let plan = reconcile_result(&specs, &[], HOST).expect("running specs with resources admit"); + let plan = reconcile_result(&specs, &[], HOST, &legacy()).expect("running specs with resources admit"); assert_eq!(plan.launch.len(), 1); assert_eq!(plan.launch[0].tasks[0].pty_id, "hetz.worker"); assert!(plan.settle_retirement.is_empty()); @@ -1104,3 +1126,284 @@ fn selecting_a_live_task_proves_it_alive_to_the_restart_cap() { assert_eq!(plan.adopt.len(), 1); assert!(plan.launch.is_empty()); } + +// --------------------------------------------------------------------------- +// DELTA-003 activated identity projection (R24-R26). Everything above this line +// pins the legacy projection that stays normative until the catalog is migrated. +// --------------------------------------------------------------------------- + +/// A UUIDv7-shaped ID, as `st2` mints for a subject created after activation. Its bytes share +/// nothing with the host-qualified bus identity, which is what makes the re-key observable. +const MINTED_ID: &str = "01998f3a-2b7c-7c31-9f0e-2a6d4b8e5c10"; + +fn observed(display_name: Option<&str>, tags: &[(&str, &str)]) -> ObservedPtyPresentation { + ObservedPtyPresentation { + display_name: display_name.map(String::from), + tags: tags + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + } +} + +/// Every task ID shape one subject can carry: the compact canonical agent task lowering keys to +/// the bus identity, a long-form named task with no ID, lowering's derived companion, and an +/// independently authored ID. +fn every_task_shape() -> AgentSpec { + let mut ding = task(TaskKind::Exec, "ding", Some("hetz.worker.ding"), Some("d")); + ding.derived = true; + let mut spec = svc( + "worker", + Some(HOST), + vec![ + task(TaskKind::Pty, "agent", Some("hetz.worker"), Some("run")), + task(TaskKind::Pty, "shell", None, Some("sh")), + ding, + task(TaskKind::Exec, "probe", Some("bespoke-probe"), Some("p")), + ], + ); + spec.description = Some("owns the build".into()); + spec +} + +fn launched(plan: &st2::ReconcilePlan<'_>) -> Vec<(String, String)> { + let mut rows = plan.launch[0] + .tasks + .iter() + .map(|target| { + ( + target.pty_id.clone(), + target.env["ST_AGENT"].clone(), + ) + }) + .collect::>(); + rows.sort(); + rows +} + +/// The continuity proof. Migration freezes a live subject's ID to its former bus identity, so +/// activating the target model must not move one task ID — and because a task ID *is* the name +/// `pty` binds its session socket for, not one socket path moves either. +#[test] +fn a_migrated_subject_keeps_byte_identical_task_ids_and_socket_paths() { + let before = [every_task_shape()]; + let mut migrated = every_task_shape(); + migrated.id = Some(migrated.bus_id(HOST)); + let after = [migrated]; + + let legacy_rows = launched(&reconcile(&before, &[], HOST)); + let activated_rows = launched( + &reconcile_result(&after, &[], HOST, &IdentityActivation::Activated).unwrap(), + ); + + assert_eq!( + legacy_rows, activated_rows, + "the frozen ID equals the former bus identity, so nothing may move" + ); + assert_eq!( + legacy_rows, + vec![ + ("bespoke-probe".to_owned(), "hetz.worker".to_owned()), + ("hetz.worker".to_owned(), "hetz.worker".to_owned()), + ("hetz.worker.ding".to_owned(), "hetz.worker".to_owned()), + ("hetz.worker.shell".to_owned(), "hetz.worker".to_owned()), + ] + ); +} + +/// R26 for a subject minted after activation: `ST_AGENT` and every default task ID key off the +/// immutable ID, never the host-qualified bus identity, while an authored ID stays authoritative. +#[test] +fn activated_identity_keys_st_agent_and_default_task_ids_on_the_immutable_id() { + let mut spec = every_task_shape(); + spec.id = Some(MINTED_ID.to_owned()); + let specs = [spec]; + + let plan = reconcile_result(&specs, &[], HOST, &IdentityActivation::Activated).unwrap(); + + assert_eq!( + launched(&plan), + vec![ + (MINTED_ID.to_owned(), MINTED_ID.to_owned()), + (format!("{MINTED_ID}.ding"), MINTED_ID.to_owned()), + (format!("{MINTED_ID}.shell"), MINTED_ID.to_owned()), + ("bespoke-probe".to_owned(), MINTED_ID.to_owned()), + ] + ); + // Host placement is never concatenated into the ID, and the legacy prefix is gone entirely. + assert!( + plan.launch[0] + .tasks + .iter() + .all(|target| !target.pty_id.starts_with("hetz.")), + "no activated task ID may retain the host-qualified prefix" + ); +} + +/// The schema-2 owned tag snapshot: `agent.actor.path` is retired as a removal, `agent.actor.id` +/// and `agent.actor.address` replace it, an absent description is a removal, and exec tasks get no +/// PTY metadata at all. +#[test] +fn activated_pty_metadata_is_the_schema_two_snapshot_and_retires_the_actor_path() { + let mut spec = every_task_shape(); + spec.id = Some(MINTED_ID.to_owned()); + spec.address = Some("build".into()); + let specs = [spec]; + + let plan = reconcile_result(&specs, &[], HOST, &IdentityActivation::Activated).unwrap(); + let target = |id: &str| { + plan.launch[0] + .tasks + .iter() + .find(|target| target.pty_id == id) + .expect("declared task is in the launch plan") + }; + + let expected = BTreeMap::from([ + ("agent.presentation.schema".to_owned(), Some("2".to_owned())), + ("agent.actor.id".to_owned(), Some(MINTED_ID.to_owned())), + ( + "agent.actor.address".to_owned(), + Some("hetz.build".to_owned()), + ), + ("agent.actor.path".to_owned(), None), + ( + "agent.presentation.description".to_owned(), + Some("owns the build".to_owned()), + ), + ("role".to_owned(), Some("agent".to_owned())), + ]); + assert_eq!( + target(MINTED_ID).presentation.as_ref().unwrap().tags, + expected + ); + // The mutable address is projected, and it is not the ownership key. + assert_eq!(target(MINTED_ID).bus_address, "hetz.build"); + assert_eq!(target(MINTED_ID).agent_key, MINTED_ID); + + let mut secondary = expected.clone(); + secondary.insert("role".to_owned(), None); + assert_eq!( + target(&format!("{MINTED_ID}.shell")) + .presentation + .as_ref() + .unwrap() + .tags, + secondary, + "every managed PTY carries the snapshot; only the canonical agent carries the role" + ); + + for exec in ["bespoke-probe", &format!("{MINTED_ID}.ding")] { + assert!( + target(exec).presentation.is_none(), + "exec task {exec} must receive no PTY metadata" + ); + } +} + +/// Native display metadata and the compatibility role belong to the canonical compact agent task +/// alone. A task merely *named* `agent` whose authored ID is not the agent ID is an ordinary +/// secondary PTY: it keeps its own display convention and has the role actively cleared. +#[test] +fn activated_role_and_display_name_are_scoped_to_the_canonical_agent_task() { + let mut spec = svc( + "worker", + Some(HOST), + vec![ + task(TaskKind::Pty, "agent", Some("hetz.worker"), Some("run")), + task(TaskKind::Pty, "agent", Some("bespoke-agent"), Some("alt")), + ], + ); + spec.id = Some(MINTED_ID.to_owned()); + spec.name = Some("Build Worker".into()); + let specs = [spec]; + + let plan = reconcile_result(&specs, &[], HOST, &IdentityActivation::Activated).unwrap(); + let presentation = |id: &str| { + plan.launch[0] + .tasks + .iter() + .find(|target| target.pty_id == id) + .unwrap() + .presentation + .clone() + .unwrap() + }; + + let canonical = presentation(MINTED_ID); + assert_eq!(canonical.display_name, Some(Some("Build Worker".to_owned()))); + assert_eq!(canonical.tags["role"], Some("agent".to_owned())); + + let other = presentation("bespoke-agent"); + assert_eq!( + other.display_name, None, + "a non-canonical PTY preserves its task-specific display convention" + ); + assert_eq!( + other.tags["role"], None, + "the compatibility role is actively cleared off every other PTY" + ); + // Presentation is never duplicated into tags. + assert!(!other.tags.contains_key("name")); + assert!(!canonical.tags.contains_key("name")); +} + +/// An address change is a nondisruptive cutover: it produces exactly one metadata patch and enters +/// no launch, teardown, garbage collection, or replacement. The healthy task keeps its ID — and so +/// its PID, creation identity, and generation. +#[test] +fn an_activated_address_change_patches_metadata_without_touching_the_runtime() { + let mut spec = svc( + "worker", + Some(HOST), + vec![task(TaskKind::Pty, "agent", Some("hetz.worker"), Some("run"))], + ); + spec.id = Some(MINTED_ID.to_owned()); + + // The live session already carries the converged snapshot for the current address. + let converged = observed( + None, + &[ + ("agent.presentation.schema", "2"), + ("agent.actor.id", MINTED_ID), + ("agent.actor.address", "hetz.worker"), + ("role", "agent"), + ("operator.note", "unrelated"), + ], + ); + let mut session = live(MINTED_ID); + session.presentation = Some(converged); + + let steady = [spec.clone()]; + let plan = reconcile_result(&steady, &[session.clone()], HOST, &IdentityActivation::Activated) + .unwrap(); + assert!( + plan.presentation.is_empty(), + "a converged snapshot must not be re-patched: {:?}", + plan.presentation + ); + + spec.address = Some("build".into()); + let cutover = [spec]; + let plan = + reconcile_result(&cutover, &[session], HOST, &IdentityActivation::Activated).unwrap(); + + assert_eq!(plan.presentation.len(), 1); + let patch = &plan.presentation[0]; + assert_eq!(patch.pty_id, MINTED_ID, "the task ID does not move"); + assert_eq!( + patch.tags["agent.actor.address"], + Some("hetz.build".to_owned()) + ); + assert_eq!(patch.tags["agent.actor.id"], Some(MINTED_ID.to_owned())); + // The patch owns only st2's own keys: an unrelated operator tag is not in the snapshot at all, + // so it is preserved rather than cleared. + assert!(!patch.tags.contains_key("operator.note")); + + assert!(plan.launch.is_empty(), "no launch"); + assert!(plan.teardown.is_empty(), "no teardown"); + assert!(plan.gc.is_empty(), "no garbage collection"); + assert!(plan.held.is_empty(), "no hold"); + assert_eq!(plan.adopt.len(), 1, "the healthy task is adopted unchanged"); + assert_eq!(plan.live, vec![MINTED_ID.to_owned()]); +} diff --git a/tests/resync.rs b/tests/resync.rs index 81a27e3d..b1d3b363 100644 --- a/tests/resync.rs +++ b/tests/resync.rs @@ -80,7 +80,14 @@ fn carrier_change_emits_one_superseded_resync_event_and_silent_stores_stay_quiet let specs = st2::discover_strict(catalog.path()).specs; assert!( supervisor - .refresh(&specs, &specs, "hetz", &[], &[]) + .refresh( + &specs, + &specs, + "hetz", + &[], + &[], + &st2::reconcile::identity_activation(catalog.path(), &specs), + ) .is_empty() ); std::thread::sleep(Duration::from_millis(300)); @@ -171,7 +178,14 @@ fn whole_file_declaration_replacement_by_rename_notifies_immediately() { let specs = st2::discover_strict(catalog.path()).specs; assert!( supervisor - .refresh(&specs, &specs, "hetz", &[], &[]) + .refresh( + &specs, + &specs, + "hetz", + &[], + &[], + &st2::reconcile::identity_activation(catalog.path(), &specs), + ) .is_empty() ); std::thread::sleep(Duration::from_millis(300)); @@ -268,7 +282,14 @@ fn declared_wasm_profile_resolves_a_scheme_uri_goal_binding_and_fires_on_change( let specs = st2::discover_strict(catalog.path()).specs; assert!( supervisor - .refresh(&specs, &specs, "hetz", &[], &[]) + .refresh( + &specs, + &specs, + "hetz", + &[], + &[], + &st2::reconcile::identity_activation(catalog.path(), &specs), + ) .is_empty() ); std::thread::sleep(Duration::from_millis(300)); @@ -302,7 +323,14 @@ fn declared_profile_class_governs_and_resolver_failures_stay_contained() { let specs = st2::discover_strict(silent.path()).specs; assert!( supervisor - .refresh(&specs, &specs, "hetz", &[], &[]) + .refresh( + &specs, + &specs, + "hetz", + &[], + &[], + &st2::reconcile::identity_activation(silent.path(), &specs), + ) .is_empty() ); std::thread::sleep(Duration::from_millis(300)); @@ -325,7 +353,14 @@ fn declared_profile_class_governs_and_resolver_failures_stay_contained() { fs::write(broken.path().join("broken.wasm"), b"not a module").unwrap(); let registry = st2::catalog::declared_profiles(broken.path()).unwrap(); let spec = &st2::discover_strict(broken.path()).specs[0]; - let set = st2::resync::watch_set_for(spec, "hetz", ®istry); + let set = st2::resync::watch_set_for( + spec, + "hetz", + ®istry, + &st2::identity::IdentityActivation::Legacy( + st2::identity::LegacyReason::MigrationIncomplete, + ), + ); assert!( !set.carriers.iter().any(|c| c.label == "goal"), "a failing resolver must not produce a carrier" diff --git a/tests/resync_notify_chain.rs b/tests/resync_notify_chain.rs index 3ce940a7..d0752c65 100644 --- a/tests/resync_notify_chain.rs +++ b/tests/resync_notify_chain.rs @@ -103,8 +103,14 @@ fn spawn(catalog: &Path) -> st2::resync::ResyncSupervisor { .filter(|spec| spec.desired_state.is_running()) .cloned() .collect::>(); - let diagnostics = - supervisor.refresh(&specs, &live_subscription_specs, "hetz", &[], &[]); + let diagnostics = supervisor.refresh( + &specs, + &live_subscription_specs, + "hetz", + &[], + &[], + &st2::reconcile::identity_activation(catalog, &specs), + ); assert!(diagnostics.is_empty(), "clean refresh: {diagnostics:?}"); std::thread::sleep(Duration::from_millis(300)); supervisor @@ -253,6 +259,9 @@ fn a_suspended_ancestor_contributes_its_layer_without_becoming_a_subscription() &specs, "hetz", &st2::catalog::declared_profiles(catalog.path()).unwrap(), + &st2::identity::IdentityActivation::Legacy( + st2::identity::LegacyReason::MigrationIncomplete, + ), ); assert!( set.carriers @@ -315,7 +324,12 @@ fn live_install_reports_an_unwalkable_notify_chain() { registry, ); - let diagnostics = supervisor.install_live(&specs[0], &specs, "hetz"); + let diagnostics = supervisor.install_live( + &specs[0], + &specs, + "hetz", + &st2::reconcile::identity_activation(catalog.path(), &specs), + ); assert!( diagnostics.iter().any(|diagnostic| { diagnostic.contains("supervisor chain is unwalkable") diff --git a/tests/run.rs b/tests/run.rs index fe431422..9f221149 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -473,8 +473,16 @@ use st2::park::{DirParkObserver, ParkObserver, ParkProjection, ParkState}; use st2::run::{grant_unpark_requests, publish_parks}; use st2::{FlappingCap, UpReport, discover, down, execute, reconcile as reconcile_result, up_once}; +/// These cases pin the pre-activation projection, which stays normative until the catalog is +/// migrated. +fn legacy() -> st2::identity::IdentityActivation { + st2::identity::IdentityActivation::Legacy( + st2::identity::LegacyReason::MigrationIncomplete, + ) +} + fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], host: &str) -> ReconcilePlan<'a> { - reconcile_result(specs, sessions, host).unwrap() + reconcile_result(specs, sessions, host, &legacy()).unwrap() } #[derive(Default)] @@ -609,7 +617,8 @@ fn lifecycle_work_precedes_a_bounded_presentation_batch() { let target = TaskTarget { kind: TaskKind::Exec, pty_id: "host.owner.work".to_owned(), - bus_id: "host.owner".to_owned(), + agent_key: "host.owner".to_owned(), + bus_address: "host.owner".to_owned(), name: "work".to_owned(), derived: false, launch: TaskLaunch::Shell("true".to_owned()), @@ -933,6 +942,181 @@ fn runner_owned_identity_is_rederived_for_dead_task_replay() { ); } +/// The activated half of **Runner-owned task identity**. A catalog whose every subject carries an +/// explicit ID turns the gate on, and then `ST_AGENT` is the raw immutable ID and every managed PTY +/// carries the schema-2 snapshot with `agent.actor.id` plus `agent.actor.address` in place of the +/// retired `agent.actor.path`. +/// +/// The migrated case is also the end-to-end continuity proof: a frozen ID equals the former bus +/// identity, so the spawned task IDs — the names `pty` binds session sockets for — are exactly the +/// legacy ones. +#[test] +fn runner_owned_identity_is_the_raw_agent_id_with_schema_two_metadata_once_activated() { + const MINTED_ID: &str = "01998f3a-2b7c-7c31-9f0e-2a6d4b8e5c10"; + + for (agent_id, expected_ids) in [ + // Migrated: the frozen ID is the former bus identity, so nothing moves. + ( + "hetz.demo", + vec!["hetz.demo".to_owned(), "hetz.demo.ding".to_owned()], + ), + // Minted after activation: default task IDs re-key onto the ID, host bytes and all. + ( + MINTED_ID, + vec![MINTED_ID.to_owned(), format!("{MINTED_ID}.ding")], + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/hetz/demo/agent.kdl", + &format!( + r#"agent "demo" {{ + host "hetz" + id "{agent_id}" + description "owns the build" + command "true" + ding +}} +"# + ), + ); + let runner = FakeRunner::default(); + + let report = up_once(tmp.path(), "hetz", &runner).unwrap(); + + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!(runner.spawned.borrow().as_slice(), expected_ids); + let targets = runner.spawned_targets.borrow(); + for target in targets.iter() { + assert_eq!( + target.env.get("ST_AGENT").map(String::as_str), + Some(agent_id), + "ST_AGENT is the raw agent ID, not the bus address: task {}", + target.pty_id + ); + } + + let agent_pty = targets + .iter() + .find(|target| target.pty_id == agent_id) + .expect("the canonical agent PTY is keyed by the agent ID"); + let tags = &agent_pty.presentation.as_ref().unwrap().tags; + assert_eq!( + tags.get("agent.presentation.schema"), + Some(&Some("2".to_owned())) + ); + assert_eq!(tags.get("agent.actor.id"), Some(&Some(agent_id.to_owned()))); + assert_eq!( + tags.get("agent.actor.address"), + Some(&Some("hetz.demo".to_owned())), + "the address stays the host-qualified route even when the ID is opaque" + ); + assert_eq!( + tags.get("agent.actor.path"), + Some(&None), + "schema 1's owned actor tag is retired as a removal" + ); + assert_eq!(tags.get("role"), Some(&Some("agent".to_owned()))); + + // The derived DING companion is an exec task: no PTY metadata, and its generated argv + // selects its owner by exact ID. + let ding = targets + .iter() + .find(|target| target.pty_id != agent_id) + .unwrap(); + assert_eq!(ding.kind, TaskKind::Exec); + assert!(ding.presentation.is_none()); + match &ding.launch { + TaskLaunch::Argv(argv) => { + let identity = argv + .windows(2) + .find(|pair| pair[0] == "--identity") + .expect("the generated DING argv names its owner"); + assert_eq!(identity[1], agent_id); + } + other => panic!("the generated DING companion must be direct argv: {other:?}"), + } + } +} + +/// `--identity` is an exact actor selector and `--runtime-id` is the task's on-disk ID, so once the +/// identity model is activated the session wrapper receives the immutable agent ID and the re-keyed +/// task ID — not the host-qualified bus identity the shared driver expansion emits. +#[test] +fn activated_wrapper_argv_carries_the_agent_id_and_the_task_id() { + const MINTED_ID: &str = "01998f3a-2b7c-7c31-9f0e-2a6d4b8e5c10"; + + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&catalog).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let declaration = catalog.join("worker.kdl"); + fs::write( + &declaration, + format!( + r#"agent "worker" {{ + host "h" + id "{MINTED_ID}" + workspace "{}" + deliver "pi-channel" + argv "pi" "-a" "boot" +}} +"#, + workspace.display() + ), + ) + .unwrap(); + let executable = catalog.join("bin/st2"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "test binary").unwrap(); + + let (specs, _) = st2::discover_file(&catalog, &declaration).unwrap(); + let compile = |activated: bool| { + let mut spec = specs.clone().into_iter().next().unwrap(); + let mut context = TaskCompileContext::new(catalog.clone(), executable.clone()).unwrap(); + if activated { + context = context.with_identity_activation(st2::identity::IdentityActivation::Activated); + } + compile_generated_tasks(std::slice::from_mut(&mut spec), "h", &context).unwrap(); + let task = spec.tasks.iter().find(|task| task.name == "agent").unwrap(); + let argv = task.argv.clone().unwrap(); + let value = |flag: &str| { + argv.windows(2) + .find(|pair| pair[0] == flag) + .map(|pair| pair[1].clone()) + .unwrap_or_else(|| panic!("wrapper argv has no {flag}: {argv:?}")) + }; + ( + value("--identity"), + value("--runtime-id"), + task.id.clone().unwrap(), + ) + }; + + // Legacy stays byte-for-byte on the host-qualified bus identity. + assert_eq!( + compile(false), + ( + "h.worker".to_owned(), + "h.worker".to_owned(), + "h.worker".to_owned() + ) + ); + // Activated: both runner-owned values key off the immutable ID. The lowered task ID itself is + // untouched in the declaration — reconciliation re-keys it — which is why `--runtime-id` is + // taken from the same shared rule rather than from `task.id`. + assert_eq!( + compile(true), + ( + MINTED_ID.to_owned(), + MINTED_ID.to_owned(), + "h.worker".to_owned() + ) + ); +} + #[test] fn up_once_launches_all_tasks_of_a_fresh_agent() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/validate.rs b/tests/validate.rs index 5e3e4e7c..abe76321 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -1133,7 +1133,7 @@ fn an_agent_id_equal_to_another_agents_address_is_not_a_collision() { let c = catalog(&[ ( "h/root/agent.kdl", - r#"agent "root" { host "h"; address "reviewer"; command "x" }"#, + r#"agent "root" { host "h"; id "h.root"; address "reviewer"; command "x" }"#, ), ( "h/worker/agent.kdl", @@ -1141,6 +1141,8 @@ fn an_agent_id_equal_to_another_agents_address_is_not_a_collision() { ), ]); + // Both declarations carry an `id`: a partially migrated catalog is separately inadmissible, so + // the namespace question is only askable of a uniformly migrated one. let r = validate(c.path()); assert_eq!( r.errors(), @@ -1302,3 +1304,63 @@ fn a_legacy_catalog_produces_byte_identical_duplicate_diagnostics() { r.issues ); } + +/// A catalog is either fully migrated to explicit agent IDs or not migrated at all. A mixture has +/// no coherent ID namespace, and refusing it is what stops a new declaration from entering a +/// migrated catalog without an `id` — including one published through a digest-bound path whose +/// exact bytes cannot have an id injected into them. +#[test] +fn a_partially_migrated_catalog_is_refused() { + let c = catalog(&[ + ( + "agents/h/root/agent.kdl", + "agent \"root\" {\n id \"h.root\"\n host \"h\"\n argv \"true\"\n}\n", + ), + ( + "agents/h/worker/agent.kdl", + "agent \"worker\" {\n host \"h\"\n supervisor \"h.root\"\n argv \"true\"\n}\n", + ), + ]); + let r = validate_for_host(c.path(), "h"); + assert!( + has(&r, "agent-id-missing", Severity::Error), + "expected agent-id-missing: {:?}", + r.issues + ); + let missing = r + .issues + .iter() + .find(|issue| issue.code == "agent-id-missing") + .expect("the issue just asserted"); + assert!( + missing.message.contains("1 of 2 declarations carry one"), + "{}", + missing.message + ); + assert_eq!(missing.agent.as_deref(), Some("worker")); +} + +/// Neither uniform state is a mixture: an all-legacy catalog and a fully migrated one both admit. +#[test] +fn a_uniformly_legacy_or_uniformly_migrated_catalog_admits() { + for (root_id, worker_id) in [("", ""), (" id \"h.root\"\n", " id \"h.worker\"\n")] { + let c = catalog(&[ + ( + "agents/h/root/agent.kdl", + &format!("agent \"root\" {{\n{root_id} host \"h\"\n argv \"true\"\n}}\n"), + ), + ( + "agents/h/worker/agent.kdl", + &format!( + "agent \"worker\" {{\n{worker_id} host \"h\"\n supervisor \"h.root\"\n argv \"true\"\n}}\n" + ), + ), + ]); + let r = validate_for_host(c.path(), "h"); + assert!( + !has(&r, "agent-id-missing", Severity::Error), + "{:?}", + r.issues + ); + } +}