diff --git a/INVARIANTS.md b/INVARIANTS.md index 8b7d7d9d..2b3e921a 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 `ST_AGENT=` — the raw catalog-global immutable agent ID — from reconciliation as an exact subject selector, never a mutable address and never separately host-qualified. 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 schema-2 owned snapshot `agent.presentation.schema=2`, `agent.subject.id=`, and `agent.subject.address=` (removed when the subject is non-routable), while only the canonical compact agent task whose task ID equals the agent ID carries the agent compatibility role and maps `name` to native display metadata. The external-actor tag `agent.actor.id` is not st2-owned and is never written or deleted. | `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` reports every row by immutable agent ID beside a nullable current bus address, and a proved non-routable retired subject's null address never weakens completeness. It 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` | | **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` | @@ -20,7 +20,7 @@ materialization, messaging, DING, or presence must preserve them. | **Prompt catalog convergence** | A resident catalog supervisor observes cooperative transaction commits through a constant-cost catalog-generation watcher and authorized direct Agent Spec publication through an independent declaration watcher. Both channels coalesce into the serialized loop without waiting for the periodic audit interval; failure of one leaves the other and the timer fallback available. Runtime/control noise remains excluded. | `src/watch.rs::catalog_generation_commit_wakes_catalog_watch`; `src/watch.rs::atomic_agent_bundle_publication_wakes_production_shaped_catalog`; `src/run.rs::supervisor_wakes_and_launches_a_new_direct_declaration`; `src/run.rs::failed_watch_installation_keeps_supervisor_on_timer_cadence` | | **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | | **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | -| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, inbox counts, and the appended `observedState`, `driverDiagnostic`, and `context` objects. Declared presence, desired lifecycle, observed harness state, native-driver degradation, and harness context are independent axes: none is derived from another; a missing observed or context record is `null`, while missing diagnostic evidence is explicitly `absent`, never healthy. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `src/agents.rs::driver_diagnostic_wire_exposes_failure_and_evidence_age_without_identity_payloads`; `src/agents.rs::context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state`; `tests/status_agents.rs::roster_uses_version_1_origin_time_for_last_activity`; `tests/status_agents.rs::roster_joins_a_real_context_record_independently_of_observed_state` | +| **Stable roster JSON** | `st2 agents --json [--enrich]` appends immutable `id`, effective `address`, and nullable `busAddress` (null for a proved non-routable retired subject) after its existing fields, keeps `identity` as the positional `.` declaration key, projects each supervisor as the parent's migrated ID, and otherwise preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, inbox counts, and the appended `observedState`, `driverDiagnostic`, and `context` objects. Declared presence, desired lifecycle, observed harness state, native-driver degradation, and harness context are independent axes: none is derived from another; a missing observed or context record is `null`, while missing diagnostic evidence is explicitly `absent`, never healthy. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `src/agents.rs::driver_diagnostic_wire_exposes_failure_and_evidence_age_without_identity_payloads`; `src/agents.rs::context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state`; `tests/status_agents.rs::roster_uses_version_1_origin_time_for_last_activity`; `tests/status_agents.rs::roster_joins_a_real_context_record_independently_of_observed_state` | | **Agent-declared presence** | Refresh preserves non-DND declared status and advances the version 1 heartbeat. A missing status starts as `available`. Legacy DND migrates without renewing its hold. Version 1 DND is not refreshed. Stale, malformed, or implausibly future heartbeats read as `unknown`. The outer Codex, Claude, and pi session wrappers own a five-minute heartbeat while their provider remains alive. | `src/status.rs::refresh_preserves_value_and_changes_heartbeat_bytes`; `src/status.rs::refresh_upgrades_legacy_dnd_without_renewing_the_hold`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::version_1_staleness_and_future_skew_are_bounded`; `src/status.rs::malformed_versioned_record_is_unknown_without_mtime_fallback`; `src/claude_session.rs::idle_provider_refreshes_presence_without_mcp_input`; `src/pi_session.rs::idle_pi_provider_refreshes_presence_without_channel_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` | | **Scoped delivery-input wakeups** | Native delivery pumps watch only their inputs: the agent's `resources/inbox` subtree and its `status` file. Runtime records written beside them by the pump's own process group — presence temp siblings, the `harness-state` and `harness-context` records with their locks and staged siblings, stream state — never wake delivery, so a producer that writes on every turn boundary cannot pump its own delivery loop. | `src/watch.rs::delivery_watcher_ignores_runtime_records_but_wakes_on_inbox_and_status` | | **Observed harness state discipline** | The `harness-state` record is written only by the owning session's driver processes — wrapper, channel, or hooks — serialized by a cross-process lock and coalesced against the on-disk record, atomically and byte-distinct on every write that lands, with freshness from its embedded timestamp and never file mtime; restating an unchanged state touches the record only when the refresh cadence is due. `unknown` is derived, never written: staleness, future skew, malformation, an unsupported schema, and a provably dead pty session each read as `unknown` with a distinct reason; an indeterminate liveness probe downgrades nothing; a missing record is no observation rather than `unknown`; no absence derives a definite state. A writer that loses sight of its harness stops heartbeating instead of refreshing a state it cannot see, and a predecessor session's record is never re-stamped. A reaped provider yields a terminal `ended` record carrying its real exit — written before the stop path's SIGKILL escalation and rewritten from the escalation cover when a grace-window reap observes the real status, proven against the real wrapper binaries of both stop implementations — and never a live state. | `src/harness_state.rs::unknown_state_is_derived_and_cannot_be_written`; `src/harness_state.rs::malformed_record_is_unknown_without_mtime_fallback`; `src/harness_state.rs::staleness_and_future_skew_derive_unknown_with_distinct_reasons`; `src/harness_state.rs::a_dead_session_reads_unknown_even_while_fresh_but_ended_survives`; `src/harness_state.rs::every_landed_write_is_byte_distinct_and_fresh_restatements_do_not_write`; `src/harness_state.rs::a_chatty_producer_restating_its_state_causes_zero_writes`; `src/harness_state.rs::concurrent_writers_defer_to_the_on_disk_record_not_their_cache`; `src/harness_state.rs::a_predecessor_sessions_record_is_never_heartbeat_eligible`; `src/harness_state.rs::missing_record_reads_as_none_not_unknown`; `src/codex_app_server.rs::pump_publishes_observations_and_stops_heartbeating_on_evidence_loss`; `src/claude_session.rs::a_provider_killed_mid_turn_reads_ended_rather_than_active`; `src/claude_session.rs::a_clean_provider_exit_writes_the_terminal_record`; `tests/harness_state_teardown.rs::stop_escalation_writes_the_terminal_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_stop_escalation_writes_the_cover_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_graceful_stop_records_the_real_reaped_exit` | @@ -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` preserves the subject's frozen immutable agent ID, releases its effective address so it no longer occupies the host-local namespace, and moves its 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` | | **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** | Task IDs default to `.` and the canonical compact task's ID is the agent ID itself, so every legacy socket path survives migration unchanged because a frozen legacy ID equals its former bus identity. 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` | | **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/agent-spec/src/discovery.rs b/crates/agent-spec/src/discovery.rs index 3fd38593..92fb5b91 100644 --- a/crates/agent-spec/src/discovery.rs +++ b/crates/agent-spec/src/discovery.rs @@ -332,8 +332,12 @@ fn unobservable_entry_may_hide_declaration( /// gain fields without breaking readers. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Declared { + /// `id` as written in the file. `None` for an unmigrated legacy declaration. + pub id: Option, /// `identity` as written in the file. `None` when the file relies on [`path_defaults`]. pub identity: Option, + /// `address` as written in the file. `None` when the positional identity is the fallback. + pub address: Option, /// `host` as written in the file. `None` when the file relies on [`path_defaults`]. pub host: Option, /// `type` as written, before it is normalized to `JobType::Service`. `None` when unset. @@ -343,6 +347,8 @@ pub struct Declared { impl From<&RawSpec> for Declared { fn from(raw: &RawSpec) -> Self { Self { + id: raw.id.clone(), + address: raw.address.clone(), identity: raw.identity.clone(), host: raw.host.clone(), job_type: raw.job_type.clone(), diff --git a/crates/agent-spec/src/identity.rs b/crates/agent-spec/src/identity.rs new file mode 100644 index 00000000..07c328ea --- /dev/null +++ b/crates/agent-spec/src/identity.rs @@ -0,0 +1,898 @@ +//! Agent identity — the immutable catalog-global agent ID and the mutable host-local address. +//! +//! Decision 0015 splits one overloaded `identity` into four values with different mutability and +//! scope (root `spec.md`, "Immutable agent ID, mutable address, and presentation"): +//! +//! | value | form | mutability | scope | use | +//! |---|---|---|---|---| +//! | agent ID | `id ""` | immutable | catalog-global | subject, ownership, automation, graph edges | +//! | agent address | `address "
"`, else positional `identity` | mutable | unique per logical host | human routing | +//! | bus address | `.` | derived | catalog | qualified human routing | +//! | presentation | `name` / `description` | mutable | non-unique | display only | +//! +//! Two rules make this module small. First, an ID is **opaque**: a frozen legacy ID literally *is* +//! the subject's former `.` bus identity, so the type never asserts that an ID is a +//! UUID nor that its dots mean placement. Second, ID and address are **separate typed namespaces** +//! — equal bytes never collide, so nothing here compares one against the other. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::io::Read as _; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Maximum byte length of an agent ID. A frozen legacy ID is `.`, so this bound has +/// to be at least as generous as the address bound it may have been derived from. +pub const AGENT_ID_MAX_BYTES: usize = 255; + +/// Maximum character length of an explicit agent address (root `spec.md`, F20). +pub const AGENT_ADDRESS_MAX_CHARS: usize = 255; + +/// Maximum character length of one dotted address segment. +pub const AGENT_ADDRESS_SEGMENT_MAX_CHARS: usize = 63; + +/// A catalog-global immutable agent ID. +/// +/// The value is opaque. New subjects receive [`AgentId::generate`] (UUIDv7); migrated legacy +/// subjects receive [`AgentId::frozen_legacy`], which preserves their existing runtime identifiers +/// byte for byte. A later host move changes placement and bus address, never the ID. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgentId(String); + +impl AgentId { + /// Admit one declared or migrated agent ID. + /// + /// The grammar is deliberately wider than the address grammar because a frozen legacy ID + /// carries whatever bytes that subject's `.` already used. It is narrow enough + /// that an ID stays usable as a PTY task ID and as a shell-free CLI argument: printable ASCII, + /// no whitespace, no path separators, and no leading or trailing dot. + pub fn parse(value: &str) -> Result { + if value.is_empty() { + return Err(IdentityError::EmptyId); + } + if value.len() > AGENT_ID_MAX_BYTES { + return Err(IdentityError::IdTooLong { + bytes: value.len(), + max: AGENT_ID_MAX_BYTES, + }); + } + for byte in value.bytes() { + if !byte.is_ascii_graphic() { + return Err(IdentityError::IdByte { byte }); + } + if byte == b'/' || byte == b'\\' { + return Err(IdentityError::IdByte { byte }); + } + } + if value.starts_with('.') || value.ends_with('.') || value.contains("..") { + return Err(IdentityError::IdDots); + } + Ok(Self(value.to_owned())) + } + + /// Generate a fresh UUIDv7 agent ID for a brand-new subject. + pub fn generate() -> Result { + Ok(Self(uuid_v7()?)) + } + + /// Freeze one legacy subject's existing host-qualified bus identity as its explicit ID. + /// + /// This is the migration primitive: the bytes are exactly what runtime ownership, task IDs, and + /// declaration-anchored state already use, so migration moves no state. + pub fn frozen_legacy(host: &str, identity: &str) -> Result { + Self::parse(&legacy_bus_identity(host, identity)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for AgentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl serde::Serialize for AgentId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for AgentId { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +/// A mutable host-local semantic address used for ordinary human routing. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgentAddress(String); + +impl AgentAddress { + /// Admit one explicit address: at most 255 ASCII characters, a dotted sequence of 1..=63 + /// character segments of lowercase letters, digits, and hyphens, each starting and ending with a + /// letter or digit (root `spec.md`, F20). + pub fn parse(value: &str) -> Result { + if value.is_empty() { + return Err(IdentityError::EmptyAddress); + } + if !value.is_ascii() { + return Err(IdentityError::AddressNotAscii); + } + if value.len() > AGENT_ADDRESS_MAX_CHARS { + return Err(IdentityError::AddressTooLong { + chars: value.len(), + max: AGENT_ADDRESS_MAX_CHARS, + }); + } + for segment in value.split('.') { + validate_address_segment(segment)?; + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AgentAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl serde::Serialize for AgentAddress { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for AgentAddress { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +fn validate_address_segment(segment: &str) -> Result<(), IdentityError> { + if segment.is_empty() { + return Err(IdentityError::AddressEmptySegment); + } + if segment.len() > AGENT_ADDRESS_SEGMENT_MAX_CHARS { + return Err(IdentityError::AddressSegmentTooLong { + segment: segment.to_owned(), + max: AGENT_ADDRESS_SEGMENT_MAX_CHARS, + }); + } + let bytes = segment.as_bytes(); + let boundary_ok = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + if !boundary_ok(bytes[0]) || !boundary_ok(bytes[bytes.len() - 1]) { + return Err(IdentityError::AddressSegmentBoundary { + segment: segment.to_owned(), + }); + } + for &byte in bytes { + if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') { + return Err(IdentityError::AddressSegmentByte { + segment: segment.to_owned(), + byte, + }); + } + } + Ok(()) +} + +/// The positional declaration key `.`. +/// +/// This is both the legacy address fallback and the bytes migration freezes as a legacy ID. It is +/// *not* the agent ID of an already migrated subject. +pub fn legacy_bus_identity(host: &str, identity: &str) -> String { + if host.is_empty() { + identity.to_owned() + } else { + format!("{host}.{identity}") + } +} + +/// Join a host and an effective address into the qualified bus address. +pub fn bus_address(host: &str, effective_address: &str) -> String { + legacy_bus_identity(host, effective_address) +} + +/// Every way one identity value can be rejected at the shared parse/authoring boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityError { + EmptyId, + IdTooLong { bytes: usize, max: usize }, + IdByte { byte: u8 }, + IdDots, + EmptyAddress, + AddressNotAscii, + AddressTooLong { chars: usize, max: usize }, + AddressEmptySegment, + AddressSegmentTooLong { segment: String, max: usize }, + AddressSegmentBoundary { segment: String }, + AddressSegmentByte { segment: String, byte: u8 }, + RandomnessUnavailable { detail: String }, +} + +impl fmt::Display for IdentityError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyId => write!(f, "agent `id` must not be empty"), + Self::IdTooLong { bytes, max } => { + write!(f, "agent `id` is {bytes} bytes; the limit is {max}") + } + Self::IdByte { byte } => write!( + f, + "agent `id` contains byte 0x{byte:02x}; an id is printable ASCII without \ + whitespace or path separators" + ), + Self::IdDots => write!( + f, + "agent `id` must not begin or end with `.` or contain an empty dotted segment" + ), + Self::EmptyAddress => write!(f, "agent `address` must not be empty"), + Self::AddressNotAscii => write!(f, "agent `address` must be ASCII"), + Self::AddressTooLong { chars, max } => { + write!(f, "agent `address` is {chars} characters; the limit is {max}") + } + Self::AddressEmptySegment => { + write!(f, "agent `address` must not contain an empty dotted segment") + } + Self::AddressSegmentTooLong { segment, max } => write!( + f, + "agent `address` segment '{segment}' is longer than {max} characters" + ), + Self::AddressSegmentBoundary { segment } => write!( + f, + "agent `address` segment '{segment}' must begin and end with a lowercase letter \ + or digit" + ), + Self::AddressSegmentByte { segment, byte } => write!( + f, + "agent `address` segment '{segment}' contains byte 0x{byte:02x}; segments use \ + lowercase letters, digits, and hyphens" + ), + Self::RandomnessUnavailable { detail } => { + write!(f, "cannot generate an agent id without randomness: {detail}") + } + } + } +} + +impl std::error::Error for IdentityError {} + +/// One admitted subject as the address book sees it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Subject { + /// Catalog-global immutable ID. + pub id: AgentId, + /// Resolved logical host. + pub host: String, + /// Effective address: the explicit `address`, else the positional `identity` fallback. + pub effective_address: String, + /// Whether ordinary address routing may reach this subject. Retired subjects are non-routable + /// and release their address. + pub routable: bool, +} + +impl Subject { + /// The qualified human route, or `None` for a proved non-routable subject. + pub fn bus_address(&self) -> Option { + self.routable + .then(|| bus_address(&self.host, &self.effective_address)) + } +} + +/// How a caller named one subject. The two forms are disjoint by construction: an exact ID never +/// falls through to address lookup, and an ordinary reference never reaches the ID namespace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSelector { + /// An explicit typed ID. Catalog-global exact lookup only. + Id(String), + /// An ordinary human reference: a bare address or a host-qualified bus address. + Address { + reference: String, + /// A caller-pinned host, when the command supplied one. + host: Option, + }, +} + +impl AgentSelector { + pub fn id(value: impl Into) -> Self { + Self::Id(value.into()) + } + + pub fn address(reference: impl Into) -> Self { + Self::Address { + reference: reference.into(), + host: None, + } + } + + pub fn address_on_host(reference: impl Into, host: impl Into) -> Self { + Self::Address { + reference: reference.into(), + host: Some(host.into()), + } + } + + /// The caller's literal input, for diagnostics. + pub fn as_input(&self) -> &str { + match self { + Self::Id(id) => id, + Self::Address { reference, .. } => reference, + } + } +} + +/// Why one selector did not name exactly one subject. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// No subject carries this ID. + UnknownId { id: String }, + /// More than one subject claims this ID. A catalog-global ID is unique by contract, so this is + /// a broken catalog rather than a selection to disambiguate. + AmbiguousId { id: String, count: usize }, + /// No routable subject answers this ordinary reference. + UnknownAddress { + reference: String, + host: Option, + }, + /// The reference is decidable as more than one distinct subject. + AmbiguousAddress { + reference: String, + candidates: Vec, + }, +} + +impl fmt::Display for ResolveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownId { id } => { + write!(f, "no agent with id '{id}' exists in the selected catalog") + } + Self::AmbiguousId { id, count } => write!( + f, + "agent id '{id}' is claimed by {count} declarations; a catalog-global id must be \ + unique, so the catalog is broken rather than the selector ambiguous" + ), + Self::UnknownAddress { + reference, + host: Some(host), + } => write!( + f, + "no routable agent answers address '{reference}' on host '{host}'" + ), + Self::UnknownAddress { + reference, + host: None, + } => write!(f, "no routable agent answers address '{reference}'"), + Self::AmbiguousAddress { + reference, + candidates, + } => write!( + f, + "address '{reference}' is ambiguous across {} subjects: {}", + candidates.len(), + candidates.join(", ") + ), + } + } +} + +impl std::error::Error for ResolveError {} + +/// One admitted uniqueness violation in a complete prospective catalog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UniquenessConflict { + /// Two subjects declare the same catalog-global ID. + DuplicateId { id: String, count: usize }, + /// Two routable subjects on one logical host share an effective address. This includes a + /// collision between an explicit address and another declaration's identity fallback. + DuplicateAddress { + host: String, + address: String, + ids: Vec, + }, +} + +impl fmt::Display for UniquenessConflict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateId { id, count } => { + write!(f, "agent id '{id}' is declared by {count} subjects") + } + Self::DuplicateAddress { host, address, ids } => write!( + f, + "effective address '{address}' on host '{host}' is claimed by {}", + ids.join(", ") + ), + } + } +} + +/// The complete catalog projection ordinary references resolve against. +/// +/// The book is built from one immutable discovery snapshot so a lookup and the uniqueness proof it +/// depends on describe the same catalog generation. +#[derive(Debug, Clone, Default)] +pub struct AddressBook { + subjects: Vec, + hosts: BTreeSet, +} + +impl AddressBook { + /// Build the book from every admitted subject. Admitted logical hosts are exactly the hosts + /// those subjects resolve to, which is what makes a dotted split decidable without guessing. + pub fn new(subjects: Vec) -> Self { + let hosts = subjects + .iter() + .map(|subject| subject.host.clone()) + .filter(|host| !host.is_empty()) + .collect(); + Self { subjects, hosts } + } + + pub fn subjects(&self) -> &[Subject] { + &self.subjects + } + + pub fn hosts(&self) -> &BTreeSet { + &self.hosts + } + + /// Exact catalog-global ID lookup. Never falls through to address lookup. + /// + /// An ID is catalog-global and unique by contract, so two subjects claiming one ID is a broken + /// catalog, not a choice to make. Returning the first match would let a duplicate-ID catalog + /// silently bind ownership, authority, or a message to whichever declaration happened to be + /// discovered first, so this refuses instead (R19: unknown and ambiguous targets refuse before + /// writes, listing, or actions). + pub fn resolve_id(&self, id: &str) -> Result<&Subject, ResolveError> { + let mut matches = self + .subjects + .iter() + .filter(|subject| subject.id.as_str() == id); + let first = matches + .next() + .ok_or_else(|| ResolveError::UnknownId { id: id.to_owned() })?; + match matches.next() { + None => Ok(first), + Some(_) => { + let count = self + .subjects + .iter() + .filter(|subject| subject.id.as_str() == id) + .count(); + Err(ResolveError::AmbiguousId { + id: id.to_owned(), + count, + }) + } + } + } + + /// The fail-closed bare-or-qualified ordinary reference algorithm (root `spec.md`): + /// + /// 1. a host-pinned reference tries the complete input as an address in that host plus the + /// qualified split whose prefix equals the pinned host; + /// 2. an unpinned reference tries the complete input as a bare address across the catalog plus + /// every dotted split whose prefix is an admitted logical host and whose suffix is an + /// effective address in that host; + /// 3. candidates deduplicate by agent ID and exactly one distinct subject must remain. + pub fn resolve_address( + &self, + reference: &str, + pinned_host: Option<&str>, + ) -> Result<&Subject, ResolveError> { + let mut candidates: Vec<&Subject> = Vec::new(); + + match pinned_host { + Some(host) => { + self.collect_routable(host, reference, &mut candidates); + if let Some(rest) = reference + .strip_prefix(host) + .and_then(|rest| rest.strip_prefix('.')) + { + self.collect_routable(host, rest, &mut candidates); + } + } + None => { + for subject in &self.subjects { + if subject.routable && subject.effective_address == reference { + push_unique(&mut candidates, subject); + } + } + for (index, byte) in reference.bytes().enumerate() { + if byte != b'.' { + continue; + } + let (host, rest) = (&reference[..index], &reference[index + 1..]); + if !self.hosts.contains(host) { + continue; + } + self.collect_routable(host, rest, &mut candidates); + } + } + } + + match candidates.as_slice() { + [] => Err(ResolveError::UnknownAddress { + reference: reference.to_owned(), + host: pinned_host.map(str::to_owned), + }), + [only] => Ok(only), + many => { + let mut candidates = many + .iter() + .map(|subject| { + format!( + "{} ({})", + subject.id, + bus_address(&subject.host, &subject.effective_address) + ) + }) + .collect::>(); + candidates.sort(); + Err(ResolveError::AmbiguousAddress { + reference: reference.to_owned(), + candidates, + }) + } + } + } + + /// Resolve one typed selector. + pub fn resolve(&self, selector: &AgentSelector) -> Result<&Subject, ResolveError> { + match selector { + AgentSelector::Id(id) => self.resolve_id(id), + AgentSelector::Address { reference, host } => { + self.resolve_address(reference, host.as_deref()) + } + } + } + + /// Append every routable subject on `host` whose effective address is exactly `address`. + fn collect_routable<'a>( + &'a self, + host: &str, + address: &str, + candidates: &mut Vec<&'a Subject>, + ) { + for subject in &self.subjects { + if subject.routable && subject.host == host && subject.effective_address == address { + push_unique(candidates, subject); + } + } + } + + /// Every uniqueness violation in this complete prospective catalog: catalog-global ID + /// duplicates and host-local effective-address duplicates among routable subjects. + pub fn conflicts(&self) -> Vec { + let mut conflicts = Vec::new(); + + let mut by_id: BTreeMap<&str, usize> = BTreeMap::new(); + for subject in &self.subjects { + *by_id.entry(subject.id.as_str()).or_default() += 1; + } + for (id, count) in by_id { + if count > 1 { + conflicts.push(UniquenessConflict::DuplicateId { + id: id.to_owned(), + count, + }); + } + } + + let mut by_address: BTreeMap<(&str, &str), Vec<&str>> = BTreeMap::new(); + for subject in self.subjects.iter().filter(|subject| subject.routable) { + by_address + .entry((subject.host.as_str(), subject.effective_address.as_str())) + .or_default() + .push(subject.id.as_str()); + } + for ((host, address), ids) in by_address { + if ids.len() > 1 { + let mut ids = ids.iter().map(|id| (*id).to_owned()).collect::>(); + ids.sort(); + conflicts.push(UniquenessConflict::DuplicateAddress { + host: host.to_owned(), + address: address.to_owned(), + ids, + }); + } + } + + conflicts + } +} + +/// Candidate accumulation deduplicates by agent ID: the same subject reached as a bare address and +/// again as a host-qualified split is one subject, not an ambiguity. The candidate set is bounded +/// by the dot count of one reference, so a linear scan is the right structure. +fn push_unique<'a>(candidates: &mut Vec<&'a Subject>, subject: &'a Subject) { + if !candidates + .iter() + .any(|candidate| candidate.id == subject.id) + { + candidates.push(subject); + } +} + +/// Format a UUIDv7 as its canonical lowercase hyphenated string. +fn uuid_v7() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since| since.as_millis()) + .unwrap_or_default() as u64 + & 0x0000_FFFF_FFFF_FFFF; + let mut bytes = [0u8; 16]; + bytes[..6].copy_from_slice(&millis.to_be_bytes()[2..]); + fill_random(&mut bytes[6..])?; + bytes[6] = (bytes[6] & 0x0F) | 0x70; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + let hex = |slice: &[u8]| { + slice + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }; + Ok(format!( + "{}-{}-{}-{}-{}", + hex(&bytes[0..4]), + hex(&bytes[4..6]), + hex(&bytes[6..8]), + hex(&bytes[8..10]), + hex(&bytes[10..16]) + )) +} + +/// Fill `buffer` from the operating system CSPRNG. A failure refuses rather than degrading to a +/// predictable id: a colliding "unique" subject ID is unrecoverable. +fn fill_random(buffer: &mut [u8]) -> Result<(), IdentityError> { + let mut source = std::fs::File::open("/dev/urandom").map_err(|error| { + IdentityError::RandomnessUnavailable { + detail: format!("open /dev/urandom: {error}"), + } + })?; + source + .read_exact(buffer) + .map_err(|error| IdentityError::RandomnessUnavailable { + detail: format!("read /dev/urandom: {error}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subject(id: &str, host: &str, address: &str) -> Subject { + Subject { + id: AgentId::parse(id).unwrap(), + host: host.to_owned(), + effective_address: address.to_owned(), + routable: true, + } + } + + #[test] + fn generated_ids_are_distinct_uuid_v7_values() { + let first = AgentId::generate().unwrap(); + let second = AgentId::generate().unwrap(); + assert_ne!(first, second); + for id in [&first, &second] { + let text = id.as_str(); + assert_eq!(text.len(), 36, "{text}"); + let fields = text.split('-').collect::>(); + assert_eq!( + fields.iter().map(|f| f.len()).collect::>(), + vec![8, 4, 4, 4, 12] + ); + assert!(text.chars().all(|c| c == '-' || c.is_ascii_hexdigit())); + assert_eq!(&fields[2][..1], "7", "version nibble: {text}"); + let variant = u8::from_str_radix(&fields[3][..1], 16).unwrap(); + assert_eq!(variant & 0xC, 0x8, "variant nibble: {text}"); + } + } + + #[test] + fn frozen_legacy_id_is_the_existing_bus_identity() { + let id = AgentId::frozen_legacy("dev3", "dotfiles.fractal.verifier").unwrap(); + assert_eq!(id.as_str(), "dev3.dotfiles.fractal.verifier"); + } + + #[test] + fn address_grammar_admits_dotted_lowercase_segments_and_refuses_everything_else() { + assert!(AgentAddress::parse("dotfiles.fractal.keymap.verifier").is_ok()); + assert!(AgentAddress::parse("a").is_ok()); + assert!(AgentAddress::parse("a-b").is_ok()); + assert!(AgentAddress::parse("").is_err()); + assert!(AgentAddress::parse("Upper").is_err()); + assert!(AgentAddress::parse("has_underscore").is_err()); + assert!(AgentAddress::parse("-leading").is_err()); + assert!(AgentAddress::parse("trailing-").is_err()); + assert!(AgentAddress::parse("a..b").is_err()); + assert!(AgentAddress::parse(".a").is_err()); + assert!(AgentAddress::parse("a.").is_err()); + assert!(AgentAddress::parse(&"a".repeat(64)).is_err()); + assert!(AgentAddress::parse(&format!("{}.{}", "a".repeat(63), "b".repeat(63))).is_ok()); + let too_long = std::iter::repeat_n("abcd".to_string(), 64) + .collect::>() + .join("."); + assert!(too_long.len() > AGENT_ADDRESS_MAX_CHARS); + assert!(AgentAddress::parse(&too_long).is_err()); + } + + #[test] + fn id_grammar_is_opaque_but_refuses_unusable_bytes() { + assert!(AgentId::parse("dev3.omp.zf8bz8y7").is_ok()); + assert!(AgentId::parse("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1").is_ok()); + assert!(AgentId::parse("Mixed.Case_Legacy").is_ok()); + assert!(AgentId::parse("").is_err()); + assert!(AgentId::parse("has space").is_err()); + assert!(AgentId::parse("has/slash").is_err()); + assert!(AgentId::parse("has\\backslash").is_err()); + assert!(AgentId::parse(".leading").is_err()); + assert!(AgentId::parse("trailing.").is_err()); + assert!(AgentId::parse("double..dot").is_err()); + assert!(AgentId::parse(&"a".repeat(256)).is_err()); + } + + #[test] + fn id_and_address_are_separate_namespaces() { + let book = AddressBook::new(vec![ + subject("dev3.alpha", "dev3", "alpha"), + subject("uuid-beta", "dev3", "dev3.alpha"), + ]); + // Equal bytes across the two namespaces are not a conflict. + assert!(book.conflicts().is_empty()); + assert_eq!(book.resolve_id("dev3.alpha").unwrap().id.as_str(), "dev3.alpha"); + // The ordinary reference `dev3.alpha` is decidable: the bare-address candidate is the + // second subject and the qualified split is the first, so it is ambiguous, not a silent win. + assert!(matches!( + book.resolve_address("dev3.alpha", None), + Err(ResolveError::AmbiguousAddress { .. }) + )); + } + + #[test] + fn unpinned_reference_tries_bare_then_admitted_host_splits() { + let book = AddressBook::new(vec![ + subject("id-a", "dev3", "dotfiles.fractal.verifier"), + subject("id-b", "dev4", "other"), + ]); + assert_eq!( + book.resolve_address("dotfiles.fractal.verifier", None) + .unwrap() + .id + .as_str(), + "id-a" + ); + assert_eq!( + book.resolve_address("dev3.dotfiles.fractal.verifier", None) + .unwrap() + .id + .as_str(), + "id-a" + ); + // `mbp` is not an admitted host, so no split is attempted and the lookup fails closed. + assert!(matches!( + book.resolve_address("mbp.dotfiles.fractal.verifier", None), + Err(ResolveError::UnknownAddress { .. }) + )); + } + + #[test] + fn host_pinned_reference_accepts_bare_and_self_qualified_input_only() { + let book = AddressBook::new(vec![ + subject("id-a", "dev3", "worker"), + subject("id-b", "dev4", "worker"), + ]); + assert_eq!( + book.resolve_address("worker", Some("dev3")).unwrap().id.as_str(), + "id-a" + ); + assert_eq!( + book.resolve_address("dev3.worker", Some("dev3")) + .unwrap() + .id + .as_str(), + "id-a" + ); + // A pinned host never reaches another host's split. + assert!(matches!( + book.resolve_address("dev4.worker", Some("dev3")), + Err(ResolveError::UnknownAddress { .. }) + )); + // The same address on two hosts is legal; only an unpinned bare reference is ambiguous. + assert!(matches!( + book.resolve_address("worker", None), + Err(ResolveError::AmbiguousAddress { .. }) + )); + } + + #[test] + fn retired_subjects_are_non_routable_and_release_their_address() { + let mut retired = subject("id-a", "dev3", "worker"); + retired.routable = false; + let book = AddressBook::new(vec![retired, subject("id-b", "dev3", "worker")]); + // The retired subject does not occupy the namespace, so no conflict and no ambiguity. + assert!(book.conflicts().is_empty()); + assert_eq!( + book.resolve_address("worker", Some("dev3")).unwrap().id.as_str(), + "id-b" + ); + // It remains reachable by exact ID. + assert_eq!(book.resolve_id("id-a").unwrap().bus_address(), None); + } + + #[test] + fn duplicate_ids_and_host_local_addresses_are_conflicts() { + let book = AddressBook::new(vec![ + subject("dup", "dev3", "a"), + subject("dup", "dev4", "b"), + subject("id-c", "dev3", "shared"), + subject("id-d", "dev3", "shared"), + ]); + let conflicts = book.conflicts(); + assert!(conflicts.iter().any(|conflict| matches!( + conflict, + UniquenessConflict::DuplicateId { id, count: 2 } if id == "dup" + ))); + assert!(conflicts.iter().any(|conflict| matches!( + conflict, + UniquenessConflict::DuplicateAddress { host, address, .. } + if host == "dev3" && address == "shared" + ))); + } + + #[test] + fn a_duplicate_agent_id_refuses_instead_of_binding_the_first_declaration() { + // A catalog-global id is unique by contract. Returning the first match would let a broken + // catalog silently bind ownership or authority to whichever subject was discovered first. + let book = AddressBook::new(vec![ + subject("dup", "dev3", "first"), + subject("dup", "dev4", "second"), + ]); + assert!(matches!( + book.resolve_id("dup"), + Err(ResolveError::AmbiguousId { count: 2, .. }) + )); + assert!(matches!( + book.resolve(&AgentSelector::id("dup")), + Err(ResolveError::AmbiguousId { .. }) + )); + // The conflict is also reported as a catalog-level uniqueness violation. + assert!(book.conflicts().iter().any(|conflict| matches!( + conflict, + UniquenessConflict::DuplicateId { count: 2, .. } + ))); + } + + #[test] + fn exact_id_selection_never_falls_through_to_address_lookup() { + let book = AddressBook::new(vec![subject("id-a", "dev3", "worker")]); + assert!(matches!( + book.resolve(&AgentSelector::id("worker")), + Err(ResolveError::UnknownId { .. }) + )); + assert!(matches!( + book.resolve(&AgentSelector::id("dev3.worker")), + Err(ResolveError::UnknownId { .. }) + )); + assert_eq!( + book.resolve(&AgentSelector::id("id-a")).unwrap().id.as_str(), + "id-a" + ); + } +} diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 1a407922..324ea326 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -71,6 +71,37 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result { for child in &node.children { match child.name.as_str() { + "id" => { + anyhow::ensure!(raw.id.is_none(), "agent declares `id` more than once"); + anyhow::ensure!( + child.type_name.is_none() + && child.children.is_empty() + && child.entries.len() == 1 + && child.entries[0].name.is_none(), + "agent `id` must contain exactly one positional string" + ); + raw.id = Some( + arg_string(child) + .ok_or_else(|| anyhow::anyhow!("agent `id` value must be a string"))?, + ); + } + "address" => { + anyhow::ensure!( + raw.address.is_none(), + "agent declares `address` more than once" + ); + anyhow::ensure!( + child.type_name.is_none() + && child.children.is_empty() + && child.entries.len() == 1 + && child.entries[0].name.is_none(), + "agent `address` must contain exactly one positional string" + ); + raw.address = Some( + arg_string(child) + .ok_or_else(|| anyhow::anyhow!("agent `address` value must be a string"))?, + ); + } "identity" => raw.identity = arg_string(child).or(raw.identity), "name" => parse_presentation(child, "name", &mut raw.name)?, "description" => parse_presentation(child, "description", &mut raw.description)?, diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index 050b5c13..c8a24e7e 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -30,6 +30,7 @@ pub const AGENT_SPEC_REVISION: &str = env!("AGENT_SPEC_REVISION"); pub mod declared; pub mod discovery; +pub mod identity; mod kdl_format; pub mod spec; pub mod profile; @@ -41,6 +42,11 @@ pub use declared::{ DeclaredNode, DeclaredParse, DeclaredSeverity, DeclaredSpan, DeclaredValue, parse_declared_document, parse_declared_file, }; +pub use identity::{ + AGENT_ADDRESS_MAX_CHARS, AGENT_ADDRESS_SEGMENT_MAX_CHARS, AGENT_ID_MAX_BYTES, AddressBook, + AgentAddress, AgentId, AgentSelector, IdentityError, ResolveError, Subject, + UniquenessConflict, bus_address, legacy_bus_identity, +}; pub use discovery::{ Declared, Discovered, SpecError, discover, discover_file, discover_strict, is_catalog_path, parse_declared, path_defaults, @@ -48,8 +54,8 @@ pub use discovery::{ pub use spec::{ AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryReadiness, DeliveryTransport, Driver, JobType, OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, - SessionDriver, Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, - stream_name_of_task, validate_desired_state_reason, + SessionDriver, Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, address_book, + parse_duration, stream_name_of_task, validate_desired_state_reason, }; pub use profile::{ DEFAULT_SELECTOR_LIMIT_BYTES, DescriptorValidationError, PROFILE_DESCRIPTOR_ABI_VERSION, diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index 58fc7884..ac19ac68 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -21,6 +21,10 @@ use std::time::Duration; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Serialize}; +use crate::identity::{ + AddressBook, AgentAddress, AgentId, Subject, bus_address, legacy_bus_identity, +}; + /// Maximum Unicode scalar count for an agent's human-facing label. pub const AGENT_NAME_MAX_CHARS: usize = 160; /// Maximum Unicode scalar count for an agent's enduring responsibility description. @@ -314,8 +318,17 @@ impl AgentDesiredState { /// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentSpec { - /// Unique id; the bus id is `.`. + /// Explicit catalog-global immutable agent ID. + /// + /// `None` is an unmigrated legacy declaration. Its implicit ID is exactly the value migration + /// freezes — `.` — so [`AgentSpec::agent_id`] is the single ownership + /// selector before and after migration and no declaration-anchored state moves when the field + /// appears. + pub id: Option, + /// Positional declaration key and legacy address fallback. Not immutable subject identity. pub identity: String, + /// Explicit mutable host-local semantic address. `None` falls back to `identity`. + pub address: Option, /// Optional mutable human-facing label. Never used as an automation selector. pub name: Option, /// Optional enduring responsibility boundary. Never used for lifecycle decisions. @@ -585,13 +598,49 @@ impl Default for Restart { } impl AgentSpec { - /// The bus id this spec compiles to — `.` — using `this_host` when `host` is unset. - pub fn bus_id(&self, this_host: &str) -> String { - format!( - "{}.{}", - self.host.as_deref().unwrap_or(this_host), - self.identity - ) + /// The catalog-global immutable agent ID that owns this subject's runtime, durable state, + /// graph edges, and automation. + /// + /// An explicit `id` is authoritative. An unmigrated legacy declaration yields exactly the + /// bytes migration freezes, so this is one selector with one meaning across the transition. + /// Never use it as a human route: after an address change the two diverge. + pub fn agent_id(&self, this_host: &str) -> String { + match &self.id { + Some(id) => id.as_str().to_owned(), + None => self.legacy_bus_identity(this_host), + } + } + + /// The positional declaration key `.` — the legacy address fallback and the + /// exact bytes legacy-ID migration freezes. + pub fn legacy_bus_identity(&self, this_host: &str) -> String { + legacy_bus_identity(self.resolved_host(this_host), &self.identity) + } + + /// The effective address: explicit `address` when present, else the positional `identity`. + pub fn effective_address(&self) -> &str { + match &self.address { + Some(address) => address.as_str(), + None => &self.identity, + } + } + + /// The qualified human route `.`. + /// + /// This routes; it never owns. A retired subject is non-routable — read + /// [`AgentSpec::subject`] and consult [`Subject::bus_address`] when routability matters. + pub fn bus_address(&self, this_host: &str) -> String { + bus_address(self.resolved_host(this_host), self.effective_address()) + } + + /// This declaration as one address-book subject. + pub fn subject(&self, this_host: &str) -> anyhow::Result { + Ok(Subject { + id: AgentId::parse(&self.agent_id(this_host))?, + host: self.resolved_host(this_host).to_owned(), + effective_address: self.effective_address().to_owned(), + routable: !self.desired_state.is_retired(), + }) } /// The host that should run this spec, defaulting to `this_host` when unset. @@ -624,6 +673,20 @@ impl AgentSpec { } } +/// Project one complete catalog snapshot into the address book ordinary references resolve +/// through. +/// +/// Build this from an immutable discovery result so a lookup and the uniqueness proof it depends on +/// describe the same catalog generation. A declaration whose implicit or explicit ID cannot be +/// admitted is a catalog error, not a silently skipped subject. +pub fn address_book(specs: &[AgentSpec], this_host: &str) -> anyhow::Result { + specs + .iter() + .map(|spec| spec.subject(this_host)) + .collect::>>() + .map(AddressBook::new) +} + // ---- Duration parsing ("60s", "5s", "20m", "2h", "3d") --------------------------------------- /// Parse a duration like `60s` / `5m` / `2h` / `3d` (also a bare integer = seconds). @@ -658,7 +721,11 @@ pub fn parse_duration(s: &str) -> Result { /// render-agnostic. #[derive(Debug, Default, Deserialize)] pub(crate) struct RawSpec { + /// Explicit catalog-global immutable agent ID, as written. + pub id: Option, pub identity: Option, + /// Explicit mutable host-local semantic address, as written. + pub address: Option, pub name: Option, pub description: Option, pub host: Option, @@ -1209,7 +1276,9 @@ impl RawSpec { /// lifecycle intent, the supported `service` type, or task blocks. Random TOML/JSON in the tree /// has none of these and is skipped. pub(crate) fn looks_like_spec(&self) -> bool { - self.identity.is_some() + self.id.is_some() + || self.address.is_some() + || self.identity.is_some() || self.job_type.as_deref() == Some("service") || self.retired.is_some() || self.desired_state.is_some() @@ -1241,6 +1310,18 @@ impl RawSpec { host: Option, path: PathBuf, ) -> anyhow::Result { + let declared_id = self + .id + .as_deref() + .map(AgentId::parse) + .transpose() + .map_err(|error| anyhow::anyhow!("agent '{identity}': {error}"))?; + let declared_address = self + .address + .as_deref() + .map(AgentAddress::parse) + .transpose() + .map_err(|error| anyhow::anyhow!("agent '{identity}': {error}"))?; validate_presentation("name", self.name.as_deref(), AGENT_NAME_MAX_CHARS)?; validate_presentation( "description", @@ -1319,9 +1400,13 @@ impl RawSpec { "agent '{identity}' declares both a compact launch and `pty \"agent\"`; choose one form" ); } - let bus_id = format!("{}.{}", host.as_deref().unwrap_or_default(), identity) - .trim_start_matches('.') - .to_string(); + // Runtime ownership keys off the immutable agent ID. For an unmigrated legacy declaration + // that is exactly the bytes migration freezes, which is why every existing task ID and + // socket path survives the transition unchanged. + let agent_id = match &declared_id { + Some(id) => id.as_str().to_owned(), + None => legacy_bus_identity(host.as_deref().unwrap_or_default(), &identity), + }; let mut tasks: Vec = Vec::new(); // Authored task names, captured before the maps are consumed: a derived stream companion must // not silently shadow an explicit sibling that already owns `stream-`. @@ -1344,8 +1429,8 @@ impl RawSpec { kind: TaskKind::Pty, derived: false, name: "agent".to_string(), - // An agent IS its pty: ding defaults its poke target to this same bus id. - id: Some(bus_id.clone()), + // An agent IS its pty: the canonical compact task's runtime ID is the agent ID. + id: Some(agent_id.clone()), command: self.command, argv: self.argv, cwd: None, @@ -1360,8 +1445,9 @@ impl RawSpec { kind: TaskKind::Exec, derived: true, name: "ding".to_string(), - id: Some(format!("{bus_id}.ding")), - command: Some(format!("st2 ding --identity {bus_id} --root $ST_ROOT")), + id: Some(format!("{agent_id}.ding")), + // DING receives an exact-ID selector, never a mutable address. + command: Some(format!("st2 ding --id {agent_id} --root $ST_ROOT")), argv: None, cwd: None, tags: BTreeMap::new(), @@ -1427,7 +1513,7 @@ impl RawSpec { kind: TaskKind::Exec, derived: true, name: task_name.clone(), - id: Some(format!("{bus_id}.{task_name}")), + id: Some(format!("{agent_id}.{task_name}")), command, argv, cwd: None, @@ -1444,7 +1530,9 @@ impl RawSpec { let resources = self.resource.lower()?; Ok(AgentSpec { + id: declared_id, identity, + address: declared_address, name: self.name, description: self.description, host, diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index 3cc8c235..e10109ea 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -383,7 +383,7 @@ agent "cos" { assert_eq!(ding.id.as_deref(), Some("Silber.cos.ding")); assert_eq!( ding.command.as_deref(), - Some("st2 ding --identity Silber.cos --root $ST_ROOT") + Some("st2 ding --id Silber.cos --root $ST_ROOT") ); assert_eq!( ding.env.get("ST_AGENT").map(String::as_str), @@ -1915,7 +1915,13 @@ fn path_supplies_identity_and_host_when_content_omits_them() { let s = &found.specs[0]; assert_eq!(s.identity, "st2-claude"); assert_eq!(s.host.as_deref(), Some("hetz")); - assert_eq!(s.bus_id("fallback"), "hetz.st2-claude"); + // An unmigrated legacy declaration: its implicit agent ID, its positional declaration key, + // and its bus address are the same bytes, which is exactly why migration moves no state. + assert_eq!(s.agent_id("fallback"), "hetz.st2-claude"); + assert_eq!(s.legacy_bus_identity("fallback"), "hetz.st2-claude"); + assert_eq!(s.bus_address("fallback"), "hetz.st2-claude"); + assert_eq!(s.id, None); + assert_eq!(s.address, None); } #[test] diff --git a/crates/st2-wire/src/message.rs b/crates/st2-wire/src/message.rs index 285d01c1..32f2911a 100644 --- a/crates/st2-wire/src/message.rs +++ b/crates/st2-wire/src/message.rs @@ -71,6 +71,32 @@ pub enum SentCoverage { Partial { since: u64, pending: usize }, } +/// Which kind of bus endpoint a durable message field names. +/// +/// This is what stops a reader from mistaking a canonical service-principal or external route for +/// an agent ID. An `agent` endpoint carries an immutable agent ID; every other kind carries that +/// endpoint's own canonical address. Absent on a version-1 row, which means `agent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EndpointKind { + /// A declared Agent. The endpoint value is its catalog-global immutable agent ID. + Agent, + /// A declared service principal. The endpoint value is its canonical `.` route. + Principal, + /// An eval-owned external requester mailbox. The endpoint value is its canonical route. + External, +} + +impl EndpointKind { + /// The meaning of an absent `fromKind`/`toKind`: a version-1 row only ever carried agents. + pub const fn or_version_1_default(kind: Option) -> Self { + match kind { + Some(kind) => kind, + None => Self::Agent, + } + } +} + /// One sender-owned message row. The sender is the selected index owner; `to` is the directional /// field that varies between rows. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -92,6 +118,15 @@ pub struct SentMessageRow { pub idempotency_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, + /// The recipient's bus address at publication time. + /// + /// Display only, and only meaningful for an `agent` endpoint: an address is mutable and a + /// released address is immediately reusable, so this is never a selector and never authority. + #[serde(rename = "toAddress", default, skip_serializing_if = "Option::is_none")] + pub to_address: Option, + /// Whether `to` is an agent ID or a canonical non-Agent address. Absent means `agent`. + #[serde(rename = "toKind", default, skip_serializing_if = "Option::is_none")] + pub to_kind: Option, } /// The stable `st2 message sent --json` envelope. @@ -258,6 +293,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 +302,59 @@ mod tests { assert!(json["messages"][0].get("from").is_none()); assert!(json["messages"][0].get("body").is_none()); } + + /// A row emitted before typed endpoints existed carries neither key, and must still be read as + /// an agent endpoint — never as an unknown or non-Agent one. + #[test] + fn a_version_1_sent_row_without_to_kind_decodes_as_an_agent_endpoint() { + let json = r#"{"filename":"1785000000000-abcdef.md","ts":1785000000000, + "to":"h.recipient","subject":null,"inReplyTo":null,"tags":[],"priority":null}"#; + let row = serde_json::from_str::(json).expect("a version-1 row parses"); + assert_eq!(row.to_kind, None); + assert_eq!(row.to_address, None); + assert_eq!( + EndpointKind::or_version_1_default(row.to_kind), + EndpointKind::Agent + ); + // Absent stays absent on re-emission: a reader must not invent a kind it was not told. + let reemitted = serde_json::to_value(&row).unwrap(); + assert!(reemitted.get("toKind").is_none()); + assert!(reemitted.get("toAddress").is_none()); + } + + /// A typed non-Agent endpoint keeps its canonical address in `to` and says so, so no reader + /// can mistake that address for an agent ID. + #[test] + fn a_typed_non_agent_sent_row_round_trips_its_kind_and_snapshot() { + let row = SentMessageRow { + filename: "1785000000000-abcdef.md".to_string(), + ts: 1_785_000_000_000, + to: "h.billing".to_string(), + subject: None, + in_reply_to: None, + tags: Vec::new(), + priority: None, + idempotency_key: None, + body: None, + to_address: None, + to_kind: Some(EndpointKind::Principal), + }; + let json = serde_json::to_value(&row).unwrap(); + assert_eq!(json["toKind"], "principal"); + assert_eq!(serde_json::from_value::(json).unwrap(), row); + + let agent = SentMessageRow { + to: "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".to_string(), + to_address: Some("dev4.fractal.chat".to_string()), + to_kind: Some(EndpointKind::Agent), + ..row + }; + let json = serde_json::to_value(&agent).unwrap(); + assert_eq!(json["toKind"], "agent"); + assert_eq!(json["toAddress"], "dev4.fractal.chat"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + agent + ); + } } 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..e2eb1d0f 100644 --- a/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md +++ b/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md @@ -42,9 +42,36 @@ 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: +The typed ID/address distinction is implemented in st2 and covered by model-free +tests. `agent-spec` owns `AgentId`, `AgentAddress`, UUIDv7 creation, frozen +legacy IDs, `Subject`, `AgentSelector`, and the fail-closed `AddressBook`; +`AgentSpec` carries explicit `id` and optional `address` and exposes exactly +three typed accessors — `agent_id` (ownership), `bus_address` (route), and +`legacy_bus_identity` (positional declaration key). An unmigrated declaration's +`agent_id` is by construction the value migration freezes, which is why no +declaration-anchored state, task ID, or socket path moves. +`AgentSpec::bus_id` is deleted, so every former call site had to choose a +meaning at compile time. + +Landed with it: `st2 catalog migrate-ids` as one additive, idempotent catalog +transaction with durable collision metadata and `legacy-supervisor-unresolved`; +ID-validating unarchive; `st2 agent address --id`; ID-only identity authoring +for `rename`, `describe`, and `desired-state`; disjoint address/exact-ID inputs +on every agent-selecting command; raw-ID `ST_AGENT`; `.` +default task IDs; PTY schema-2 owned metadata; ID-keyed graph, roster, task +inventory, supervisor edges, resource observation, stream ingress, and resync +ownership; typed message endpoints with collision-aware version-1 attribution; +and tolerant readers for message versions 1-2, harness-state 1-2, and +harness-context 1-2. + +What remains before this delta closes: the canonical Agent Spec and the +downstream evals/generators still predate `id`/`address`; no real catalog has +been migrated; and the target writers stay behind their switches +(`message::WRITE_MESSAGE_RECORD_VERSION_2` and the harness-record version-2 +writer gate) pending steps 2-5 below. Reader-first is therefore satisfied while +activation is not. + +The propagation surface this covered: - live and archived catalog validation, explicit-ID migration, unarchive, and ID-keyed supervisor references; @@ -105,9 +132,10 @@ shape named by decision 0015. Update these load-bearing invariant rows and their named tests in the same implementation: -- `Runner-owned task identity`, including its PTY actor-tag clause, from +- `Runner-owned task identity`, including its PTY subject-tag clause, from host-qualified `ST_AGENT` and schema-1 `agent.actor.path` to raw immutable ID - and schema-2 `agent.actor.id` plus `agent.actor.address`; + and schema-2 `agent.subject.id` plus `agent.subject.address`, leaving the + external-actor tag `agent.actor.id` untouched; - `Stable roster JSON` for appended immutable ID, nullable current bus address, presentation, and migrated supervisor projection; - `R23 fail-closed diagnostic inventory` for ID-keyed ownership and nullable diff --git a/docs/vrs/02-agent-spec/spec.md b/docs/vrs/02-agent-spec/spec.md index e68a216c..1d79c451 100644 --- a/docs/vrs/02-agent-spec/spec.md +++ b/docs/vrs/02-agent-spec/spec.md @@ -519,7 +519,8 @@ directly. Both paths preserve explicitly nullable name and description and grant no lifecycle authority. For a healthy managed PTY, patch the exact runtime task ID in place. Every PTY -receives the schema-2 owned actor-ID, current-bus-address, and +receives the schema-2 owned subject-ID (`agent.subject.id`), +current-bus-address (`agent.subject.address`), and optional-description tag snapshot. Only the primary task named `agent` carries the compatibility role and maps optional name to native display metadata. Clearing removes only the corresponding st2-owned value. Preserve unrelated diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 5f388036..4f450819 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -399,9 +399,11 @@ accepted. `.`, including a task named `agent`; an explicitly authored task ID remains authoritative. State and Resource paths remain anchored at the declaration parent under R02. For every healthy managed PTY, - st2 reconciles a versioned owned metadata snapshot containing immutable actor - ID, current bus address, and optional description through one exact task-ID - metadata patch. Only the canonical compact agent task whose task ID equals + st2 reconciles a versioned owned metadata snapshot containing immutable + subject ID, current bus address, and optional description through one exact + task-ID metadata patch. The external-actor tag `agent.actor.id` is outside + that snapshot and is never written or removed. + Only the canonical compact agent task whose task ID equals the agent ID maps optional name to native PTY display metadata; other PTYs preserve their task-specific display convention. Projection preserves unrelated tags, removes absent owned values, reports and retries diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index a690cee6..e80342b3 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -204,7 +204,7 @@ edit canonical KDL only. Each operation: original inode/version and bytes, atomically renames it through retained no-follow directory capabilities, then fsyncs the declaration directory. -`ST_AGENT` carries the catalog-global agent ID and remains an exact actor +`ST_AGENT` carries the catalog-global agent ID and remains an exact subject selector supplied by reconciliation, not the mutable bus address. Host placement comes from the declaration/runtime context rather than being encoded into `ST_AGENT`. `ST_AGENT` is a trusted-fleet convention rather than an @@ -237,8 +237,8 @@ preserves every legacy task ID and socket path because each frozen ID equals its former bus identity and the lowering rule itself does not change. Host placement is not separately concatenated into a default ID; host-looking bytes inside a legacy ID are opaque. Every PTY receives the exact owned tag snapshot -`agent.presentation.schema=2`, `agent.actor.id=`, -`agent.actor.address=`, and optional +`agent.presentation.schema=2`, `agent.subject.id=`, +`agent.subject.address=`, and optional `agent.presentation.description=`. Clearing a value removes only its owned tag. Unrelated tags remain unchanged. Only the canonical compact agent task whose task ID equals the agent ID carries the compatibility tag @@ -761,7 +761,7 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler exact match is accepted, and a conflicting authored value refuses the declaration before workspace materialization or runner access. The value is part of the persisted launch environment, so initial launch, supervised - replay, and manual PTY restart preserve the same actor ID. + replay, and manual PTY restart preserve the same subject ID. The canonical `agent` task treats a reconciler's ambient `NO_COLOR` as a launcher preference rather than agent policy. Unless the Agent Spec declares diff --git a/src/agent_author.rs b/src/agent_author.rs index 566d757b..9b667e4e 100644 --- a/src/agent_author.rs +++ b/src/agent_author.rs @@ -14,7 +14,7 @@ use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; use agent_spec::spec::{ - AGENT_DESCRIPTION_MAX_CHARS, AGENT_NAME_MAX_CHARS, Resource, StreamLaunch, + AGENT_DESCRIPTION_MAX_CHARS, AGENT_NAME_MAX_CHARS, Resource, StreamLaunch, address_book, validate_desired_state_reason, validate_presentation, }; use kdl::{KdlDocument, KdlNode}; @@ -22,6 +22,7 @@ use serde::Serialize; use crate::catalog_lock::CatalogLock; use crate::run::Runner as _; +use crate::{AddressBook, AgentAddress, AgentId, AgentSelector, Subject, UniquenessConflict}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct SourceVersion { @@ -72,6 +73,50 @@ impl PresentationField { } } +/// One directly authored single-positional-string declaration field. +/// +/// `id` is deliberately absent. An immutable agent ID is never edited in place: replacing one is +/// retire-old/add-new (R24, R30), so no span edit can express it. See [`refuse_agent_id_change`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DirectField { + Name, + Description, + Address, +} + +impl DirectField { + fn as_str(self) -> &'static str { + match self { + Self::Name => "name", + Self::Description => "description", + Self::Address => "address", + } + } + + fn duplicate_code(self) -> &'static str { + match self { + Self::Name | Self::Description => "duplicate-presentation-field", + Self::Address => "duplicate-address-field", + } + } + + fn malformed_code(self) -> &'static str { + match self { + Self::Name | Self::Description => "malformed-presentation-field", + Self::Address => "malformed-address-field", + } + } +} + +impl From for DirectField { + fn from(field: PresentationField) -> Self { + match field { + PresentationField::Name => Self::Name, + PresentationField::Description => Self::Description, + } + } +} + /// Whether a request changed declaration bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -90,6 +135,25 @@ pub struct PresentationReceipt { pub retired: bool, } +/// Stable machine-readable receipt from one address cutover. +/// +/// The cutover is immediate and total: the previous effective address stops resolving and becomes +/// claimable as soon as this catalog generation is visible. There is no alias, redirect, history, +/// or expiry to report, so the receipt describes only the new address book entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AddressReceipt { + pub result: AuthorOutcome, + /// The immutable agent ID the declaration was selected by. This operation never changes it. + pub id: String, + /// The effective address after the edit: the explicit value, or the restored `identity` + /// fallback when the request cleared it. + pub address: String, + /// The qualified human route, or `None` for a retired subject that holds no route. + pub bus_address: Option, + /// Whether the declaration now carries an explicit `address`. + pub explicit: bool, +} + /// Stable authored desired-state selector. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -193,11 +257,17 @@ impl fmt::Display for AuthorError { impl std::error::Error for AuthorError {} +/// One declaration proved to be exactly one authoring target. +/// +/// `id` is the immutable catalog-global agent ID — the value every receipt, authority edge, and +/// candidate recheck uses. `source_host` and `source_identity` are the positional declaration key +/// this target was written with, which is what locates the node inside its own source file. #[derive(Debug)] struct AgentTarget { - identity: String, + id: String, source_host: String, source_identity: String, + effective_address: String, declaration: PathBuf, retired: bool, } @@ -205,9 +275,9 @@ struct AgentTarget { /// Add an agent-owned stream, or prove that the identical declaration already exists. pub fn add_stream( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, launch: Option, ) -> Result { @@ -231,9 +301,9 @@ pub fn add_stream( /// Remove one agent-owned stream. An already absent stream is an idempotent success. pub fn remove_stream( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, ) -> Result { author_stream(catalog_root, selector, this_host, actor, name, None, true).map( @@ -247,9 +317,9 @@ pub fn remove_stream( fn author_stream( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, launch: Option<&StreamLaunch>, remove: bool, @@ -271,13 +341,11 @@ fn author_stream( ), )); } - let target = resolve_target(&found.specs, selector, this_host)?; - let actor = actor - .map(|actor| resolve_target(&found.specs, actor, this_host).map(|target| target.identity)) - .transpose()?; + let target = resolve_selected_target(&found.specs, selector, this_host)?; + let actor = resolve_actor(&found.specs, actor, this_host, "stream-not-authorized")?; authorize_actor( &found.specs, - &target.identity, + &target.id, this_host, actor.as_deref(), "stream-not-authorized", @@ -307,7 +375,7 @@ fn author_stream( let runtime_id = task .id .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name)); + .unwrap_or_else(|| format!("{}.{}", spec.agent_id(this_host), task.name)); let runner = crate::run::SystemRunner::new( catalog_root.to_path_buf(), crate::run::exec_state_dir(this_host), @@ -335,7 +403,7 @@ fn author_stream( &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.id, &target.source_host, &target.source_identity, name, @@ -343,7 +411,7 @@ fn author_stream( remove, || {}, )?; - Ok((result, target.identity)) + Ok((result, target.id)) } /// Declare one Resource binding, or update the binding that already carries `name`. @@ -353,9 +421,9 @@ fn author_stream( #[allow(clippy::too_many_arguments)] pub fn add_resource( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, uri: &str, reason: &str, @@ -377,9 +445,9 @@ pub fn add_resource( #[allow(clippy::too_many_arguments)] pub fn add_resource_with_selector( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, uri: &str, reason: &str, @@ -413,9 +481,9 @@ pub fn add_resource_with_selector( /// Remove one Resource binding. An already absent binding is an idempotent success. pub fn remove_resource( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, name: &str, ) -> Result { author_resource( @@ -438,9 +506,9 @@ pub fn remove_resource( /// agent, so neither request has an outcome that preserves the caller's intent. pub fn rename_resource( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, old: &str, new: &str, ) -> Result { @@ -487,9 +555,9 @@ struct ResourceExpectation { fn author_resource( catalog_root: &Path, - selector: &str, + selector: &AgentSelector, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, intent: ResourceIntent<'_>, ) -> Result<(AuthorOutcome, String), AuthorError> { let catalog_lock = CatalogLock::exclusive(catalog_root).map_err(|error| { @@ -509,13 +577,11 @@ fn author_resource( ), )); } - let target = resolve_target(&found.specs, selector, this_host)?; - let actor = actor - .map(|actor| resolve_target(&found.specs, actor, this_host).map(|target| target.identity)) - .transpose()?; + let target = resolve_selected_target(&found.specs, selector, this_host)?; + let actor = resolve_actor(&found.specs, actor, this_host, "resource-not-authorized")?; authorize_actor( &found.specs, - &target.identity, + &target.id, this_host, actor.as_deref(), "resource-not-authorized", @@ -526,21 +592,26 @@ fn author_resource( &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.id, &target.source_host, &target.source_identity, intent, || {}, )?; - Ok((result, target.identity)) + Ok((result, target.id)) } /// Author one whole-agent desired state without claiming runtime convergence. +/// +/// `id` is the explicit immutable agent ID (R28); an ordinary address never selects a lifecycle +/// edit. Leaving retirement re-enters the address namespace, so that transition must prove the +/// subject's effective address — explicit or the positional `identity` fallback — is still free on +/// its resolved host. pub fn set_desired_state( catalog_root: &Path, - selector: &str, + id: &str, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, state: DesiredStateValue, reason: Option<&str>, ) -> Result { @@ -580,21 +651,49 @@ pub fn set_desired_state( ), )); } - let target = resolve_target(&found.specs, selector, this_host)?; + let target = resolve_by_id(&found.specs, id, this_host)?; + let actor = resolve_actor(&found.specs, actor, this_host, "desired-state-not-authorized")?; authorize_actor( &found.specs, - &target.identity, + &target.id, this_host, - actor, + actor.as_deref(), "desired-state-not-authorized", )?; + if target.retired && state != DesiredStateValue::Retired { + // Leaving retirement re-enters the address namespace, and namespace occupancy is a global + // property: prove it against a strict snapshot, where an entry that could conceal a + // declaration is uncertainty rather than absence. + let strict = crate::discover_strict(catalog_root); + if let Some(error) = strict.errors.first() { + return Err(AuthorError::new( + "catalog-malformed", + format!( + "cannot prove effective-address uniqueness while {} is malformed: {}", + error.path.display(), + error.message + ), + )); + } + let restored = Subject { + id: parsed_agent_id(&target.id)?, + host: target.source_host.clone(), + effective_address: target.effective_address.clone(), + routable: true, + }; + refuse_target_conflicts( + &prospective_book(&strict.specs, this_host, restored)?, + &target.id, + "address-conflict", + )?; + } let result = edit_desired_state_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.id, &target.source_host, &target.source_identity, state, @@ -603,22 +702,24 @@ pub fn set_desired_state( )?; Ok(DesiredStateReceipt { result, - identity: target.identity, + identity: target.id, desired_state: state, reason: reason.map(str::to_owned), }) } -/// Set or clear one presentation field for one stable Agent Spec identity. +/// Set or clear one presentation field for one stable Agent Spec subject. /// -/// `actor` is the caller-supplied `ST_AGENT` identity. An absent actor is the explicit operator -/// path. Within the trusted-fleet model, the guardrail limits a catalog-managed caller to itself or -/// a descendant reached through declared supervisor edges; no presentation field expands it. +/// `id` is the explicit immutable agent ID (R25): presentation authoring never resolves a mutable +/// address. `actor` is the caller's typed selector — `$ST_AGENT` or `--id` as an exact ID, `--as` +/// as an ordinary address — resolved once in its own namespace. An absent actor is the explicit +/// operator path. Within the trusted-fleet model, the guardrail limits a catalog-managed caller to +/// itself or a descendant reached through supervisor edges; no presentation field expands it. pub fn set_presentation( catalog_root: &Path, - selector: &str, + id: &str, this_host: &str, - actor: Option<&str>, + actor: Option<&AgentSelector>, field: PresentationField, requested: Option<&str>, ) -> Result { @@ -639,12 +740,13 @@ pub fn set_presentation( ), )); } - let target = resolve_target(&found.specs, selector, this_host)?; + let target = resolve_by_id(&found.specs, id, this_host)?; + let actor = resolve_actor(&found.specs, actor, this_host, "presentation-not-authorized")?; authorize_actor( &found.specs, - &target.identity, + &target.id, this_host, - actor, + actor.as_deref(), "presentation-not-authorized", )?; let requested = requested @@ -660,7 +762,7 @@ pub fn set_presentation( &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.id, &target.source_host, &target.source_identity, field, @@ -669,59 +771,314 @@ pub fn set_presentation( )?; Ok(PresentationReceipt { result, - identity: target.identity, + identity: target.id, field, value: requested, retired: target.retired, }) } -fn resolve_target( +/// Cut one agent's mutable address over, or restore its positional `identity` fallback. +/// +/// The subject is selected by explicit immutable ID (R25): its ID, ID-keyed supervisor edges, task +/// IDs, declaration-anchored state, and any live runtime incarnation are untouched. The cutover is +/// atomic and total — the moment this catalog generation is visible the previous effective address +/// stops resolving and is claimable by another subject. st2 keeps no alias, redirect, history, or +/// expiry (F20, "Address cutover and unsupported redirects"). +/// +/// `requested` of `None` is `--clear`: it removes the explicit `address` and restores the +/// positional `identity` fallback, which is admitted only when that fallback is itself free on the +/// resolved host. +pub fn set_address( + catalog_root: &Path, + id: &str, + this_host: &str, + actor: Option<&AgentSelector>, + requested: Option<&AgentAddress>, +) -> Result { + let catalog_lock = CatalogLock::exclusive(catalog_root).map_err(|error| { + AuthorError::new( + "catalog-lock-failed", + format!("acquire catalog-authoring lock: {error:#}"), + ) + })?; + // Host-local address uniqueness is a global property, so an entry that could conceal a + // declaration is uncertainty rather than absence. + let found = crate::discover_strict(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_by_id(&found.specs, id, this_host)?; + let actor = resolve_actor(&found.specs, actor, this_host, "address-not-authorized")?; + authorize_actor( + &found.specs, + &target.id, + this_host, + actor.as_deref(), + "address-not-authorized", + )?; + let effective = match requested { + Some(address) => address.as_str().to_owned(), + None => target.source_identity.clone(), + }; + let conflict_code = if requested.is_some() { + "address-conflict" + } else { + "address-fallback-conflict" + }; + let candidate = Subject { + id: parsed_agent_id(&target.id)?, + host: target.source_host.clone(), + effective_address: effective.clone(), + routable: !target.retired, + }; + refuse_target_conflicts( + &prospective_book(&found.specs, this_host, candidate)?, + &target.id, + conflict_code, + )?; + let result = edit_address_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.id, + &target.source_host, + &target.source_identity, + requested, + &found.specs, + this_host, + conflict_code, + || {}, + )?; + Ok(AddressReceipt { + result, + bus_address: (!target.retired) + .then(|| crate::bus_address(&target.source_host, &effective)), + id: target.id, + address: effective, + explicit: requested.is_some(), + }) +} + +fn parsed_agent_id(id: &str) -> Result { + AgentId::parse(id).map_err(|error| AuthorError::new("invalid-agent-id", error.to_string())) +} + +/// The complete prospective catalog with exactly one subject swapped for its candidate shape. +/// +/// Uniqueness is a whole-catalog property, so it is proved against every other admitted subject in +/// the same discovery generation rather than against the edited declaration alone. +fn prospective_book( + specs: &[crate::AgentSpec], + this_host: &str, + replacement: Subject, +) -> Result { + let mut subjects = Vec::with_capacity(specs.len()); + let mut replaced = false; + for spec in specs { + let subject = spec.subject(this_host).map_err(|error| { + AuthorError::new( + "catalog-malformed", + format!( + "cannot admit declaration {} as an address-book subject: {error:#}", + spec.path.display() + ), + ) + })?; + if subject.id == replacement.id { + subjects.push(replacement.clone()); + replaced = true; + } else { + subjects.push(subject); + } + } + if !replaced { + subjects.push(replacement); + } + Ok(AddressBook::new(subjects)) +} + +/// Refuse only the conflicts this request is responsible for. +/// +/// A pre-existing violation elsewhere in the catalog is someone else's repair; blocking on it would +/// make an unrelated declaration uneditable. ID and address are separate namespaces, so equal bytes +/// across the two never appear here as a conflict. +fn refuse_target_conflicts( + book: &AddressBook, + id: &str, + address_code: &'static str, +) -> Result<(), AuthorError> { + for conflict in book.conflicts() { + match &conflict { + UniquenessConflict::DuplicateId { id: other, .. } if other == id => { + return Err(AuthorError::new("duplicate-agent-id", conflict.to_string())); + } + UniquenessConflict::DuplicateAddress { ids, .. } + if ids.iter().any(|other| other == id) => + { + return Err(AuthorError::new(address_code, conflict.to_string())); + } + _ => {} + } + } + Ok(()) +} + +/// Build the address book for one complete discovery snapshot. +/// +/// A declaration whose implicit or explicit ID cannot be admitted is a catalog error, not a +/// silently skipped subject: authoring may not prove exactness against a partial catalog. +fn catalog_book(specs: &[crate::AgentSpec], this_host: &str) -> Result { + address_book(specs, this_host).map_err(|error| { + AuthorError::new( + "catalog-malformed", + format!("cannot project the catalog address book: {error:#}"), + ) + }) +} + +/// Locate the declaration carrying one immutable agent ID. +/// +/// Exact ID lookup is its own typed namespace (R24): it never falls through to address lookup, so +/// an ID that names nothing refuses even when the same bytes happen to be a live address. +fn resolve_by_id( specs: &[crate::AgentSpec], - selector: &str, + id: &str, this_host: &str, ) -> Result { - let exact = specs + let matches = specs .iter() - .filter(|spec| spec.bus_id(this_host) == selector) + .filter(|spec| spec.agent_id(this_host) == id) .collect::>(); - let matches = if exact.is_empty() { - specs - .iter() - .filter(|spec| spec.identity == selector) - .collect::>() - } else { - exact - }; match matches.as_slice() { [] => Err(AuthorError::new( "target-not-found", - format!("no agent {selector:?} found in the selected catalog"), + format!("no agent with id {id:?} exists in the selected catalog"), )), - [spec] => Ok(AgentTarget { - identity: spec.bus_id(this_host), - source_host: spec.resolved_host(this_host).to_owned(), - source_identity: spec.identity.clone(), - declaration: spec.path.clone(), - retired: spec.desired_state.is_retired(), - }), - many => { - let mut candidates = many - .iter() - .map(|spec| format!("{} ({})", spec.bus_id(this_host), spec.path.display())) - .collect::>(); - candidates.sort(); - Err(AuthorError::new( - "target-ambiguous", - format!( - "agent selector {selector:?} is ambiguous: {}", - candidates.join(", ") - ), - )) + [spec] => Ok(target_of(spec, this_host)), + many => Err(AuthorError::new( + "target-ambiguous", + format!( + "agent id {id:?} is declared by more than one declaration: {}", + declaration_list(many) + ), + )), + } +} + +/// Locate the declaration one ordinary human reference routes to. +/// +/// This is the F20 bare-or-qualified address algorithm, never a hand-rolled positional comparison, +/// and it reaches only routable subjects. Content authoring (streams, Resource bindings) accepts +/// it; identity and lifecycle authoring does not (R25, R28). +fn resolve_by_address( + specs: &[crate::AgentSpec], + reference: &str, + this_host: &str, + pinned_host: Option<&str>, +) -> Result { + let book = catalog_book(specs, this_host)?; + let subject = book + .resolve_address(reference, pinned_host) + .map_err(|error| { + let code = match error { + crate::ResolveError::AmbiguousAddress { .. } => "target-ambiguous", + _ => "target-not-found", + }; + AuthorError::new(code, error.to_string()) + })?; + let id = subject.id.as_str(); + let matches = specs + .iter() + .filter(|spec| spec.agent_id(this_host) == id) + .collect::>(); + match matches.as_slice() { + [spec] => Ok(target_of(spec, this_host)), + [] => Err(AuthorError::new( + "target-not-found", + format!("address {reference:?} resolved to id {id:?}, which no declaration carries"), + )), + many => Err(AuthorError::new( + "target-ambiguous", + format!( + "address {reference:?} resolved to id {id:?}, declared more than once: {}", + declaration_list(many) + ), + )), + } +} + +/// Resolve one caller-supplied target selector in the namespace its own form declares. +/// +/// Content authoring (streams, Resource bindings) accepts either an exact ID — `--id`, or the +/// acting subject defaulted from `$ST_AGENT` — or an ordinary human address. The two never fall +/// back into each other, so defaulting the target to the caller keeps whichever namespace the +/// caller actually arrived in. +fn resolve_selected_target( + specs: &[crate::AgentSpec], + selector: &AgentSelector, + this_host: &str, +) -> Result { + match selector { + AgentSelector::Id(id) => resolve_by_id(specs, id, this_host), + AgentSelector::Address { reference, host } => { + resolve_by_address(specs, reference, this_host, host.as_deref()) } } } +fn target_of(spec: &crate::AgentSpec, this_host: &str) -> AgentTarget { + AgentTarget { + id: spec.agent_id(this_host), + source_host: spec.resolved_host(this_host).to_owned(), + source_identity: spec.identity.clone(), + effective_address: spec.effective_address().to_owned(), + declaration: spec.path.clone(), + retired: spec.desired_state.is_retired(), + } +} + +fn declaration_list(specs: &[&crate::AgentSpec]) -> String { + let mut paths = specs + .iter() + .map(|spec| spec.path.display().to_string()) + .collect::>(); + paths.sort(); + paths.join(", ") +} + +/// Refuse to edit a declaration's immutable agent ID. +/// +/// R24 and R30 make ID replacement retire-old/add-new: no in-place edit preserves the subject, so +/// st2 exposes no id-authoring path. This classified refusal exists so a caller that asks for one +/// gets the same machine-readable shape as every other authoring refusal instead of a surface that +/// looks editable. +pub fn refuse_agent_id_change( + id: &str, + requested: &str, +) -> Result { + Err(AuthorError::new( + "immutable-agent-id", + format!( + "agent id {id:?} is immutable and cannot become {requested:?}: \ + retire this subject and add a new declaration with its own id" + ), + )) +} + +/// Apply the trusted-fleet self/descendant guardrail over supervisor edges. +/// +/// `actor` is an already-resolved immutable agent ID: every human reference was resolved once, in +/// its own namespace, before this call. An actor ID is never compared against an address. fn authorize_actor( specs: &[crate::AgentSpec], target: &str, @@ -735,34 +1092,26 @@ fn authorize_actor( if actor == target { return Ok(()); } - let by_identity = specs + // The book itself is unused here — the walk below is keyed by ID — but building it is the + // load-bearing admission proof that this snapshot's identity namespaces are unambiguous. + let _book = catalog_book(specs, this_host)?; + let by_id = specs .iter() - .map(|spec| (spec.bus_id(this_host), spec)) + .map(|spec| (spec.agent_id(this_host), spec)) .collect::>(); let mut current = target.to_owned(); let mut visited = BTreeSet::new(); while visited.insert(current.clone()) { - let Some(spec) = by_identity.get(¤t) else { + let Some(spec) = by_id.get(¤t) else { break; }; - let Some(supervisor) = spec.supervisor.as_deref() else { + let Some(parent) = supervisor_id(specs, &by_id, spec, this_host) else { break; }; - if supervisor == actor { - return Ok(()); - } - let same_host = format!("{}.{}", spec.resolved_host(this_host), supervisor); - let qualified = if by_identity.contains_key(supervisor) { - supervisor.to_owned() - } else if by_identity.contains_key(&same_host) { - same_host - } else { - supervisor.to_owned() - }; - if qualified == actor { + if parent == actor { return Ok(()); } - current = qualified; + current = parent; } Err(AuthorError::new( refusal_code, @@ -770,10 +1119,58 @@ fn authorize_actor( )) } +/// The immutable ID one declared `supervisor` value names, resolved in exactly one namespace. +/// +/// The edge's namespace is decided by the child that declares it — once, in +/// [`crate::supervisor_chain::supervisor_edge`] — never by which lookup happens to answer. +/// Trying both namespaces would let an unrelated subject whose ID is byte-equal to another +/// subject's address capture the edge, which decision 0015 forbids. An edge that names no subject, +/// or one this snapshot cannot attribute to a single declaration, ends the walk rather than +/// guessing. +fn supervisor_id( + specs: &[crate::AgentSpec], + by_id: &BTreeMap, + child: &crate::AgentSpec, + this_host: &str, +) -> Option { + let parent = crate::supervisor_chain::resolve_supervisor_spec(specs, child, this_host)?; + let id = parent.agent_id(this_host); + by_id.contains_key(&id).then_some(id) +} + +/// Resolve one caller-supplied actor to the immutable agent ID it names, in its own namespace. +/// +/// `AgentSelector::Id` is `$ST_AGENT` or `--id`: an exact ID that never reaches the address +/// algorithm. `AgentSelector::Address` is a human `--as` reference: an ordinary address that never +/// reaches the ID namespace. An actor that names no subject cannot hold catalog authority, so it +/// refuses with the operation's own refusal code instead of silently walking to a refusal. +fn resolve_actor( + specs: &[crate::AgentSpec], + actor: Option<&AgentSelector>, + this_host: &str, + refusal_code: &'static str, +) -> Result, AuthorError> { + let Some(actor) = actor else { + return Ok(None); + }; + let book = catalog_book(specs, this_host)?; + book.resolve(actor) + .map(|subject| Some(subject.id.as_str().to_owned())) + .map_err(|error| { + AuthorError::new( + refusal_code, + format!( + "acting agent {:?} does not name a catalog subject: {error}", + actor.as_input() + ), + ) + }) +} + #[cfg(test)] fn edit_declaration_for_test( path: &Path, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, field: PresentationField, @@ -792,7 +1189,7 @@ fn edit_declaration_for_test( path.parent().expect("test catalog has a parent"), &control, path, - expected_identity, + expected_id, expected_host, expected_agent, field, @@ -829,13 +1226,44 @@ fn edit_desired_state_for_test( ) } +/// Drive one address edit with a commit-window hook. +/// +/// The stale-writer recheck happens inside the catalog lock, so a competing write can only be +/// scheduled from inside the transaction. That window is not reachable from an integration test. +#[cfg(test)] +fn edit_address_for_test( + path: &Path, + requested: Option<&AgentAddress>, + before_commit: impl FnOnce(), +) -> Result { + let catalog = path.parent().expect("test catalog has a parent"); + let control = catalog.join(crate::catalog_lock::CONTROL_DIR); + fs::create_dir_all(&control).expect("create test catalog control directory"); + let catalog_lock = CatalogLock::exclusive(catalog).expect("acquire test catalog lock"); + let found = crate::discover(catalog); + edit_address_declaration( + &catalog_lock, + catalog, + &control, + path, + "h.worker", + "h", + "worker", + requested, + &found.specs, + "h", + "address-conflict", + before_commit, + ) +} + #[allow(clippy::too_many_arguments)] fn edit_stream_declaration( catalog_lock: &CatalogLock, catalog: &Path, control: &Path, path: &Path, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, name: &str, @@ -883,12 +1311,12 @@ fn edit_stream_declaration( format!("parsing declaration {}: {error}", path.display()), ) })?; - let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; if is_nix_managed(target) { return Err(AuthorError::new( "nix-managed-declaration", format!( - "agent {expected_identity:?} is Nix-owned; edit its Nix source instead of {}", + "agent {expected_id:?} is Nix-owned; edit its Nix source instead of {}", path.display() ), )); @@ -901,7 +1329,7 @@ fn edit_stream_declaration( catalog, path, &replacement, - expected_identity, + expected_id, expected_host, expected_agent, name, @@ -1016,7 +1444,7 @@ fn verify_stream_candidate( catalog: &Path, path: &Path, candidate: &str, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, name: &str, @@ -1053,7 +1481,7 @@ fn verify_stream_candidate( let spec = specs .iter() .find(|spec| { - spec.identity == expected_agent && spec.bus_id(expected_host) == expected_identity + spec.identity == expected_agent && spec.agent_id(expected_host) == expected_id }) .ok_or_else(|| { AuthorError::new( @@ -1080,7 +1508,7 @@ fn edit_resource_declaration( catalog: &Path, control: &Path, path: &Path, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, intent: ResourceIntent<'_>, @@ -1126,12 +1554,12 @@ fn edit_resource_declaration( format!("parsing declaration {}: {error}", path.display()), ) })?; - let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; if is_nix_managed(target) { return Err(AuthorError::new( "nix-managed-declaration", format!( - "agent {expected_identity:?} is Nix-owned; edit its Nix source instead of {}", + "agent {expected_id:?} is Nix-owned; edit its Nix source instead of {}", path.display() ), )); @@ -1143,7 +1571,7 @@ fn edit_resource_declaration( catalog, path, &replacement, - expected_identity, + expected_id, expected_host, expected_agent, &expectation, @@ -1397,7 +1825,7 @@ fn verify_resource_candidate( catalog: &Path, path: &Path, candidate: &str, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, expectation: &ResourceExpectation, @@ -1432,7 +1860,7 @@ fn verify_resource_candidate( let spec = specs .iter() .find(|spec| { - spec.identity == expected_agent && spec.bus_id(expected_host) == expected_identity + spec.identity == expected_agent && spec.agent_id(expected_host) == expected_id }) .ok_or_else(|| { AuthorError::new( @@ -1467,7 +1895,7 @@ fn edit_declaration( catalog: &Path, control: &Path, path: &Path, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, field: PresentationField, @@ -1514,25 +1942,25 @@ fn edit_declaration( format!("parsing declaration {}: {error}", path.display()), ) })?; - let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; if is_nix_managed(target) { return Err(AuthorError::new( "nix-managed-declaration", format!( - "agent {expected_identity:?} is Nix-owned; edit its Nix source instead of {}", + "agent {expected_id:?} is Nix-owned; edit its Nix source instead of {}", path.display() ), )); } - let Some(replacement) = presentation_edit(text, target, field, requested)? else { + let Some(replacement) = field_edit(text, target, field.into(), requested)? else { return Ok(AuthorOutcome::Unchanged); }; verify_candidate( &replacement, - expected_identity, + expected_id, expected_host, expected_agent, - field, + field.into(), requested, )?; atomic_replace_checked( @@ -1549,13 +1977,195 @@ fn edit_declaration( Ok(AuthorOutcome::Changed) } +/// Apply one span-bounded `address` edit under the transaction contract shared with F17. +/// +/// The candidate is reparsed and proved as part of the *complete* prospective catalog, not just as +/// one file: host-local effective-address uniqueness is a whole-catalog property and an identity +/// fallback can collide with another declaration's explicit address. +#[allow(clippy::too_many_arguments)] +fn edit_address_declaration( + catalog_lock: &CatalogLock, + catalog: &Path, + control: &Path, + path: &Path, + expected_id: &str, + expected_host: &str, + expected_agent: &str, + requested: Option<&AgentAddress>, + snapshot: &[crate::AgentSpec], + this_host: &str, + conflict_code: &'static str, + before_commit: impl FnOnce(), +) -> Result { + if path.extension().and_then(|value| value.to_str()) != Some("kdl") { + return Err(AuthorError::new( + "unsupported-declaration-format", + format!( + "address authoring requires canonical KDL, found {}", + path.display() + ), + )); + } + let metadata = fs::symlink_metadata(path).map_err(|error| { + AuthorError::new( + "declaration-read-failed", + format!("reading declaration {}: {error}", path.display()), + ) + })?; + if !metadata.file_type().is_file() { + return Err(AuthorError::new( + "unsafe-declaration-path", + format!("refusing non-regular declaration path {}", path.display()), + )); + } + let original = fs::read(path).map_err(|error| { + AuthorError::new( + "declaration-read-failed", + format!("reading declaration {}: {error}", path.display()), + ) + })?; + let original_version = SourceVersion::from_metadata(&metadata); + let text = std::str::from_utf8(&original).map_err(|error| { + AuthorError::new( + "malformed-declaration", + format!("declaration {} is not UTF-8: {error}", path.display()), + ) + })?; + let document = KdlDocument::parse(text).map_err(|error| { + AuthorError::new( + "malformed-declaration", + format!("parsing declaration {}: {error}", path.display()), + ) + })?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; + if is_nix_managed(target) { + return Err(AuthorError::new( + "nix-managed-declaration", + format!( + "agent {expected_id:?} is Nix-owned; edit its Nix source instead of {}", + path.display() + ), + )); + } + let Some(replacement) = field_edit( + text, + target, + DirectField::Address, + requested.map(AgentAddress::as_str), + )? + else { + return Ok(AuthorOutcome::Unchanged); + }; + verify_candidate( + &replacement, + expected_id, + expected_host, + expected_agent, + DirectField::Address, + requested.map(AgentAddress::as_str), + )?; + verify_address_candidate( + catalog, + path, + &replacement, + expected_id, + expected_host, + requested, + snapshot, + this_host, + conflict_code, + )?; + atomic_replace_checked( + catalog_lock, + catalog, + control, + path, + &original, + original_version, + replacement.as_bytes(), + metadata.permissions().mode() & 0o7777, + before_commit, + )?; + Ok(AuthorOutcome::Changed) +} + +/// Reparse the candidate declaration and prove the complete prospective catalog it produces. +/// +/// Three things must hold: the candidate still parses as a canonical Agent Spec, the subject's +/// immutable ID is byte-identical (an address edit that moved an ID would be an unsupported +/// identity replacement, R30), and no admitted uniqueness rule this subject participates in is +/// violated. +#[allow(clippy::too_many_arguments)] +fn verify_address_candidate( + catalog: &Path, + path: &Path, + candidate: &str, + expected_id: &str, + expected_host: &str, + requested: Option<&AgentAddress>, + snapshot: &[crate::AgentSpec], + this_host: &str, + conflict_code: &'static str, +) -> Result<(), AuthorError> { + let temporary = tempfile::tempdir() + .map_err(|error| AuthorError::new("unsafe-source-edit", error.to_string()))?; + let relative = path.strip_prefix(catalog).map_err(|_| { + AuthorError::new( + "unsafe-declaration-path", + format!( + "declaration {} is outside catalog {}", + path.display(), + catalog.display() + ), + ) + })?; + let candidate_path = temporary.path().join(relative); + fs::create_dir_all( + candidate_path + .parent() + .expect("candidate declaration has a parent"), + ) + .and_then(|()| fs::write(&candidate_path, candidate)) + .map_err(|error| { + AuthorError::new( + "unsafe-source-edit", + format!("stage address validation: {error}"), + ) + })?; + let (specs, _) = agent_spec::discover_file(temporary.path(), &candidate_path) + .map_err(|error| AuthorError::new("invalid-address", error.to_string()))?; + let spec = specs + .iter() + .find(|spec| spec.agent_id(expected_host) == expected_id) + .ok_or_else(|| { + AuthorError::new( + "immutable-agent-id", + format!("address candidate no longer carries agent id {expected_id:?}"), + ) + })?; + if spec.address.as_ref().map(AgentAddress::as_str) != requested.map(AgentAddress::as_str) { + return Err(AuthorError::new( + "unsafe-source-edit", + "address candidate did not read back as the authored intent", + )); + } + let subject = spec.subject(expected_host).map_err(|error| { + AuthorError::new("invalid-address", format!("admit address candidate: {error:#}")) + })?; + refuse_target_conflicts( + &prospective_book(snapshot, this_host, subject)?, + expected_id, + conflict_code, + ) +} + #[allow(clippy::too_many_arguments)] fn edit_desired_state_declaration( catalog_lock: &CatalogLock, catalog: &Path, control: &Path, path: &Path, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, state: DesiredStateValue, @@ -1602,12 +2212,12 @@ fn edit_desired_state_declaration( format!("parsing declaration {}: {error}", path.display()), ) })?; - let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; if is_nix_managed(target) { return Err(AuthorError::new( "nix-managed-declaration", format!( - "agent {expected_identity:?} is Nix-owned; edit its Nix source instead of {}", + "agent {expected_id:?} is Nix-owned; edit its Nix source instead of {}", path.display() ), )); @@ -1617,7 +2227,7 @@ fn edit_desired_state_declaration( }; verify_desired_state_candidate( &replacement, - expected_identity, + expected_id, expected_host, expected_agent, state, @@ -1684,7 +2294,7 @@ fn desired_state_edit( fn verify_desired_state_candidate( candidate: &str, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, state: DesiredStateValue, @@ -1696,7 +2306,7 @@ fn verify_desired_state_candidate( format!("desired-state edit did not produce valid KDL: {error}"), ) })?; - let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; let lifecycle = target .children() .into_iter() @@ -1722,7 +2332,7 @@ fn verify_desired_state_candidate( fn exact_agent_node<'a>( document: &'a KdlDocument, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, ) -> Result<&'a KdlNode, AuthorError> { @@ -1755,11 +2365,11 @@ fn exact_agent_node<'a>( [target] => Ok(*target), [] => Err(AuthorError::new( "target-changed", - format!("declaration no longer contains agent {expected_identity:?}"), + format!("declaration no longer contains agent {expected_id:?}"), )), _ => Err(AuthorError::new( "target-ambiguous", - format!("declaration contains more than one agent {expected_identity:?}"), + format!("declaration contains more than one agent {expected_id:?}"), )), } } @@ -1806,10 +2416,14 @@ fn is_nix_managed(node: &KdlNode) -> bool { }) } -fn presentation_edit( +/// Set, replace, or remove exactly one direct single-string field on one agent node. +/// +/// `Ok(None)` is the proven no-op. Every other outcome rewrites exactly that field's span, so every +/// unrelated byte of the declaration — including unknown fields and comments — survives. +fn field_edit( text: &str, target: &KdlNode, - field: PresentationField, + field: DirectField, requested: Option<&str>, ) -> Result, AuthorError> { let fields = target @@ -1828,17 +2442,17 @@ fn presentation_edit( None => remove_field(text, node).map(Some), }, _ => Err(AuthorError::new( - "duplicate-presentation-field", + field.duplicate_code(), format!("target declares `{}` more than once", field.as_str()), )), } } -fn parse_field_value(node: &KdlNode, field: PresentationField) -> Result<&str, AuthorError> { +fn parse_field_value(node: &KdlNode, field: DirectField) -> Result<&str, AuthorError> { if node.children().is_some() || node.entries().len() != 1 || node.entries()[0].name().is_some() { return Err(AuthorError::new( - "malformed-presentation-field", + field.malformed_code(), format!( "`{}` must contain exactly one positional string", field.as_str() @@ -1849,7 +2463,7 @@ fn parse_field_value(node: &KdlNode, field: PresentationField) -> Result<&str, A .and_then(|value| value.as_string()) .ok_or_else(|| { AuthorError::new( - "malformed-presentation-field", + field.malformed_code(), format!("`{}` must contain a string", field.as_str()), ) }) @@ -1859,7 +2473,7 @@ fn quoted(value: &str) -> Result { serde_json::to_string(value).map_err(|error| { AuthorError::new( "unsafe-source-edit", - format!("encode presentation string for canonical KDL: {error}"), + format!("encode declaration string for canonical KDL: {error}"), ) }) } @@ -1867,7 +2481,7 @@ fn quoted(value: &str) -> Result { fn replace_field( text: &str, node: &KdlNode, - field: PresentationField, + field: DirectField, value: &str, ) -> Result, AuthorError> { if parse_field_value(node, field)? == value { @@ -1879,7 +2493,7 @@ fn replace_field( text.get(range.clone()).ok_or_else(|| { AuthorError::new( "malformed-declaration", - "presentation value span falls outside the declaration", + "declaration field value span falls outside the declaration", ) })?; let mut replacement = text.to_owned(); @@ -1890,7 +2504,7 @@ fn replace_field( fn insert_field( text: &str, target: &KdlNode, - field: PresentationField, + field: DirectField, value: &str, ) -> Result { insert_node( @@ -2050,19 +2664,19 @@ fn line_indent(text: &str, offset: usize) -> Option { fn verify_candidate( candidate: &str, - expected_identity: &str, + expected_id: &str, expected_host: &str, expected_agent: &str, - field: PresentationField, + field: DirectField, expected: 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)?; + let target = exact_agent_node(&document, expected_id, expected_host, expected_agent)?; let fields = target .children() .into_iter() @@ -2076,7 +2690,7 @@ fn verify_candidate( return Err(AuthorError::new( "unsafe-source-edit", format!( - "presentation edit produced duplicate `{}` fields", + "field edit produced duplicate `{}` fields", field.as_str() ), )); @@ -2086,7 +2700,7 @@ fn verify_candidate( return Err(AuthorError::new( "unsafe-source-edit", format!( - "presentation edit did not produce the requested `{}`", + "field edit did not produce the requested `{}`", field.as_str() ), )); @@ -2257,7 +2871,7 @@ mod tests { assert_eq!( set_presentation( root, - "worker", + "h.worker", "h", None, PresentationField::Name, @@ -2270,7 +2884,7 @@ mod tests { assert_eq!( set_presentation( root, - "worker", + "h.worker", "h", None, PresentationField::Name, @@ -2281,7 +2895,7 @@ mod tests { AuthorOutcome::Changed ); assert_eq!( - set_presentation(root, "worker", "h", None, PresentationField::Name, None) + set_presentation(root, "h.worker", "h", None, PresentationField::Name, None) .unwrap() .result, AuthorOutcome::Changed @@ -2364,7 +2978,7 @@ mod tests { root, "h.child", "h", - Some("h.child"), + Some(&AgentSelector::id("h.child")), PresentationField::Name, Some("self"), ) @@ -2373,7 +2987,7 @@ mod tests { root, "h.child", "h", - Some("h.root"), + Some(&AgentSelector::id("h.root")), PresentationField::Description, Some("supervised"), ) @@ -2383,7 +2997,7 @@ mod tests { root, "h.sibling", "h", - Some("h.child"), + Some(&AgentSelector::id("h.child")), PresentationField::Name, Some("no") ) @@ -2396,7 +3010,7 @@ mod tests { root, "h.nix", "h", - Some("h.root"), + Some(&AgentSelector::id("h.root")), PresentationField::Name, Some("no") ) @@ -2471,6 +3085,202 @@ mod tests { assert_eq!(fs::read_to_string(path).unwrap(), changed); } + #[test] + fn address_authoring_refuses_a_stale_source_before_replacing_it() { + let temporary = tempfile::tempdir().unwrap(); + let path = write( + temporary.path(), + "agent.kdl", + &declaration("worker", "h", None, "catalog"), + ); + let changed = declaration("worker", "h", None, "external"); + let error = edit_address_for_test(&path, Some(&address("build.owner")), || { + fs::write(&path, &changed).unwrap() + }) + .unwrap_err(); + assert_eq!(error.code(), "source-changed"); + assert_eq!( + fs::read_to_string(path).unwrap(), + changed, + "the competing writer's bytes survive; the refused edit wrote nothing" + ); + } + + + const PARENT_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320e01"; + const CHILD_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320e02"; + + /// One agent declaration with arbitrary extra child nodes. + fn agent_with(identity: &str, host: &str, extra: &str) -> String { + format!( + "// keep this comment\nagent {identity:?} {{\n host {host:?}\n{extra} command \"sleep 60\"\n}}\n" + ) + } + + /// A supervisor edge resolves in exactly one namespace, chosen by the child that declares it. + /// + /// The trap: an unrelated subject whose immutable ID is byte-equal to another subject's bus + /// address. Trying IDs first and falling back to addresses let that subject capture an + /// unmigrated child's legacy edge and gain descendant authority over a declaration it does not + /// supervise. + #[test] + fn a_supervisor_edge_never_lets_a_byte_equal_id_capture_a_legacy_address_edge() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + // `impostor` holds the ID "dev3.worker"; `lead` holds the bus address "dev3.worker". + write( + root, + "dev3/impostor/agent.kdl", + &agent_with("impostor", "dev3", " id \"dev3.worker\"\n"), + ); + write( + root, + "dev3/lead/agent.kdl", + &agent_with("worker", "dev3", &format!(" id {PARENT_ID:?}\n")), + ); + write( + root, + "dev3/legacy/agent.kdl", + &agent_with("legacy", "dev3", " supervisor \"dev3.worker\"\n"), + ); + write( + root, + "dev3/migrated/agent.kdl", + &agent_with( + "migrated", + "dev3", + &format!(" id {CHILD_ID:?}\n supervisor \"dev3.worker\"\n"), + ), + ); + + // The unmigrated child's edge is a legacy positional reference: it names `lead`, never the + // subject that merely carries those bytes as an ID. + let captured = set_address( + root, + "dev3.legacy", + "dev3", + Some(&AgentSelector::id("dev3.worker")), + Some(&address("legacy.edge")), + ) + .unwrap_err(); + assert_eq!( + captured.code(), + "address-not-authorized", + "an id byte-equal to a bus address must not capture a legacy edge" + ); + set_address( + root, + "dev3.legacy", + "dev3", + Some(&AgentSelector::id(PARENT_ID)), + Some(&address("legacy.edge")), + ) + .unwrap(); + + // The migrated child's edge is an ID: it names `impostor`, and never resolves by address. + set_address( + root, + CHILD_ID, + "dev3", + Some(&AgentSelector::id("dev3.worker")), + Some(&address("migrated.edge")), + ) + .unwrap(); + let crossed = set_address( + root, + CHILD_ID, + "dev3", + Some(&AgentSelector::id(PARENT_ID)), + Some(&address("migrated.edge2")), + ) + .unwrap_err(); + assert_eq!( + crossed.code(), + "address-not-authorized", + "a migrated edge is an id edge; the address namespace never answers it" + ); + } + + /// An actor is resolved once, in the namespace its own selector declares. + /// + /// `$ST_AGENT` and `--id` are exact IDs; `--as` is an ordinary address. Neither input is + /// accepted in the other namespace, so a UUID never reaches the address algorithm and a human + /// address is never compared against an ID. + #[test] + fn an_actor_selector_resolves_in_its_own_namespace_only() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/lead/agent.kdl", + &agent_with( + "lead", + "h", + &format!(" id {PARENT_ID:?}\n address \"parent.edge\"\n"), + ), + ); + write( + root, + "h/child/agent.kdl", + &agent_with( + "child", + "h", + &format!(" id {CHILD_ID:?}\n supervisor {PARENT_ID:?}\n"), + ), + ); + + // An ambient UUID `ST_AGENT` authorizes itself. + set_address( + root, + PARENT_ID, + "h", + Some(&AgentSelector::id(PARENT_ID)), + Some(&address("parent.edge2")), + ) + .unwrap(); + + // A human `--as` address authorizes its subject over a descendant. + set_address( + root, + CHILD_ID, + "h", + Some(&AgentSelector::address("parent.edge2")), + Some(&address("child.edge")), + ) + .unwrap(); + + // Neither input crosses into the other namespace. + for (actor, why) in [ + (AgentSelector::id("parent.edge2"), "an address is not an id"), + ( + AgentSelector::address(PARENT_ID), + "an id is not an address", + ), + ] { + let error = set_address( + root, + CHILD_ID, + "h", + Some(&actor), + Some(&address("child.edge2")), + ) + .unwrap_err(); + assert_eq!(error.code(), "address-not-authorized", "{why}"); + assert!(error.to_string().contains("does not name"), "{why}: {error}"); + } + + let found = crate::discover(root); + let book = address_book(&found.specs, "h").unwrap(); + assert_eq!( + book.resolve_address("child.edge", None).unwrap().id.as_str(), + CHILD_ID + ); + } + + fn address(value: &str) -> AgentAddress { + AgentAddress::parse(value).expect("test address") + } + #[test] fn desired_state_authoring_prefers_an_explicit_target_over_an_anonymous_sibling() { let temporary = tempfile::tempdir().unwrap(); @@ -2507,7 +3317,7 @@ mod tests { let original = fs::read_to_string(&path).unwrap(); assert_eq!( - add_stream(root, "h.worker", "h", Some("h.worker"), "webhook", None) + add_stream(root, &AgentSelector::address("h.worker"), "h", Some(&AgentSelector::id("h.worker")), "webhook", None) .unwrap() .result, AuthorOutcome::Changed @@ -2515,9 +3325,9 @@ mod tests { assert_eq!( add_stream( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", - Some("h.worker"), + Some(&AgentSelector::id("h.worker")), "github-ci", Some(StreamLaunch::Command("gh watch --repo st2".to_owned())), ) @@ -2528,9 +3338,9 @@ mod tests { assert_eq!( add_stream( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", - Some("h.worker"), + Some(&AgentSelector::id("h.worker")), "tick", Some(StreamLaunch::Argv(vec![ "tick-source".to_owned(), @@ -2542,7 +3352,7 @@ mod tests { AuthorOutcome::Changed ); assert_eq!( - add_stream(root, "h.worker", "h", Some("h.worker"), "webhook", None) + add_stream(root, &AgentSelector::address("h.worker"), "h", Some(&AgentSelector::id("h.worker")), "webhook", None) .unwrap() .result, AuthorOutcome::Unchanged @@ -2553,13 +3363,13 @@ mod tests { assert!(authored.contains("stream \"tick\" { argv \"tick-source\" \"--daily\" }")); assert_eq!( - remove_stream(root, "h.worker", "h", None, "webhook") + remove_stream(root, &AgentSelector::address("h.worker"), "h", None, "webhook") .unwrap() .result, AuthorOutcome::Changed ); assert_eq!( - remove_stream(root, "h.worker", "h", None, "webhook") + remove_stream(root, &AgentSelector::address("h.worker"), "h", None, "webhook") .unwrap() .result, AuthorOutcome::Unchanged @@ -2584,9 +3394,9 @@ mod tests { assert_eq!( add_stream( root, - "beta.worker", + &AgentSelector::address("beta.worker"), "beta", - Some("beta.worker"), + Some(&AgentSelector::id("beta.worker")), "webhook", None, ) @@ -2595,7 +3405,7 @@ mod tests { AuthorOutcome::Changed ); assert_eq!( - remove_stream(root, "beta.worker", "beta", Some("beta.worker"), "existing",) + remove_stream(root, &AgentSelector::address("beta.worker"), "beta", Some(&AgentSelector::id("beta.worker")), "existing",) .unwrap() .result, AuthorOutcome::Changed @@ -2635,21 +3445,21 @@ mod tests { &declaration("nix", "h", Some("root"), "nix"), ); - add_stream(root, "h.child", "h", Some("h.root"), "events", None).unwrap(); + add_stream(root, &AgentSelector::address("h.child"), "h", Some(&AgentSelector::id("h.root")), "events", None).unwrap(); assert_eq!( - add_stream(root, "h.sibling", "h", Some("h.child"), "events", None) + add_stream(root, &AgentSelector::address("h.sibling"), "h", Some(&AgentSelector::id("h.child")), "events", None) .unwrap_err() .code(), "stream-not-authorized" ); assert_eq!( - add_stream(root, "h.nix", "h", Some("h.root"), "events", None) + add_stream(root, &AgentSelector::address("h.nix"), "h", Some(&AgentSelector::id("h.root")), "events", None) .unwrap_err() .code(), "nix-managed-declaration" ); assert_eq!( - add_stream(root, "h.child", "h", None, "Bad Name", None) + add_stream(root, &AgentSelector::address("h.child"), "h", None, "Bad Name", None) .unwrap_err() .code(), "invalid-stream" @@ -2657,7 +3467,7 @@ mod tests { assert_eq!( add_stream( root, - "h.child", + &AgentSelector::address("h.child"), "h", None, "empty-argv", @@ -2689,7 +3499,7 @@ mod tests { symlink(&concealed, root.join("concealed-link")).unwrap(); let original = fs::read(&declaration_path).unwrap(); - let error = add_stream(&root, "h.worker", "h", None, "events", None).unwrap_err(); + let error = add_stream(&root, &AgentSelector::address("h.worker"), "h", None, "events", None).unwrap_err(); assert_eq!(error.code(), "catalog-malformed"); assert!( @@ -2723,9 +3533,9 @@ mod tests { let added = add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", - Some("h.worker"), + Some(&AgentSelector::id("h.worker")), "work", "github-issue://example/project/123", "release work item", @@ -2738,7 +3548,7 @@ mod tests { add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "source", @@ -2753,7 +3563,7 @@ mod tests { assert_eq!( add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2771,7 +3581,7 @@ mod tests { assert_eq!( add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2805,7 +3615,7 @@ mod tests { assert_eq!( add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2839,7 +3649,7 @@ mod tests { assert_eq!( add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2865,7 +3675,7 @@ mod tests { ); add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2876,7 +3686,7 @@ mod tests { .unwrap(); add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "source", @@ -2886,19 +3696,19 @@ mod tests { ) .unwrap(); - let removed = remove_resource(root, "h.worker", "h", Some("h.worker"), "work").unwrap(); + let removed = remove_resource(root, &AgentSelector::address("h.worker"), "h", Some(&AgentSelector::id("h.worker")), "work").unwrap(); assert_eq!(removed.result, AuthorOutcome::Changed); assert_eq!(removed.name, "work"); let after_remove = fs::read_to_string(&path).unwrap(); assert_eq!( - remove_resource(root, "h.worker", "h", None, "work") + remove_resource(root, &AgentSelector::address("h.worker"), "h", None, "work") .unwrap() .result, AuthorOutcome::Unchanged ); assert_eq!( - remove_resource(root, "h.worker", "h", None, "never-declared") + remove_resource(root, &AgentSelector::address("h.worker"), "h", None, "never-declared") .unwrap() .result, AuthorOutcome::Unchanged @@ -2921,7 +3731,7 @@ mod tests { ); add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "work", @@ -2932,7 +3742,7 @@ mod tests { .unwrap(); add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "source", @@ -2944,7 +3754,7 @@ mod tests { let before = fs::read_to_string(&path).unwrap(); assert_eq!( - rename_resource(root, "h.worker", "h", None, "work", "work") + rename_resource(root, &AgentSelector::address("h.worker"), "h", None, "work", "work") .unwrap() .result, AuthorOutcome::Unchanged @@ -2952,7 +3762,7 @@ mod tests { assert_eq!(fs::read_to_string(&path).unwrap(), before); let renamed = - rename_resource(root, "h.worker", "h", Some("h.worker"), "work", "task").unwrap(); + rename_resource(root, &AgentSelector::address("h.worker"), "h", Some(&AgentSelector::id("h.worker")), "work", "task").unwrap(); assert_eq!(renamed.result, AuthorOutcome::Changed); assert_eq!(renamed.old, "work"); assert_eq!(renamed.new, "task"); @@ -2972,20 +3782,20 @@ mod tests { ); assert_eq!( - rename_resource(root, "h.worker", "h", None, "work", "elsewhere") + rename_resource(root, &AgentSelector::address("h.worker"), "h", None, "work", "elsewhere") .unwrap_err() .code(), "resource-not-found" ); // An absent `old` refuses even when the rename would otherwise be a self-rename no-op. assert_eq!( - rename_resource(root, "h.worker", "h", None, "absent", "absent") + rename_resource(root, &AgentSelector::address("h.worker"), "h", None, "absent", "absent") .unwrap_err() .code(), "resource-not-found" ); assert_eq!( - rename_resource(root, "h.worker", "h", None, "task", "source") + rename_resource(root, &AgentSelector::address("h.worker"), "h", None, "task", "source") .unwrap_err() .code(), "resource-already-exists" @@ -3021,9 +3831,9 @@ mod tests { add_resource( root, - "h.child", + &AgentSelector::address("h.child"), "h", - Some("h.root"), + Some(&AgentSelector::id("h.root")), "work", "github-issue://example/project/1", "supervised work item", @@ -3034,9 +3844,9 @@ mod tests { assert_eq!( add_resource( root, - "h.sibling", + &AgentSelector::address("h.sibling"), "h", - Some("h.child"), + Some(&AgentSelector::id("h.child")), "work", "github-issue://example/project/1", "reaching across the fleet", @@ -3047,7 +3857,7 @@ mod tests { "resource-not-authorized" ); assert_eq!( - remove_resource(root, "h.sibling", "h", Some("h.child"), "work") + remove_resource(root, &AgentSelector::address("h.sibling"), "h", Some(&AgentSelector::id("h.child")), "work") .unwrap_err() .code(), "resource-not-authorized" @@ -3055,9 +3865,9 @@ mod tests { assert_eq!( add_resource( root, - "h.nix", + &AgentSelector::address("h.nix"), "h", - Some("h.root"), + Some(&AgentSelector::id("h.root")), "work", "github-issue://example/project/1", "Nix owns this declaration", @@ -3085,7 +3895,7 @@ mod tests { ] { let error = add_resource( root, - "h.child", + &AgentSelector::address("h.child"), "h", None, name, @@ -3107,7 +3917,7 @@ mod tests { // #345 widened the envelope: a catalog-relative carrier path is a valid binding uri. add_resource( root, - "h.child", + &AgentSelector::address("h.child"), "h", None, "carrier", @@ -3132,7 +3942,7 @@ mod tests { add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "subject", @@ -3144,14 +3954,14 @@ mod tests { assert_eq!(bound(root, "worker", "subject").uri(), exact); // The rename path carries the identity across without normalizing it either. - rename_resource(root, "h.worker", "h", None, "subject", "carried").unwrap(); + rename_resource(root, &AgentSelector::address("h.worker"), "h", None, "subject", "carried").unwrap(); assert_eq!(bound(root, "worker", "carried").uri(), exact); // A byte-identical re-declaration is a proven no-op, not a rewrite. assert_eq!( add_resource( root, - "h.worker", + &AgentSelector::address("h.worker"), "h", None, "carried", diff --git a/src/agent_publish.rs b/src/agent_publish.rs index 7eb86881..e2d91701 100644 --- a/src/agent_publish.rs +++ b/src/agent_publish.rs @@ -17,11 +17,42 @@ use sha2::{Digest, Sha256}; use crate::catalog_lock::CatalogLock; use crate::catalog_transaction::sync_dir; +use crate::{AgentAddress, AgentId}; const SCHEMA: &str = "st2.agent-publish.v2"; const DIGEST_SCHEMA: &str = "st2.agent-source-digest.v1"; const BUNDLE_DIGEST_DOMAIN: &[u8] = b"st2.agent-publish-bundle.v1\0"; +/// A classified publication refusal. `code` is stable for machine consumers. +#[derive(Debug)] +pub struct PublishRefusal { + pub code: &'static str, + pub message: String, +} + +impl PublishRefusal { + fn new(code: &'static str, message: impl Into) -> anyhow::Error { + anyhow::Error::new(Self { + code, + message: message.into(), + }) + } +} + +impl std::fmt::Display for PublishRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +impl std::error::Error for PublishRefusal {} + +/// A publication that would change an existing subject's immutable ID. +pub const IMMUTABLE_AGENT_ID: &str = "immutable-agent-id"; + +/// A creating publication that did not mint an explicit generated ID. +pub const CREATION_REQUIRES_GENERATED_ID: &str = "creation-requires-generated-id"; + #[derive(Debug, Clone)] pub enum PublishSource { Spec(PathBuf), @@ -56,7 +87,12 @@ pub struct PublishResult { pub policy_profile: &'static str, pub agent_spec_revision: &'static str, pub status: PublishStatus, - pub bus_id: String, + /// The published subject's catalog-global immutable ID: the ownership key every automation, + /// durable edge, and task ID uses. + pub agent_id: String, + /// How a human reaches the published subject right now, or `None` for a retired subject that + /// released its address. + pub bus_address: Option, pub path: PathBuf, pub input_sha256: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -76,7 +112,15 @@ struct Candidate { kind: CandidateKind, bytes: Vec, host: String, + /// The positional declaration key. Also the legacy address fallback — never the ID. identity: String, + /// Explicit `id` as declared. `None` is an unmigrated legacy candidate whose ID is its frozen + /// legacy bus identity. + id: Option, + /// Explicit `address` as declared. `None` falls back to `identity`. + address: Option, + /// Whether ordinary address routing may reach the candidate once published. + routable: bool, input_sha256: String, } @@ -199,11 +243,22 @@ impl Candidate { .context("candidate must declare a non-empty explicit identity")?; validate_component("host", host)?; validate_component("identity", identity)?; + // Lower the already strictly parsed candidate so `id`, `address`, and desired state come + // from the one canonical Agent Spec lowering rather than a second hand-rolled reader. + let (specs, _) = agent_spec::discover_file(stage.path(), &spec_path) + .context("lower the candidate Agent Spec")?; + let spec = match specs.as_slice() { + [spec] => spec, + _ => anyhow::bail!("candidate must lower to exactly one Agent Spec"), + }; let input_sha256 = match kind { CandidateKind::Spec => sha256(&bytes), CandidateKind::Bundle => bundle_sha256(stage.path())?, }; Ok(Self { + id: spec.id.clone(), + address: spec.address.clone(), + routable: !spec.desired_state.is_retired(), stage, kind, bytes, @@ -213,11 +268,78 @@ impl Candidate { }) } - fn bus_id(&self) -> String { - format!("{}.{}", self.host, self.identity) + /// The positional declaration key `.`. + fn legacy_bus_identity(&self) -> String { + agent_spec::legacy_bus_identity(&self.host, &self.identity) + } + + /// The catalog-global immutable ID this publication claims. + fn agent_id(&self) -> String { + match &self.id { + Some(id) => id.as_str().to_owned(), + None => self.legacy_bus_identity(), + } + } + + /// The human route the published subject answers on, or `None` once it is non-routable. + fn bus_address(&self) -> Option { + let effective = match &self.address { + Some(address) => address.as_str(), + None => self.identity.as_str(), + }; + self.routable + .then(|| agent_spec::bus_address(&self.host, effective)) } } +/// Lower the incumbent declaration bytes under the lock and report the ID they own. +/// +/// The bytes are staged at the same canonical placement they occupy in the live catalog, then read +/// through exactly the Agent Spec lowering [`Candidate::stage_in`] uses, so this mints no second +/// reader and no second precedence rule. An unmigrated incumbent yields its frozen legacy bus +/// identity — the same value it will keep after migration. +fn incumbent_agent_id(bytes: &[u8], host: &str, identity: &str) -> Result { + let staging = tempfile::tempdir().context("create incumbent lowering staging root")?; + let directory = staging.path().join(host).join(identity); + fs::create_dir_all(&directory).context("stage the incumbent declaration directory")?; + let staged = directory.join("agent.kdl"); + fs::write(&staged, bytes).context("stage the incumbent declaration")?; + let (specs, _) = agent_spec::discover_file(staging.path(), &staged) + .context("lower the incumbent Agent Spec")?; + match specs.as_slice() { + [spec] => Ok(spec.agent_id(host)), + [] => anyhow::bail!("the incumbent declaration lowers to no Agent Spec"), + many => anyhow::bail!( + "the incumbent declaration lowers to {} Agent Specs", + many.len() + ), + } +} + +/// Whether `value` is a canonical lowercase hyphenated UUIDv7. +/// +/// Creation mints a brand-new subject, and a brand-new subject's ID is generated, never derived +/// from a route. Accepting a frozen-legacy-shaped ID here would let publication keep minting +/// placement-shaped IDs forever, which is exactly what decision 0015 retires. +fn is_canonical_uuid_v7(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (index, byte) in bytes.iter().enumerate() { + let expected_hyphen = matches!(index, 8 | 13 | 18 | 23); + let is_hyphen = *byte == b'-'; + if expected_hyphen != is_hyphen { + return false; + } + if !is_hyphen && !byte.is_ascii_digit() && !(b'a'..=b'f').contains(byte) { + return false; + } + } + // Version 7 in the high nibble of octet 6, RFC 9562 variant `10xx` in octet 8. + bytes[14] == b'7' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b') +} + pub fn digest_source(source: PublishSource) -> Result { let parent = tempfile::tempdir().context("create source-digest staging root")?; let candidate = Candidate::stage_in(parent.path(), source)?; @@ -347,6 +469,47 @@ pub fn publish(request: PublishRequest) -> Result { } } + // The subject's ID is immutable, and a byte-level expectation cannot see identity: an + // `--expect-sha256` update that matches the incumbent bytes exactly would otherwise be free to + // re-key the subject, which F02 refuses rather than inferring a rename, a replacement, or a + // state migration. Compare the two IDs before anything is admitted or written. + match &before { + Some(current) => { + let incumbent = incumbent_agent_id(current, &candidate.host, &candidate.identity)?; + let proposed = candidate.agent_id(); + if incumbent != proposed { + return Err(PublishRefusal::new( + IMMUTABLE_AGENT_ID, + format!( + "{} already declares agent id '{incumbent}'; this candidate claims '{proposed}'. An agent ID is immutable: retire the subject and create a replacement instead of re-keying it.", + target_spec.display() + ), + )); + } + } + None => { + // Creation, not update: a brand-new subject mints a generated ID. The frozen-legacy + // fallback exists to read and update a subject that predates migration, never to keep + // minting new placement-shaped IDs. + let minted = candidate.id.as_ref().map(|id| id.as_str().to_owned()); + if !minted.as_deref().is_some_and(is_canonical_uuid_v7) { + return Err(PublishRefusal::new( + CREATION_REQUIRES_GENERATED_ID, + match minted { + Some(declared) => format!( + "creating {} requires a generated canonical UUIDv7 `id`; '{declared}' is not one", + target_spec.display() + ), + None => format!( + "creating {} requires an explicit generated canonical UUIDv7 `id`", + target_spec.display() + ), + }, + )); + } + } + } + validate_overlay(&catalog, &control, &candidate)?; ensure_real_dir_chain( &catalog, @@ -367,7 +530,8 @@ pub fn publish(request: PublishRequest) -> Result { verify_published_spec(&catalog, &target_spec, &candidate.bytes, &after_hash)?; return Ok(result( PublishStatus::Unchanged, - candidate.bus_id(), + candidate.agent_id(), + candidate.bus_address(), candidate.input_sha256.clone(), target_spec, before_hash, @@ -377,7 +541,8 @@ pub fn publish(request: PublishRequest) -> Result { test_before_publication(); let generation = lock.begin_generation_commit()?; - let bus_id = candidate.bus_id(); + let agent_id = candidate.agent_id(); + let bus_address = candidate.bus_address(); let input_sha256 = candidate.input_sha256.clone(); match candidate.kind { CandidateKind::Spec => { @@ -400,7 +565,8 @@ pub fn publish(request: PublishRequest) -> Result { generation.commit()?; Ok(result( PublishStatus::Published, - bus_id, + agent_id, + bus_address, input_sha256, target_spec, before_hash, @@ -410,7 +576,8 @@ pub fn publish(request: PublishRequest) -> Result { fn result( status: PublishStatus, - bus_id: String, + agent_id: String, + bus_address: Option, input_sha256: String, path: PathBuf, before_sha256: Option, @@ -421,7 +588,8 @@ fn result( policy_profile: crate::validate::CORE_CATALOG_POLICY_PROFILE, agent_spec_revision: agent_spec::AGENT_SPEC_REVISION, status, - bus_id, + agent_id, + bus_address, path, input_sha256, before_sha256, @@ -442,7 +610,10 @@ fn verify_published_spec( observed_sha256 == expected_sha256 && observed == expected_bytes, "published Agent Spec readback mismatch: expected sha256 {expected_sha256}, found {observed_sha256}" ); - crate::catalog_transaction::validate_full_catalog(catalog) + crate::catalog_transaction::validate_full_catalog( + catalog, + &crate::catalog_archive::archived_subjects(catalog)?, + ) .context("published catalog fails locked core/catalog re-admission")?; Ok(observed_sha256) } @@ -522,9 +693,15 @@ fn read_regular_optional(path: &Path) -> Result>> { } } +/// Admit the candidate as an overlay on the complete prospective catalog. +/// +/// Agent IDs are catalog-global across the live plane and the structural archive, so the overlay +/// is proved against both: publishing a spec whose ID an archived subject still holds must fail +/// even though that subject is undiscoverable. fn validate_overlay(catalog: &Path, control: &Path, candidate: &Candidate) -> Result<()> { let shadow = build_overlay(catalog, control, candidate)?; - crate::catalog_transaction::validate_full_catalog(shadow.path()) + let archived = crate::catalog_archive::archived_subjects(catalog)?; + crate::catalog_transaction::validate_full_catalog(shadow.path(), &archived) .context("candidate fails full-catalog validation") } diff --git a/src/agents.rs b/src/agents.rs index 546b6d2c..33313484 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -15,7 +15,8 @@ use crate::{AgentSpec, Discovered, Resource, driver_diagnostic, harness_context, /// One roster row: everything `st2 agents [--enrich]` can report about an agent. #[derive(Debug, Clone)] pub struct AgentRow { - /// The bus id — `.`. + /// The positional `.` declaration key — the legacy address fallback and the + /// bytes legacy-ID migration freezes. Not the subject's immutable identity. pub identity: String, /// Declaration source used to attribute this runtime observation. pub source_path: PathBuf, @@ -51,10 +52,19 @@ pub struct AgentRow { /// three. `None` means no record exists; a record past its horizon is still reported, marked /// stale and carrying its age, so it survives every `observedState: unknown` derivation. pub context: Option, + /// The catalog-global immutable agent ID: what owns this agent's runtime, durable state, + /// graph edges, and automation. Equal to `identity` for an unmigrated declaration by + /// construction, which is why migration moves no state. + pub id: String, + /// The effective host-local address: the explicit declared `address`, else `identity`. + pub address: String, + /// The qualified human route `.`, or `None` for a proved + /// non-routable subject. A retired subject releases its address but keeps its ID. + pub bus_address: Option, } -/// Every agent in the catalog, sorted by bus id, with presence + enrich data computed. Read-only: -/// walks discovered specs and each agent's resources, mutating nothing. +/// Every agent in the catalog, sorted by positional declaration key, with presence + enrich data +/// computed. Read-only: walks discovered specs and each agent's resources, mutating nothing. pub fn roster(catalog_root: &Path, this_host: &str) -> Vec { let found = crate::discover(catalog_root); roster_from_discovered(&found, catalog_root, this_host) @@ -76,7 +86,7 @@ pub fn roster_from_discovered( .filter_map(|s| { let agent_dir = s.path.parent()?; Some(AgentRow { - identity: s.bus_id(this_host), + identity: s.legacy_bus_identity(this_host), source_path: s.path.clone(), status: status::read_state(&status::status_path(agent_dir)), name: s.name.clone(), @@ -103,6 +113,12 @@ pub fn roster_from_discovered( // Read independently of the state record above: the wedge case this exists for is // an agent whose state has gone indeterminate at 190k of a 200k window. context: harness_context::read(&harness_context::harness_context_path(agent_dir)), + id: s.agent_id(this_host), + address: s.effective_address().to_owned(), + // Routability, not presence: a retired subject releases its effective address, so + // it has no human route at all while keeping its ID and staying reachable by it. + bus_address: (!s.desired_state.is_retired()) + .then(|| s.bus_address(this_host)), }) }) .collect(); @@ -306,7 +322,9 @@ fn resource_json(row: &AgentRow) -> Vec> { .collect() } -/// `st2 agents --json` row. Field order and names are the stable wire contract. +/// `st2 agents --json` row. Field order and names are the stable wire contract: `identity` keeps +/// its meaning — the positional `.` declaration key — and the immutable-ID axis is +/// appended rather than substituted, so an existing consumer keeps reading what it read before. #[derive(Serialize)] struct SummaryJson<'a> { identity: &'a str, @@ -324,6 +342,12 @@ struct SummaryJson<'a> { #[serde(rename = "driverDiagnostic")] driver_diagnostic: DriverDiagnosticJson<'a>, context: Option>, + id: &'a str, + address: &'a str, + /// `null` for a proved non-routable retired subject: it has released its address, which is + /// different from having one nobody answered. + #[serde(rename = "busAddress")] + bus_address: Option<&'a str>, } /// `st2 agents --json --enrich` row (adds `lastActivity` and `inbox`). @@ -347,6 +371,10 @@ struct EnrichedJson<'a> { #[serde(rename = "driverDiagnostic")] driver_diagnostic: DriverDiagnosticJson<'a>, context: Option>, + id: &'a str, + address: &'a str, + #[serde(rename = "busAddress")] + bus_address: Option<&'a str>, } /// Serialize a roster to the stable JSON emitted by `st2 agents --json [--enrich]`. @@ -368,6 +396,9 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String { observed_state: ObservedJson::from_row(r.observed.as_ref()), driver_diagnostic: DriverDiagnosticJson::from_row(&r.driver_diagnostic), context: ContextJson::from_row(r.context.as_ref()), + id: &r.id, + address: &r.address, + bus_address: r.bus_address.as_deref(), }) .collect(); serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()) @@ -386,6 +417,9 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String { observed_state: ObservedJson::from_row(r.observed.as_ref()), driver_diagnostic: DriverDiagnosticJson::from_row(&r.driver_diagnostic), context: ContextJson::from_row(r.context.as_ref()), + id: &r.id, + address: &r.address, + bus_address: r.bus_address.as_deref(), }) .collect(); serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()) @@ -467,6 +501,14 @@ mod tests { observed: None, driver_diagnostic: driver_diagnostic::Observed::Absent, context: None, + // An unmigrated declaration's ID is exactly its positional key; its effective address + // is the bare identity, and only a routable subject has a bus address at all. + id: identity.to_string(), + address: identity + .split_once('.') + .map_or(identity, |(_, bare)| bare) + .to_string(), + bus_address: (!retired).then(|| identity.to_string()), } } @@ -487,11 +529,11 @@ mod tests { assert_eq!( to_json(&rows, false), - r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"desiredState":"retired","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.cos-claude","address":"cos-claude","busAddress":"hetz.cos-claude"},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"desiredState":"retired","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.st2-claude","address":"st2-claude","busAddress":null}]"# ); assert_eq!( to_json(&rows, true), - r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":1,"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"lastActivity":null,"inbox":0,"desiredState":"retired","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.cos-claude","status":"available","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":1,"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.cos-claude","address":"cos-claude","busAddress":"hetz.cos-claude"},{"identity":"hetz.st2-claude","status":"busy","name":"owner","description":null,"retired":true,"resources":[],"lastActivity":null,"inbox":0,"desiredState":"retired","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.st2-claude","address":"st2-claude","busAddress":null}]"# ); // Empty roster is `[]`, not `null`. assert_eq!(to_json(&[], true), "[]"); @@ -514,7 +556,7 @@ mod tests { assert_eq!( to_json(&[resource_row], false), - r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task.","resync":"unsupported"}],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task.","resync":"unsupported"}],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.worker","address":"worker","busAddress":"hetz.worker"}]"# ); } @@ -532,6 +574,9 @@ mod tests { 0, ); wedged.observed = Some(harness_state::Observed { + subject: Some(harness_state::RecordSubject::BusIdentity( + "hetz.worker".into(), + )), state: harness_state::Activity::Idle, blocked_on: harness_state::BlockedOn::None, input_buffer: harness_state::InputBuffer::Empty, @@ -544,15 +589,16 @@ mod tests { assert_eq!( to_json(&[wedged.clone()], false), - r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.worker","address":"worker","busAddress":"hetz.worker"}]"# ); assert_eq!( to_json(&[wedged], true), - r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":0,"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.worker","status":"busy","name":null,"description":null,"retired":false,"resources":[],"lastActivity":1784653027733.6138,"inbox":0,"desiredState":"running","desiredStateReason":null,"observedState":{"state":"idle","blockedOn":"none","inputBuffer":"empty","ask":"none","harness":"codex","since":1784653000000,"reason":null,"exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.worker","address":"worker","busAddress":"hetz.worker"}]"# ); let mut derived = row("hetz.worker", State::Available, None, false, None, 0); derived.observed = Some(harness_state::Observed { + subject: None, state: harness_state::Activity::Unknown, blocked_on: harness_state::BlockedOn::Unknown, input_buffer: harness_state::InputBuffer::Unknown, @@ -564,7 +610,7 @@ mod tests { }); assert_eq!( to_json(&[derived], false), - r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"unknown","blockedOn":"unknown","inputBuffer":"unknown","ask":"unknown","harness":"codex","since":null,"reason":"session-dead","exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null}]"# + r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[],"desiredState":"running","desiredStateReason":null,"observedState":{"state":"unknown","blockedOn":"unknown","inputBuffer":"unknown","ask":"unknown","harness":"codex","since":null,"reason":"session-dead","exit":null},"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"},"context":null,"id":"hetz.worker","address":"worker","busAddress":"hetz.worker"}]"# ); } @@ -576,6 +622,7 @@ mod tests { fn context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state() { let mut wedged = row("hetz.worker", State::Busy, None, false, None, 0); wedged.observed = Some(harness_state::Observed { + subject: None, state: harness_state::Activity::Unknown, blocked_on: harness_state::BlockedOn::Unknown, input_buffer: harness_state::InputBuffer::Unknown, @@ -586,6 +633,7 @@ mod tests { reason: Some("session-dead".to_string()), }); wedged.context = Some(harness_context::Observed { + subject: harness_state::RecordSubject::BusIdentity("hetz.worker".into()), harness: harness_context::Harness::Codex, used_tokens: Some(92283), window_tokens: Some(258400), @@ -640,6 +688,7 @@ mod tests { // A percent above the window rides the wire raw, and a stale reading keeps its age. let mut overrun = row("hetz.pi", State::Available, None, false, None, 0); overrun.context = Some(harness_context::Observed { + subject: harness_state::RecordSubject::BusIdentity("hetz.pi".into()), harness: harness_context::Harness::Pi, used_tokens: Some(23424), window_tokens: Some(4000), @@ -673,6 +722,9 @@ mod tests { fn exhausted_claude_rate_limit_is_explicit_beside_active_state() { let mut limited = row("hetz.worker", State::Available, None, false, None, 0); limited.observed = Some(harness_state::Observed { + subject: Some(harness_state::RecordSubject::BusIdentity( + "hetz.worker".into(), + )), state: harness_state::Activity::Active, blocked_on: harness_state::BlockedOn::None, input_buffer: harness_state::InputBuffer::Unknown, @@ -683,6 +735,7 @@ mod tests { reason: None, }); limited.context = Some(harness_context::Observed { + subject: harness_state::RecordSubject::BusIdentity("hetz.worker".into()), harness: harness_context::Harness::Claude, used_tokens: Some(194_763), window_tokens: Some(1_000_000), diff --git a/src/catalog_archive.rs b/src/catalog_archive.rs index 1cac25f3..02bc3a6b 100644 --- a/src/catalog_archive.rs +++ b/src/catalog_archive.rs @@ -164,6 +164,133 @@ struct Candidate { from: PathBuf, } +/// One structurally archived declaration, read back out of the control plane. +/// +/// Archived declarations are deliberately undiscoverable, so nothing else in st2 can see them. +/// Agent IDs are catalog-global across the live plane *and* this archive, which is why the +/// admission path has to be able to read them. +#[derive(Debug, Clone)] +pub struct ArchivedDeclaration { + pub host: String, + pub identity: String, + /// The archived declaration file, inside `/.st2/archive`. + pub path: PathBuf, + pub spec: agent_spec::spec::AgentSpec, + pub tombstone_path: PathBuf, + /// `None` for an archived directory whose tombstone is missing — [`observe`] reports that as + /// an unexplained control-plane entry; identity admission still counts the declaration. + pub tombstone: Option, +} + +/// Read every structurally archived declaration. +/// +/// Fails closed on anything it cannot explain. Every consumer of this reader feeds a +/// catalog-global uniqueness or admission proof, so absence and uncertainty must not look alike: a +/// symlinked host directory, an aliased identity bundle, or a stray entry could each be the thing +/// holding the agent ID the caller is about to claim. Exactly two shapes are admitted under a host +/// directory — a real identity directory, and the defined regular tombstone file beside it — and +/// everything else refuses. +pub fn archived_declarations(catalog: &Path) -> Result> { + let root = archive_root(catalog); + let Some(hosts) = read_real_dir_optional(&root)? else { + return Ok(Vec::new()); + }; + let mut archived = Vec::new(); + for host_entry in hosts { + let host_path = host_entry.path(); + let host_meta = fs::symlink_metadata(&host_path) + .with_context(|| format!("read archive host entry {}", host_path.display()))?; + anyhow::ensure!( + host_meta.is_dir() && !host_meta.file_type().is_symlink(), + "refusing to prove archived identity: {} is not a real archive host directory", + relative(catalog, &host_path).unwrap_or_else(|| host_path.display().to_string()) + ); + let host_dir = host_entry.file_name().to_string_lossy().into_owned(); + for identity_entry in sorted_entries(&host_path)? { + let identity_path = identity_entry.path(); + let name = identity_entry.file_name().to_string_lossy().into_owned(); + let meta = fs::symlink_metadata(&identity_path).with_context(|| { + format!("read archived identity entry {}", identity_path.display()) + })?; + if name.ends_with(TOMBSTONE_SUFFIX) { + // The defined sibling shape. It holds no declaration of its own: an interrupted + // `unarchive` legitimately leaves one behind after the directory moved back, and + // `observe` reports that as an unexplained control-plane entry. + anyhow::ensure!( + meta.is_file() && !meta.file_type().is_symlink(), + "refusing to prove archived identity: {} is not a real tombstone file", + relative(catalog, &identity_path) + .unwrap_or_else(|| identity_path.display().to_string()) + ); + continue; + } + anyhow::ensure!( + meta.is_dir() && !meta.file_type().is_symlink(), + "refusing to prove archived identity: {} is neither a real archived identity directory nor a tombstone", + relative(catalog, &identity_path) + .unwrap_or_else(|| identity_path.display().to_string()) + ); + let path = identity_path.join("agent.kdl"); + let declaration_meta = fs::symlink_metadata(&path).with_context(|| { + format!( + "archived identity {name} has no canonical declaration at {}", + relative(catalog, &path).unwrap_or_else(|| path.display().to_string()) + ) + })?; + anyhow::ensure!( + declaration_meta.is_file() && !declaration_meta.file_type().is_symlink(), + "refusing to prove archived identity: {} is not a real declaration file", + relative(catalog, &path).unwrap_or_else(|| path.display().to_string()) + ); + let tombstone_path = host_path.join(format!("{name}{TOMBSTONE_SUFFIX}")); + let tombstone = read_tombstone(&tombstone_path)?; + // The archive layout is `///agent.kdl`, which supplies + // exactly the host and identity path defaults ordinary discovery would. + let (specs, _) = agent_spec::discover_file(&root, &path).with_context(|| { + format!( + "parse archived declaration {}", + relative(catalog, &path).unwrap_or_else(|| path.display().to_string()) + ) + })?; + anyhow::ensure!( + !specs.is_empty(), + "refusing to prove archived identity: {} declares no agent", + relative(catalog, &path).unwrap_or_else(|| path.display().to_string()) + ); + for spec in specs { + archived.push(ArchivedDeclaration { + host: spec.resolved_host(&host_dir).to_owned(), + identity: spec.identity.clone(), + path: path.clone(), + spec, + tombstone_path: tombstone_path.clone(), + tombstone: tombstone.clone(), + }); + } + } + } + archived.sort_by(|left, right| { + (&left.host, &left.identity, &left.path).cmp(&(&right.host, &right.identity, &right.path)) + }); + Ok(archived) +} + +/// Every archived subject, for catalog-global agent-ID uniqueness. +/// +/// Archived subjects are non-routable: archival released their effective address, so they occupy +/// the ID namespace without occupying any host's address namespace. They keep their ID, and stay +/// reachable by exact ID. +pub fn archived_subjects(catalog: &Path) -> Result> { + archived_declarations(catalog)? + .into_iter() + .map(|archived| { + let mut subject = archived.spec.subject(&archived.host)?; + subject.routable = false; + Ok(subject) + }) + .collect() +} + /// Archive every selected identity under one exclusive authoring lock and one generation commit. pub fn archive(request: ArchiveRequest) -> Result { let catalog = canonical(&request.catalog)?; @@ -289,6 +416,13 @@ fn discovered(catalog: &Path) -> Result { } /// Move one archived identity back into the live declaration plane. +/// +/// The subject keeps the ID it was archived with — reintroducing the same ID denotes the same +/// subject — so re-entry has to prove that ID is still free across the prospective live plane and +/// the rest of the archive, and that it re-enters a host address namespace it does not collide +/// with. Once the catalog is migrated, an archived declaration that never received an explicit ID +/// cannot come back at all: its implicit legacy bytes are exactly what migration may have +/// reassigned to another subject. pub fn unarchive(request: UnarchiveRequest) -> Result { let catalog = canonical(&request.catalog)?; validate_component("host", &request.host)?; @@ -318,6 +452,52 @@ pub fn unarchive(request: UnarchiveRequest) -> Result { to.display() ); + let archived = archived_declarations(&catalog)?; + let restored = archived + .iter() + .filter(|entry| entry.host == request.host && entry.identity == request.identity) + .collect::>(); + let restored = match restored.as_slice() { + [only] => *only, + [] => anyhow::bail!( + "archived identity {} declares no agent to restore", + crate::legacy_bus_identity(&request.host, &request.identity) + ), + many => anyhow::bail!( + "archived declaration for {} contains {} agents; unarchive restores exactly one", + crate::legacy_bus_identity(&request.host, &request.identity), + many.len() + ), + }; + let found = discovered(&catalog)?; + let migrated_catalog = crate::catalog_migrate::is_migrated(&found.specs); + anyhow::ensure!( + restored.spec.id.is_some() || !migrated_catalog, + "refusing to unarchive {}: the catalog is migrated and this archived declaration has no explicit `id`; repair it through the pre-activation legacy authoring path first", + crate::legacy_bus_identity(&request.host, &request.identity) + ); + let agent_id = restored.spec.agent_id(&request.host); + // The prospective catalog is the live plane plus this subject, restored as routable, plus every + // archived subject that stays archived. + let mut prospective = found.specs.clone(); + prospective.push(restored.spec.clone()); + let others = archived + .iter() + .filter(|entry| !std::ptr::eq(*entry, restored)) + .map(|entry| { + let mut subject = entry.spec.subject(&entry.host)?; + subject.routable = false; + Ok(subject) + }) + .collect::>>()?; + crate::catalog_transaction::validate_identity_uniqueness(&prospective, &others).with_context( + || { + format!( + "refusing to unarchive {agent_id}: it does not fit the prospective live-and-archived catalog" + ) + }, + )?; + let tombstone_path = host_root.join(format!("{}{TOMBSTONE_SUFFIX}", request.identity)); let archived_at = read_tombstone(&tombstone_path) .ok() @@ -345,7 +525,7 @@ pub fn unarchive(request: UnarchiveRequest) -> Result { Ok(UnarchiveResult { schema: UNARCHIVE_SCHEMA, - id: format!("{}.{}", request.host, request.identity), + id: agent_id, host: request.host, identity: request.identity, from: relative(&catalog, &from).unwrap_or_else(|| from.display().to_string()), @@ -365,7 +545,10 @@ pub fn observe(catalog: &Path) -> Result { let Some(hosts) = read_real_dir_optional(&root)? else { return Ok(observation); }; - let mut tombstones: BTreeMap = BTreeMap::new(); + // Keyed structurally by placement, never by agent ID: an ID is opaque and two archived + // subjects that somehow claim one must both stay visible so the duplicate can be reported + // instead of one silently replacing the other. + let mut tombstones: BTreeMap<(String, String), Tombstone> = BTreeMap::new(); for host_entry in hosts { let host_path = host_entry.path(); if !host_entry.file_type()?.is_dir() { @@ -401,7 +584,7 @@ pub fn observe(catalog: &Path) -> Result { match read_tombstone(&path) { Ok(Some(tombstone)) if tombstone.identity == identity => { seen.insert(identity.to_owned()); - tombstones.insert(tombstone.id.clone(), tombstone); + tombstones.insert((host.clone(), identity.to_owned()), tombstone); } Ok(_) => observation.issues.push(issue( catalog, @@ -435,7 +618,28 @@ pub fn observe(catalog: &Path) -> Result { &host_path.join(format!("{identity}{TOMBSTONE_SUFFIX}")), "archive tombstone has no archived identity directory", )); - tombstones.remove(&format!("{host}.{identity}")); + tombstones.remove(&(host.clone(), identity.clone())); + } + } + let mut by_id: BTreeMap<&str, Vec<&Tombstone>> = BTreeMap::new(); + for tombstone in tombstones.values() { + by_id.entry(tombstone.id.as_str()).or_default().push(tombstone); + } + for (id, holders) in by_id { + if holders.len() > 1 { + let placements = holders + .iter() + .map(|tombstone| crate::legacy_bus_identity(&tombstone.host, &tombstone.identity)) + .collect::>() + .join(", "); + observation.issues.push(issue( + catalog, + &root, + &format!( + "archived agent id '{id}' is claimed by {} archived identities: {placements}", + holders.len() + ), + )); } } observation.archived = tombstones.into_values().collect(); @@ -487,12 +691,12 @@ fn plan( match matches.as_slice() { [spec] => selected.push(spec), [] => refused.push(Refusal { - id: format!("{host}.{identity}"), + id: crate::legacy_bus_identity(host, identity), code: "unknown-identity", message: format!("no declaration for '{identity}' on host '{host}'"), }), many => refused.push(Refusal { - id: format!("{host}.{identity}"), + id: crate::legacy_bus_identity(host, identity), code: "ambiguous-identity", message: format!("{} declarations claim this identity", many.len()), }), @@ -520,7 +724,9 @@ fn plan( let leaving: BTreeSet<&Path> = selected.iter().map(|spec| spec.path.as_path()).collect(); let mut candidates = Vec::new(); for spec in &selected { - let id = spec.bus_id(host); + // Archival preserves the subject's frozen ID: the tombstone, the entry, and every task + // record this pass reads are ownership keys, not routes. + let id = spec.agent_id(host); let identity = spec.identity.clone(); let from = catalog.join("agents").join(host).join(&identity); if spec.path != from.join("agent.kdl") { @@ -573,16 +779,11 @@ fn plan( .iter() .filter(|other| !leaving.contains(other.path.as_path())) .filter(|other| { - other.supervisor.as_deref().is_some_and(|supervisor| { - crate::supervisor_chain::resolve_spec( - &found.specs, - supervisor, - other.resolved_host(host), - ) + crate::supervisor_chain::resolve_supervisor_spec(&found.specs, other, host) .is_some_and(|resolved| resolved.path == spec.path) - }) }) - .map(|other| other.bus_id(host)) + // A dependent is named for a human to go and repair, so it reads as a route. + .map(|other| other.bus_address(host)) .collect::>(); if !dependents.is_empty() { refused.push(Refusal { @@ -853,7 +1054,9 @@ fn pass_has_work_at( changed || !due.is_empty() } -fn read_tombstone(path: &Path) -> Result> { +/// Read one tombstone. An absent file is `Ok(None)`; a foreign schema or unreadable body is an +/// error, because a tombstone is what makes an archived identity explainable. +pub(crate) fn read_tombstone(path: &Path) -> Result> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -874,10 +1077,13 @@ fn read_tombstone(path: &Path) -> Result> { "unknown archive tombstone schema '{}'", tombstone.schema ); - anyhow::ensure!( - tombstone.id == format!("{}.{}", tombstone.host, tombstone.identity), - "archive tombstone id does not match its host and identity" - ); + // `id` is the subject's frozen agent ID, which is opaque: a migrated legacy subject carries + // its former `.` bytes, while an archived collision carries a generated + // UUIDv7. The positional declaration key lives in `host`/`identity`, which stay checkable. + crate::AgentId::parse(&tombstone.id) + .map_err(|error| anyhow::anyhow!("archive tombstone id is not an agent ID: {error}"))?; + validate_component("host", &tombstone.host)?; + validate_component("identity", &tombstone.identity)?; Ok(Some(tombstone)) } diff --git a/src/catalog_graph.rs b/src/catalog_graph.rs index 6ccfbd3b..c76c746d 100644 --- a/src/catalog_graph.rs +++ b/src/catalog_graph.rs @@ -50,7 +50,10 @@ pub struct GraphRoots { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GraphAgent { + /// The catalog-global immutable agent ID. Every topology edge below (`parentId`, `rootId`, + /// `ancestorIds`) and every uniqueness admission is keyed by this value, never by the route. pub id: String, + /// The positional `.` declaration key. Preserved with its original meaning. pub identity: String, pub host: String, pub name: Option, @@ -71,6 +74,10 @@ pub struct GraphAgent { pub source: GraphSource, pub resources: Vec, pub runtime: serde_json::Value, + /// The effective host-local address: explicit `address`, else the positional `identity`. + pub address: String, + /// The qualified human route, or `null` for a proved non-routable retired subject. + pub bus_address: Option, } #[derive(Debug, Serialize)] @@ -243,10 +250,10 @@ fn graph_agent( let source_declaration = declarations.iter().find(|entry| entry.path == spec.path); let (path_identity, path_host) = path_defaults(root, &spec.path); let raw = source_declaration.and_then(|entry| match_declared(&entry.agents, spec, path_identity.as_deref())); - let id = spec.bus_id(this_host); + let id = spec.agent_id(this_host); let runtime = runtime_by_path .get_mut(&spec.path) - .and_then(|rows| rows.iter().position(|row| row.identity == id).map(|index| rows.remove(index))) + .and_then(|rows| rows.iter().position(|row| row.id == id).map(|index| rows.remove(index))) .map(|row| crate::agents::graph_runtime_value(&row)) .unwrap_or(serde_json::Value::Null); let resolved_workspace = spec.workspace.as_deref().and_then(|workspace| { @@ -309,6 +316,8 @@ fn graph_agent( }) .collect(), runtime, + address: spec.effective_address().to_owned(), + bus_address: (!spec.desired_state.is_retired()).then(|| spec.bus_address(this_host)), } } @@ -324,10 +333,10 @@ fn admitted_topology( spec: &AgentSpec, this_host: &str, ) -> Option { - let id = spec.bus_id(this_host); + let id = spec.agent_id(this_host); if specs .iter() - .filter(|candidate| candidate.bus_id(this_host) == id) + .filter(|candidate| candidate.agent_id(this_host) == id) .count() != 1 { @@ -349,9 +358,9 @@ fn admitted_topology( let ancestor_ids = chain .iter() .skip(1) - .map(|ancestor| ancestor.bus_id(this_host)) + .map(|ancestor| ancestor.agent_id(this_host)) .collect::>(); - let root_id = chain.last()?.bus_id(this_host); + let root_id = chain.last()?.agent_id(this_host); Some(AdmittedTopology { parent_id: ancestor_ids.first().cloned(), root_id, @@ -460,6 +469,8 @@ fn declared_field(agent: &agent_spec::DeclaredAgent, name: &str) -> Option> = BTreeMap::new(); for spec in specs { grouped - .entry(spec.bus_id(this_host)) + .entry(spec.agent_id(this_host)) .or_default() .push(relative(root, &spec.path)); } diff --git a/src/catalog_migrate.rs b/src/catalog_migrate.rs new file mode 100644 index 00000000..88b2e188 --- /dev/null +++ b/src/catalog_migrate.rs @@ -0,0 +1,1317 @@ +//! Additive legacy explicit-ID migration: one catalog transaction that freezes every legacy +//! subject's existing bus identity as its explicit immutable agent ID. +//! +//! The migration is deliberately *additive*. A live legacy subject receives exactly the bytes its +//! runtime, task IDs, sockets, and declaration-anchored state already use +//! (`.`), so nothing moves: `AgentSpec::agent_id` returns the same value +//! before and after the field appears. A structurally archived subject freezes the same bytes when +//! they are still unused across the combined live-and-archived subject set; an archived collision +//! receives a generated UUIDv7 in **both** its declaration and its tombstone, because two subjects +//! cannot share one catalog-global ID. +//! +//! An archived collision means some version-1 durable record carries bytes that now belong to a +//! different subject than the one that wrote them. Migration therefore records every reassignment +//! durably (see [`LegacyIdCollisions`]) so a tolerant reader can refuse to retype those bytes into +//! the wrong subject instead of guessing. +//! +//! Supervisor references resolve against the combined **pre-migration** live-and-archived subject +//! index and are rewritten to the parent's migrated ID in the same transaction. A missing or +//! ambiguous reference refuses before any write with [`LEGACY_SUPERVISOR_UNRESOLVED`]; the +//! operator unarchives and repairs that declaration through the pre-activation legacy authoring +//! path, then retries. +//! +//! Transactional shape reuses the R27 machinery already in this crate: the exclusive authoring +//! lock, complete prospective validation before the first byte is written, the durable +//! incomplete-generation intent that fences readers across the commit and is recovered by the next +//! exclusive writer, source-preserving span-bounded KDL edits, and fsync + atomic rename out of +//! the control plane. Recovery needs no separate replay stage: every individual declaration write +//! is independently valid and the collision metadata is durable *before* the declarations it +//! explains, so re-running `migrate` completes an interrupted pass — and a completed pass is a +//! proven no-op. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use kdl::{KdlDocument, KdlNode}; +use serde::{Deserialize, Serialize}; + +use crate::catalog_lock::{CONTROL_DIR, CatalogLock}; +use crate::catalog_transaction::sync_dir; +use crate::{AgentId, legacy_bus_identity}; + +pub const MIGRATE_SCHEMA: &str = "st2.catalog-id-migration.v1"; + +/// Versioned schema identifier of the durable collision record. +pub const LEGACY_ID_COLLISIONS_SCHEMA: &str = "st2.catalog-legacy-id-collisions.v1"; + +/// The exact refusal code for an unresolvable pre-migration supervisor reference. +pub const LEGACY_SUPERVISOR_UNRESOLVED: &str = "legacy-supervisor-unresolved"; + +/// `/.st2/legacy-id-collisions.json` — reserved control plane, never a declaration leaf. +const LEGACY_ID_COLLISIONS_FILE: &str = "legacy-id-collisions.json"; + +#[derive(Debug, Clone)] +pub struct MigrateRequest { + pub catalog: PathBuf, + /// The host used to resolve a declaration that omits `host`. Canonical declarations always + /// carry one; this only closes the legacy path. + pub host: String, + /// Prove the plan without writing anything. + pub dry_run: bool, +} + +/// A classified migration refusal. `code` is stable for machine consumers. +#[derive(Debug)] +pub struct MigrateRefusal { + pub code: &'static str, + pub message: String, +} + +impl MigrateRefusal { + fn new(code: &'static str, message: impl Into) -> anyhow::Error { + anyhow::Error::new(Self { + code, + message: message.into(), + }) + } +} + +impl fmt::Display for MigrateRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +impl std::error::Error for MigrateRefusal {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MigrateStatus { + /// At least one declaration or tombstone changed. + Migrated, + /// Every live and archived declaration already carries an explicit ID and every supervisor + /// reference is already ID-keyed. Nothing was written. + Unchanged, +} + +impl MigrateStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Migrated => "migrated", + Self::Unchanged => "unchanged", + } + } +} + +impl fmt::Display for MigrateStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Which plane a migrated declaration lives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Plane { + Live, + Archived, +} + +impl Plane { + pub fn as_str(&self) -> &'static str { + match self { + Self::Live => "live", + Self::Archived => "archived", + } + } +} + +impl fmt::Display for Plane { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One subject that received an explicit ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigratedSubject { + pub plane: Plane, + pub host: String, + pub identity: String, + pub id: AgentId, + /// Catalog-relative declaration path. + pub declaration: String, + /// `true` when the frozen legacy bytes were already taken and a UUIDv7 was generated instead. + pub generated: bool, +} + +/// One supervisor reference rewritten to its parent's migrated ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupervisorRewrite { + pub plane: Plane, + pub host: String, + pub identity: String, + pub from: String, + pub to: AgentId, +} + +/// Stable machine-readable receipt from one migration pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrateResult { + pub schema: &'static str, + pub status: MigrateStatus, + pub host: String, + pub dry_run: bool, + pub migrated: Vec, + pub supervisors: Vec, + pub collisions: Vec, + /// Archived tombstones rewritten to agree with their declaration. Non-zero without any + /// `migrated` entry means this pass converged an interrupted predecessor. + pub tombstones_repaired: usize, +} + +// ---- Durable collision record ----------------------------------------------------------------- + +/// One reassigned legacy bus identity. +/// +/// `keeper` is the subject that kept those bytes as its immutable ID; `reassigned` are the +/// archived subjects that lost them and received generated IDs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyIdCollision { + /// The `.` bytes a version-1 durable record may carry. + pub legacy_bus_identity: String, + pub keeper: AgentId, + pub reassigned: Vec, +} + +/// Every legacy bus identity migration reassigned, keyed for tolerant readers of version-1 +/// records. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyIdCollisions { + pub schema: String, + pub entries: Vec, +} + +/// What a legacy `.` endpoint means after migration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LegacyEndpoint { + /// No collision was recorded for these bytes, so they *are* the frozen agent ID of the subject + /// that wrote them. A reader may retype them directly. + Frozen, + /// These bytes were contested at migration time. `keeper` owns them as its immutable ID; + /// `reassigned` subjects wrote records with these bytes and no longer own them, so a reader + /// must not retype the endpoint into any subject it cannot otherwise prove. + Collision { + keeper: AgentId, + reassigned: Vec, + }, +} + +/// `/.st2/legacy-id-collisions.json`. +pub fn legacy_id_collisions_path(catalog: &Path) -> PathBuf { + catalog + .join(CONTROL_DIR) + .join(LEGACY_ID_COLLISIONS_FILE) +} + +/// Read the durable collision record. +/// +/// Only a genuinely **absent** directory entry reads as empty — that is exactly the meaning a +/// pre-migration deployment has, and it makes every legacy endpoint [`LegacyEndpoint::Frozen`]. +/// Every other outcome (an I/O or permission failure, an entry that is not a regular file, a +/// corrupt body, or a schema string this binary does not understand) is propagated, because +/// "empty" is the fail-OPEN answer: it would retype contested legacy bytes into whichever subject +/// kept them and hand an interrupted send, a reply, or a request attribution to the wrong keeper. +/// +/// Existence is decided with `symlink_metadata`, never with the outcome of the read: a dangling +/// symlink makes `fs::read` report `NotFound` for an entry that demonstrably exists, so following +/// the read would let an alias forge "no collisions were ever recorded". +pub fn load_legacy_id_collisions(catalog: &Path) -> Result { + let path = legacy_id_collisions_path(catalog); + match fs::symlink_metadata(&path) { + Ok(metadata) => anyhow::ensure!( + metadata.file_type().is_file(), + "{}: the legacy-id-collision record is not a regular file ({:?}); refusing to \ + attribute legacy endpoints from a collision set it cannot read", + path.display(), + metadata.file_type() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LegacyIdCollisions { + schema: LEGACY_ID_COLLISIONS_SCHEMA.to_owned(), + entries: Vec::new(), + }); + } + Err(error) => { + return Err(anyhow::Error::new(error).context(format!( + "stat the legacy-id-collision record {}: refusing to attribute legacy endpoints \ + from a collision set it cannot read", + path.display() + ))); + } + } + let bytes = fs::read(&path).with_context(|| { + format!( + "read the legacy-id-collision record {}: refusing to attribute legacy endpoints from \ + an unreadable collision set", + path.display() + ) + })?; + let record: LegacyIdCollisions = serde_json::from_slice(&bytes).with_context(|| { + format!( + "parse the legacy-id-collision record {}: refusing to attribute legacy endpoints from \ + a corrupt collision set", + path.display() + ) + })?; + anyhow::ensure!( + record.schema == LEGACY_ID_COLLISIONS_SCHEMA, + "{}: unsupported legacy-id-collision schema '{}' (this binary reads '{}'); refusing to \ + attribute legacy endpoints from a collision set it cannot read", + path.display(), + record.schema, + LEGACY_ID_COLLISIONS_SCHEMA + ); + Ok(record) +} + +impl LegacyIdCollisions { + /// How a tolerant reader must attribute one legacy `.` endpoint. + /// + /// This is the collision-aware attribution boundary: a reader of a version-1 record calls it + /// with the record's endpoint bytes and only attributes the record to a migrated ID when that + /// ID independently owns the row. Anything else is a historical address with no reply or + /// automation authority. + pub fn attribution(&self, legacy_bus_identity: &str) -> LegacyEndpoint { + match self + .entries + .iter() + .find(|entry| entry.legacy_bus_identity == legacy_bus_identity) + { + Some(entry) => LegacyEndpoint::Collision { + keeper: entry.keeper.clone(), + reassigned: entry.reassigned.clone(), + }, + None => LegacyEndpoint::Frozen, + } + } +} + +// ---- Migration --------------------------------------------------------------------------------- + +/// Whether this declaration set has already been migrated. +/// +/// A migrated catalog has at least one declaration and every one of them carries an explicit `id`. +/// An empty catalog is not "migrated": there is nothing whose ID could have been frozen. +pub fn is_migrated(specs: &[agent_spec::spec::AgentSpec]) -> bool { + !specs.is_empty() && specs.iter().all(|spec| spec.id.is_some()) +} + +/// One pre-migration subject in the combined live-and-archived index. +#[derive(Debug, Clone)] +struct Indexed { + plane: Plane, + path: PathBuf, + host: String, + identity: String, + /// Effective address before migration: explicit `address`, else the positional identity. + effective_address: String, + /// The ID this subject had before migration: explicit `id`, else its legacy bus identity. + prior_id: String, + /// The ID it has after this transaction. + migrated_id: AgentId, + /// `true` when this pass assigns `migrated_id` for the first time. + assigned: bool, + /// `true` when `migrated_id` was generated because the legacy bytes were taken. + generated: bool, + supervisor: Option, + retired: bool, + spec: agent_spec::spec::AgentSpec, + /// The archived tombstone that also has to carry a generated ID. + tombstone: Option<(PathBuf, crate::catalog_archive::Tombstone)>, +} + +impl Indexed { + fn legacy(&self) -> String { + legacy_bus_identity(&self.host, &self.identity) + } + + fn bus_address(&self) -> String { + legacy_bus_identity(&self.host, &self.effective_address) + } +} + +/// Freeze every legacy subject's explicit ID and rewrite every supervisor reference, in one +/// transaction. +pub fn migrate(request: MigrateRequest) -> Result { + let catalog = request + .catalog + .canonicalize() + .with_context(|| format!("canonicalize catalog {}", request.catalog.display()))?; + let lock = CatalogLock::exclusive(&catalog)?; + + let found = crate::discover_strict(&catalog); + if !found.errors.is_empty() { + return Err(MigrateRefusal::new( + "catalog-incomplete", + format!( + "refusing to migrate: catalog discovery is incomplete, so a legacy subject or supervisor reference could be hidden:\n{}", + found + .errors + .iter() + .map(|error| format!(" {}: {}", error.path.display(), error.message)) + .collect::>() + .join("\n") + ), + )); + } + let archived = crate::catalog_archive::archived_declarations(&catalog)?; + + let mut index = plan(&request.host, &found.specs, &archived)?; + let mut collisions = assign_ids(&mut index)?; + let rewrites = resolve_supervisors(&mut index)?; + + validate_prospective(&index)?; + + let migrated = index + .iter() + .filter(|entry| entry.assigned) + .map(|entry| MigratedSubject { + plane: entry.plane, + host: entry.host.clone(), + identity: entry.identity.clone(), + id: entry.migrated_id.clone(), + declaration: relative(&catalog, &entry.path), + generated: entry.generated, + }) + .collect::>(); + + let edits = declaration_edits(&index, &rewrites)?; + // Repairs are scheduled from what the archive actually says, not from "does this declaration + // already carry an id". A pass interrupted between an archived declaration write and its + // tombstone write leaves the two disagreeing while the declaration looks migrated; only the + // observed disagreement can converge that state on a rerun. + let tombstones = tombstone_repairs(&index); + merge_collisions(&mut collisions, observed_collisions(&index)); + // A record this binary cannot read is a refusal, not an empty collision set: rewriting it + // from "no collisions" would drop the reassignments a tolerant reader depends on. + let collisions = pending_collisions(collisions, &load_legacy_id_collisions(&catalog)?); + + if edits.is_empty() && tombstones.is_empty() && collisions.is_empty() { + return Ok(MigrateResult { + schema: MIGRATE_SCHEMA, + status: MigrateStatus::Unchanged, + host: request.host, + dry_run: request.dry_run, + migrated, + supervisors: rewrites, + collisions, + tombstones_repaired: 0, + }); + } + if request.dry_run { + return Ok(MigrateResult { + schema: MIGRATE_SCHEMA, + status: MigrateStatus::Migrated, + host: request.host, + dry_run: true, + migrated, + supervisors: rewrites, + collisions, + tombstones_repaired: tombstones.len(), + }); + } + + let control = crate::catalog_transaction::retained_dir_path(lock.control())?; + let generation = lock.begin_generation_commit()?; + // The record explains declarations, so it lands before them: an interrupted pass leaves a + // catalog whose already-written IDs are all explained, and re-running completes the rest. + if !collisions.is_empty() { + write_collisions(&lock, &catalog, &control, &collisions)?; + } + for edit in &edits { + write_declaration(&lock, &catalog, &control, &edit.path, &edit.bytes)?; + } + for (path, tombstone) in &tombstones { + write_tombstone(&lock, &catalog, &control, path, tombstone)?; + } + verify(&catalog, &request.host, &index)?; + generation.commit()?; + + Ok(MigrateResult { + schema: MIGRATE_SCHEMA, + status: MigrateStatus::Migrated, + host: request.host, + dry_run: false, + migrated, + supervisors: rewrites, + collisions, + tombstones_repaired: tombstones.len(), + }) +} + +/// Build the combined pre-migration live-and-archived subject index. +fn plan( + this_host: &str, + specs: &[agent_spec::spec::AgentSpec], + archived: &[crate::catalog_archive::ArchivedDeclaration], +) -> Result> { + let mut index = Vec::with_capacity(specs.len() + archived.len()); + for spec in specs { + index.push(entry(Plane::Live, spec, this_host, None)?); + } + for declaration in archived { + index.push(entry( + Plane::Archived, + &declaration.spec, + &declaration.host, + declaration + .tombstone + .clone() + .map(|tombstone| (declaration.tombstone_path.clone(), tombstone)), + )?); + } + Ok(index) +} + +fn entry( + plane: Plane, + spec: &agent_spec::spec::AgentSpec, + this_host: &str, + tombstone: Option<(PathBuf, crate::catalog_archive::Tombstone)>, +) -> Result { + let prior_id = spec.agent_id(this_host); + // An already migrated subject keeps exactly its declared ID; only an unmigrated one is + // assigned below. + let migrated_id = match &spec.id { + Some(id) => id.clone(), + None => AgentId::parse(&prior_id).map_err(|error| { + MigrateRefusal::new( + "unmigratable-legacy-identity", + format!("{prior_id} cannot become an explicit agent ID: {error}"), + ) + })?, + }; + Ok(Indexed { + plane, + path: spec.path.clone(), + host: spec.resolved_host(this_host).to_owned(), + identity: spec.identity.clone(), + effective_address: spec.effective_address().to_owned(), + prior_id, + migrated_id, + assigned: false, + generated: false, + supervisor: spec.supervisor.clone(), + retired: spec.desired_state.is_retired(), + spec: spec.clone(), + tombstone, + }) +} + +/// Freeze live legacy bytes, then place archived subjects, generating an ID for a collision. +fn assign_ids(index: &mut [Indexed]) -> Result> { + let mut taken: BTreeSet = index + .iter() + .filter(|entry| entry.spec.id.is_some()) + .map(|entry| entry.migrated_id.as_str().to_owned()) + .collect(); + + // Live first, unconditionally: a live subject's runtime, task IDs, and declaration-anchored + // state already use these bytes, so they are not negotiable. + let mut order: Vec = (0..index.len()).collect(); + order.sort_by_key(|&position| { + ( + matches!(index[position].plane, Plane::Archived), + index[position].host.clone(), + index[position].identity.clone(), + index[position].path.clone(), + ) + }); + + let mut contested: BTreeMap> = BTreeMap::new(); + for position in order { + if index[position].spec.id.is_some() { + continue; + } + let legacy = index[position].legacy(); + if taken.insert(legacy.clone()) { + index[position].assigned = true; + continue; + } + if matches!(index[position].plane, Plane::Live) { + // Only an archived subject may be moved off its legacy bytes. A live subject's bytes + // are load-bearing runtime identity, so a live collision is a catalog to repair. + return Err(MigrateRefusal::new( + "identity-not-unique", + format!( + "live subject {legacy} cannot freeze its legacy bus identity: another subject already claims those bytes as its agent ID" + ), + )); + } + let generated = AgentId::generate().map_err(|error| { + MigrateRefusal::new( + "id-generation-failed", + format!("generate a replacement agent ID for archived {legacy}: {error}"), + ) + })?; + anyhow::ensure!( + taken.insert(generated.as_str().to_owned()), + "generated agent ID {generated} is already claimed" + ); + index[position].migrated_id = generated.clone(); + index[position].assigned = true; + index[position].generated = true; + contested.entry(legacy).or_default().push(generated); + } + + let mut collisions = Vec::new(); + for (legacy, reassigned) in contested { + let keeper = index + .iter() + .find(|entry| entry.migrated_id.as_str() == legacy) + .map(|entry| entry.migrated_id.clone()) + .with_context(|| { + format!("legacy identity {legacy} was contested but no subject kept it") + })?; + collisions.push(LegacyIdCollision { + legacy_bus_identity: legacy, + keeper, + reassigned, + }); + } + Ok(collisions) +} + +/// Every archived tombstone whose recorded ID disagrees with its own declaration. +/// +/// Two states produce a disagreement, and both need the same repair: a fresh archived collision +/// whose tombstone still carries the legacy bytes, and a pass interrupted after that subject's +/// declaration was written but before its tombstone was. Deriving the work from the observed +/// disagreement makes the second state converge instead of being skipped as "already migrated". +fn tombstone_repairs(index: &[Indexed]) -> Vec<(PathBuf, crate::catalog_archive::Tombstone)> { + let mut repairs = Vec::new(); + for entry in index { + let Some((path, tombstone)) = &entry.tombstone else { + continue; + }; + if tombstone.id == entry.migrated_id.as_str() { + continue; + } + let mut next = tombstone.clone(); + next.id = entry.migrated_id.as_str().to_owned(); + repairs.push((path.clone(), next)); + } + repairs +} + +/// Collision rows reconstructible from an observed declaration-vs-tombstone disagreement. +/// +/// The stale tombstone still names the bytes this subject lost, and the index names the subject +/// that kept them, so an interrupted pass can rebuild its own metadata rather than depending on +/// having reached the record write. +fn observed_collisions(index: &[Indexed]) -> Vec { + let mut rows = Vec::new(); + for entry in index { + let Some((_, tombstone)) = &entry.tombstone else { + continue; + }; + if tombstone.id == entry.migrated_id.as_str() { + continue; + } + let Some(keeper) = index + .iter() + .find(|other| other.migrated_id.as_str() == tombstone.id) + .map(|other| other.migrated_id.clone()) + else { + continue; + }; + rows.push(LegacyIdCollision { + legacy_bus_identity: tombstone.id.clone(), + keeper, + reassigned: vec![entry.migrated_id.clone()], + }); + } + rows +} + +/// Fold `additional` into `rows`, one row per contested legacy bus identity. +fn merge_collisions(rows: &mut Vec, additional: Vec) { + for row in additional { + match rows + .iter_mut() + .find(|existing| existing.legacy_bus_identity == row.legacy_bus_identity) + { + Some(existing) => { + for id in row.reassigned { + if !existing.reassigned.contains(&id) { + existing.reassigned.push(id); + } + } + } + None => rows.push(row), + } + } + rows.sort_by(|left, right| left.legacy_bus_identity.cmp(&right.legacy_bus_identity)); +} + +/// Drop the rows the durable record already explains, so a rerun reports and rewrites nothing. +fn pending_collisions( + rows: Vec, + record: &LegacyIdCollisions, +) -> Vec { + rows.into_iter() + .filter(|row| { + !record.entries.iter().any(|durable| { + durable.legacy_bus_identity == row.legacy_bus_identity + && durable.keeper == row.keeper + && row + .reassigned + .iter() + .all(|id| durable.reassigned.contains(id)) + }) + }) + .collect() +} + +/// Resolve every supervisor reference against the combined pre-migration index. +fn resolve_supervisors(index: &mut [Indexed]) -> Result> { + let snapshot = index.to_vec(); + let mut rewrites = Vec::new(); + for position in 0..index.len() { + let Some(reference) = index[position].supervisor.clone() else { + continue; + }; + let host = index[position].host.clone(); + let target = resolve_reference(&snapshot, &reference, &host)?; + if target.as_str() == reference { + continue; + } + rewrites.push(SupervisorRewrite { + plane: index[position].plane, + host, + identity: index[position].identity.clone(), + from: reference, + to: target, + }); + } + rewrites.sort_by(|left, right| { + (&left.host, &left.identity).cmp(&(&right.host, &right.identity)) + }); + Ok(rewrites) +} + +/// One pre-migration supervisor reference to exactly one parent's migrated ID. +/// +/// A reference names a pre-migration subject either by its exact prior ID or by the ordinary +/// pre-migration route (a bare address on the referring host, or a host-qualified bus address). +/// Zero or several matches refuse: migration may not guess which subject an ID-keyed durable edge +/// should point at. +fn resolve_reference(index: &[Indexed], reference: &str, host: &str) -> Result { + let mut by_id = index + .iter() + .filter(|entry| entry.prior_id == reference) + .collect::>(); + if by_id.is_empty() { + by_id = index + .iter() + .filter(|entry| { + (entry.host == host && entry.effective_address == reference) + || entry.bus_address() == reference + || (entry.host == host && entry.identity == reference) + || entry.legacy() == reference + }) + .collect::>(); + } + let mut distinct = BTreeSet::new(); + by_id.retain(|entry| distinct.insert(entry.migrated_id.clone())); + match by_id.as_slice() { + [only] => Ok(only.migrated_id.clone()), + [] => Err(MigrateRefusal::new( + LEGACY_SUPERVISOR_UNRESOLVED, + format!( + "supervisor '{reference}' on host '{host}' names no subject in the combined pre-migration live-and-archived index; unarchive and repair that declaration through the pre-activation legacy authoring path, then retry" + ), + )), + many => Err(MigrateRefusal::new( + LEGACY_SUPERVISOR_UNRESOLVED, + format!( + "supervisor '{reference}' on host '{host}' names {} subjects in the combined pre-migration live-and-archived index: {}", + many.len(), + many.iter() + .map(|entry| entry.migrated_id.to_string()) + .collect::>() + .join(", ") + ), + )), + } +} + +/// Prove the complete prospective catalog before the first byte is written. +fn validate_prospective(index: &[Indexed]) -> Result<()> { + let subjects = index + .iter() + .map(|entry| agent_spec::Subject { + id: entry.migrated_id.clone(), + host: entry.host.clone(), + effective_address: entry.effective_address.clone(), + // An archived subject released its address; a retired live one is non-routable too. + routable: matches!(entry.plane, Plane::Live) && !entry.retired, + }) + .collect::>(); + let conflicts = agent_spec::AddressBook::new(subjects).conflicts(); + if conflicts.is_empty() { + return Ok(()); + } + Err(MigrateRefusal::new( + "identity-not-unique", + format!( + "the migrated catalog would not be unique:\n{}", + conflicts + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ), + )) +} + +// ---- Source-preserving declaration edits ------------------------------------------------------- + +#[derive(Debug)] +struct DeclarationEdit { + path: PathBuf, + bytes: Vec, +} + +/// Author every declaration change, verifying each candidate before anything is written. +fn declaration_edits( + index: &[Indexed], + rewrites: &[SupervisorRewrite], +) -> Result> { + let mut supervisor_of: BTreeMap<(&str, &str), &AgentId> = BTreeMap::new(); + for rewrite in rewrites { + supervisor_of.insert((&rewrite.host, &rewrite.identity), &rewrite.to); + } + + let mut by_path: BTreeMap<&Path, Vec<&Indexed>> = BTreeMap::new(); + for entry in index { + let supervisor = supervisor_of + .get(&(entry.host.as_str(), entry.identity.as_str())) + .copied(); + if entry.assigned || supervisor.is_some() { + by_path.entry(entry.path.as_path()).or_default().push(entry); + } + } + + let mut edits = Vec::new(); + for (path, entries) in by_path { + if path.extension().and_then(|value| value.to_str()) != Some("kdl") { + return Err(MigrateRefusal::new( + "unsupported-declaration-format", + format!( + "{} is not a canonical KDL declaration; migrate it to KDL through the legacy authoring path first", + path.display() + ), + )); + } + let original = fs::read_to_string(path) + .with_context(|| format!("read declaration {}", path.display()))?; + let mut text = original.clone(); + for entry in entries { + let supervisor = supervisor_of + .get(&(entry.host.as_str(), entry.identity.as_str())) + .copied(); + // Each step is confined against its immediate predecessor, so a file declaring + // several agents accumulates one bounded edit per subject. + let previous = text.clone(); + text = edit_agent_node( + &text, + path, + &entry.host, + &entry.identity, + entry.assigned.then(|| entry.migrated_id.clone()).as_ref(), + supervisor, + )?; + verify_candidate(&previous, &text, path, entry, supervisor)?; + } + if text != original { + edits.push(DeclarationEdit { + path: path.to_path_buf(), + bytes: text.into_bytes(), + }); + } + } + Ok(edits) +} + +/// Insert `id` and rewrite `supervisor` inside exactly one `agent` node, preserving every other +/// byte of the file. +fn edit_agent_node( + text: &str, + path: &Path, + host: &str, + identity: &str, + id: Option<&AgentId>, + supervisor: Option<&AgentId>, +) -> Result { + let mut text = text.to_owned(); + if let Some(id) = id { + let document = parse(&text, path)?; + let target = agent_node(&document, path, host, identity)?; + refuse_nix(target, path, host, identity)?; + match child(target, "id") { + Some(node) => { + let declared = positional(node, "id", path)?; + anyhow::ensure!( + declared == id.as_str(), + "{} declares id {declared:?} for {}, expected {}", + path.display(), + legacy_bus_identity(host, identity), + id + ); + } + None => text = insert_child(&text, target, &format!("id {}", quoted(id.as_str())?))?, + } + } + if let Some(supervisor) = supervisor { + let document = parse(&text, path)?; + let target = agent_node(&document, path, host, identity)?; + refuse_nix(target, path, host, identity)?; + let node = child(target, "supervisor").with_context(|| { + format!( + "{} no longer declares a supervisor for {}", + path.display(), + legacy_bus_identity(host, identity) + ) + })?; + positional(node, "supervisor", path)?; + text = replace_positional(&text, node, supervisor.as_str(), path)?; + } + Ok(text) +} + +fn parse(text: &str, path: &Path) -> Result { + text.parse::() + .with_context(|| format!("parse canonical KDL declaration {}", path.display())) +} + +fn agent_node<'a>( + document: &'a KdlDocument, + path: &Path, + host: &str, + identity: &str, +) -> Result<&'a KdlNode> { + let matches = document + .nodes() + .iter() + .filter(|node| node.name().value() == "agent") + .filter(|node| { + let declared_identity = node + .get(0) + .and_then(|value| value.as_string()) + .or_else(|| child(node, "identity").and_then(|node| node.get(0)?.as_string())); + let declared_host = child(node, "host").and_then(|node| node.get(0)?.as_string()); + declared_identity.is_none_or(|value| value == identity) + && declared_host.is_none_or(|value| value == host) + }) + .collect::>(); + match matches.as_slice() { + [only] => Ok(*only), + [] => anyhow::bail!( + "{} no longer declares {}", + path.display(), + legacy_bus_identity(host, identity) + ), + many => anyhow::bail!( + "{} declares {} {} times; migration edits exactly one node", + path.display(), + legacy_bus_identity(host, identity), + many.len() + ), + } +} + +fn refuse_nix(node: &KdlNode, path: &Path, host: &str, identity: &str) -> Result<()> { + let nix_owned = node.children().is_some_and(|children| { + children + .nodes() + .iter() + .filter(|child| child.name().value() == "meta") + .filter_map(KdlNode::children) + .flat_map(|meta| meta.nodes()) + .filter(|child| child.name().value() == "managed-by") + .any(|child| child.get(0).and_then(|value| value.as_string()) == Some("nix")) + }); + if nix_owned { + return Err(MigrateRefusal::new( + "nix-owned-declaration", + format!( + "{} is Nix-owned ({}); migrate it at its source instead", + path.display(), + legacy_bus_identity(host, identity) + ), + )); + } + Ok(()) +} + +fn child<'a>(node: &'a KdlNode, name: &str) -> Option<&'a KdlNode> { + node.children()? + .nodes() + .iter() + .find(|child| child.name().value() == name) +} + +fn positional<'a>(node: &'a KdlNode, field: &str, path: &Path) -> Result<&'a str> { + anyhow::ensure!( + node.children().is_none() && node.entries().len() == 1 && node.entries()[0].name().is_none(), + "{}: `{field}` must contain exactly one positional string", + path.display() + ); + node.get(0) + .and_then(|value| value.as_string()) + .with_context(|| format!("{}: `{field}` must contain a string", path.display())) +} + +fn quoted(value: &str) -> Result { + serde_json::to_string(value).context("encode an identity value as canonical KDL") +} + +/// Replace exactly one node's single positional entry. The entry span carries neither leading +/// trivia nor the line terminator, so the surrounding line survives untouched. +fn replace_positional(text: &str, node: &KdlNode, value: &str, path: &Path) -> Result { + let span = node.entries()[0].span(); + let range = span.offset()..span.offset() + span.len(); + anyhow::ensure!( + text.get(range.clone()).is_some(), + "{}: declaration value span falls outside the source", + path.display() + ); + let mut replacement = text.to_owned(); + replacement.replace_range(range, "ed(value)?); + Ok(replacement) +} + +/// Insert one child node into `target`, preserving its existing block shape and indentation. +fn insert_child(text: &str, target: &KdlNode, authored: &str) -> Result { + let span = target.span(); + let start = span.offset(); + let end = start + span.len(); + let source = text + .get(start..end) + .context("agent span falls outside the declaration")?; + let mut replacement = text.to_owned(); + if target.children().is_none() { + replacement.insert_str(end, &format!(" {{ {authored} }}")); + return Ok(replacement); + } + anyhow::ensure!( + source.ends_with('}'), + "agent child block does not end at a source-preserving insertion point" + ); + let close = source.len() - 1; + if let Some(newline) = source[..close].rfind('\n') { + let closing_indent = &source[newline + 1..close]; + anyhow::ensure!( + closing_indent + .chars() + .all(|value| matches!(value, ' ' | '\t')), + "cannot preserve a non-whitespace closing-brace prefix" + ); + let child_indent = target + .children() + .and_then(|children| children.nodes().first()) + .and_then(|child| line_indent(text, child.span().offset())) + .unwrap_or_else(|| format!("{closing_indent} ")); + replacement.insert_str(start + newline + 1, &format!("{child_indent}{authored}\n")); + return Ok(replacement); + } + let before_close = &source[..close]; + let trimmed = before_close.trim_end(); + let insertion = if trimmed.ends_with('{') { + format!(" {authored}") + } else if trimmed.ends_with(';') { + format!(" {authored};") + } else { + format!("; {authored}") + }; + replacement.insert_str(start + trimmed.len(), &insertion); + Ok(replacement) +} + +fn line_indent(text: &str, offset: usize) -> Option { + let head = text.get(..offset)?; + let start = head.rfind('\n').map_or(0, |index| index + 1); + let indent = &head[start..]; + indent + .chars() + .all(|value| matches!(value, ' ' | '\t')) + .then(|| indent.to_owned()) +} + +/// Prove the candidate changed exactly the identity fields and nothing else. +/// +/// Two independent checks, because each catches what the other cannot. The line-level containment +/// proves the *source* was preserved: the only lines that may appear or disappear are the inserted +/// `id` and the rewritten `supervisor`, so every unknown field, comment, and blank line survives. +/// Re-lowering proves the *meaning*: the new bytes parse and resolve to the intended ID, address, +/// and supervisor for exactly this subject. +fn verify_candidate( + original: &str, + candidate: &str, + path: &Path, + entry: &Indexed, + supervisor: Option<&AgentId>, +) -> Result<()> { + let mut permitted_additions = vec![format!("id {}", quoted(entry.migrated_id.as_str())?)]; + if let Some(supervisor) = supervisor { + permitted_additions.push(format!("supervisor {}", quoted(supervisor.as_str())?)); + } + let before = original.lines().collect::>(); + let after = candidate.lines().collect::>(); + for line in after.iter().filter(|line| !before.contains(line)) { + anyhow::ensure!( + permitted_additions + .iter() + .any(|authored| line.trim() == authored), + "{}: the candidate added an unexpected line: {line}", + path.display() + ); + } + for line in before.iter().filter(|line| !after.contains(line)) { + anyhow::ensure!( + supervisor.is_some() && line.trim_start().starts_with("supervisor "), + "{}: the candidate removed a line it must preserve: {line}", + path.display() + ); + } + + let staging = tempfile::tempdir().context("create declaration verification staging root")?; + let directory = staging.path().join(&entry.host).join(&entry.identity); + fs::create_dir_all(&directory).context("create declaration verification directory")?; + let staged = directory.join("agent.kdl"); + fs::write(&staged, candidate).context("stage the candidate declaration")?; + let (specs, _) = agent_spec::discover_file(staging.path(), &staged) + .with_context(|| format!("re-parse the candidate for {}", path.display()))?; + let parsed = specs + .into_iter() + .find(|spec| { + spec.resolved_host(&entry.host) == entry.host && spec.identity == entry.identity + }) + .with_context(|| { + format!( + "the candidate for {} no longer declares {}", + path.display(), + legacy_bus_identity(&entry.host, &entry.identity) + ) + })?; + anyhow::ensure!( + parsed.id.as_ref() == Some(&entry.migrated_id), + "{}: the candidate does not lower to id {}", + path.display(), + entry.migrated_id + ); + anyhow::ensure!( + parsed.address == entry.spec.address && parsed.effective_address() == entry.effective_address, + "{}: the candidate changed the subject's address", + path.display() + ); + let expected_supervisor = supervisor + .map(|id| id.as_str().to_owned()) + .or_else(|| entry.supervisor.clone()); + anyhow::ensure!( + parsed.supervisor == expected_supervisor, + "{}: the candidate does not lower to supervisor {expected_supervisor:?}", + path.display() + ); + anyhow::ensure!( + parsed.desired_state == entry.spec.desired_state, + "{}: the candidate changed the subject's desired state", + path.display() + ); + Ok(()) +} + +// ---- Durable writes ---------------------------------------------------------------------------- + +fn write_collisions( + lock: &CatalogLock, + catalog: &Path, + control: &Path, + collisions: &[LegacyIdCollision], +) -> Result<()> { + let mut record = load_legacy_id_collisions(catalog)?; + for collision in collisions { + match record + .entries + .iter_mut() + .find(|entry| entry.legacy_bus_identity == collision.legacy_bus_identity) + { + Some(entry) => { + for id in &collision.reassigned { + if !entry.reassigned.contains(id) { + entry.reassigned.push(id.clone()); + } + } + entry.keeper = collision.keeper.clone(); + } + None => record.entries.push(collision.clone()), + } + } + record.schema = LEGACY_ID_COLLISIONS_SCHEMA.to_owned(); + record + .entries + .sort_by(|left, right| left.legacy_bus_identity.cmp(&right.legacy_bus_identity)); + let mut body = serde_json::to_vec_pretty(&record)?; + body.push(b'\n'); + write_control_file(lock, catalog, control, &legacy_id_collisions_path(catalog), &body) +} + +fn write_declaration( + lock: &CatalogLock, + catalog: &Path, + control: &Path, + path: &Path, + bytes: &[u8], +) -> Result<()> { + write_control_file(lock, catalog, control, path, bytes) +} + +fn write_tombstone( + lock: &CatalogLock, + catalog: &Path, + control: &Path, + path: &Path, + tombstone: &crate::catalog_archive::Tombstone, +) -> Result<()> { + let mut body = serde_json::to_vec_pretty(tombstone)?; + body.push(b'\n'); + write_control_file(lock, catalog, control, path, &body) +} + +/// Stage in the control plane, fsync, then atomically rename into place and fsync the parent. +fn write_control_file( + lock: &CatalogLock, + catalog: &Path, + control: &Path, + target: &Path, + bytes: &[u8], +) -> Result<()> { + let parent = target + .parent() + .with_context(|| format!("{} has no parent directory", target.display()))?; + let mode = fs::symlink_metadata(target) + .ok() + .filter(|metadata| metadata.file_type().is_file()) + .map_or(0o600, |metadata| { + std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777 + }); + let mut temporary = tempfile::Builder::new() + .prefix("catalog-migrate-") + .tempfile_in(control) + .with_context(|| format!("stage {}", target.display()))?; + temporary + .as_file_mut() + .set_permissions(std::os::unix::fs::PermissionsExt::from_mode(mode)) + .and_then(|()| temporary.write_all(bytes)) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("stage {}", target.display()))?; + crate::catalog_transaction::persist_tempfile_from_control( + lock.control(), + catalog, + temporary, + target, + ) + .with_context(|| format!("atomically publish {}", target.display()))?; + sync_dir(parent) +} + +/// Re-read the committed catalog and prove the transaction's whole intent landed. +fn verify(catalog: &Path, this_host: &str, index: &[Indexed]) -> Result<()> { + let found = crate::discover_strict(catalog); + anyhow::ensure!( + found.errors.is_empty(), + "the migrated catalog no longer discovers completely" + ); + let archived = crate::catalog_archive::archived_declarations(catalog)?; + // Keyed by plane: a re-created live seat and the archived subject it displaced legitimately + // share one `.` key and must not shadow each other here. + let mut committed: BTreeMap<(&'static str, String, String), String> = BTreeMap::new(); + for spec in &found.specs { + committed.insert( + ( + Plane::Live.as_str(), + spec.resolved_host(this_host).to_owned(), + spec.identity.clone(), + ), + spec.agent_id(this_host), + ); + } + for declaration in &archived { + committed.insert( + ( + Plane::Archived.as_str(), + declaration.host.clone(), + declaration.identity.clone(), + ), + declaration.spec.agent_id(&declaration.host), + ); + if let Some(tombstone) = + crate::catalog_archive::read_tombstone(&declaration.tombstone_path)? + { + anyhow::ensure!( + tombstone.id == declaration.spec.agent_id(&declaration.host), + "archived tombstone {} records id {}, but its declaration carries {}", + declaration.tombstone_path.display(), + tombstone.id, + declaration.spec.agent_id(&declaration.host) + ); + } + } + for entry in index { + let key = ( + entry.plane.as_str(), + entry.host.clone(), + entry.identity.clone(), + ); + let observed = committed.get(&key).with_context(|| { + format!( + "{} disappeared from the migrated {} plane", + legacy_bus_identity(&entry.host, &entry.identity), + entry.plane + ) + })?; + anyhow::ensure!( + observed == entry.migrated_id.as_str(), + "{} committed id {observed}, expected {}", + legacy_bus_identity(&entry.host, &entry.identity), + entry.migrated_id + ); + } + let subjects = archived + .iter() + .map(|declaration| { + let mut subject = declaration.spec.subject(&declaration.host)?; + subject.routable = false; + Ok(subject) + }) + .collect::>>()?; + crate::catalog_transaction::validate_identity_uniqueness(&found.specs, &subjects) + .context("the migrated catalog is not unique") +} + +fn relative(catalog: &Path, path: &Path) -> String { + path.strip_prefix(catalog) + .map(|value| value.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()) +} diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 3b8c6439..8d7c5aea 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -398,7 +398,10 @@ pub fn diff(request: DiffRequest) -> Result { let before = project(&retained_catalog, ProjectionSource::Current, &catalog)?; validate_projection_link_counts(&retained_catalog, &before, "live catalog")?; validate_live_workspace_facts(&catalog, &before.workspace_dirs)?; - validate_full_catalog(&retained_catalog).context("validate live catalog for diff")?; + // The structural archive belongs to the live catalog, so both sides of the comparison are + // proved unique against the same archived ID index. + let archived = crate::catalog_archive::archived_subjects(&retained_catalog)?; + validate_full_catalog(&retained_catalog, &archived).context("validate live catalog for diff")?; anyhow::ensure!( before.root_sha256 == request.expect_sha256, "catalog diff precondition failed: expected root sha256 {}, found {}", @@ -409,7 +412,7 @@ pub fn diff(request: DiffRequest) -> Result { let captured = tempfile::tempdir().context("create prepared diff capture root")?; capture_prepared_catalog(&prepared, captured.path())?; let after = project(captured.path(), ProjectionSource::Prepared, &catalog)?; - validate_full_catalog(captured.path()).context("validate prepared catalog for diff")?; + validate_full_catalog(captured.path(), &archived).context("validate prepared catalog for diff")?; let before_specs = canonical_semantic_specs(&retained_catalog)?; let after_specs = canonical_semantic_specs(captured.path())?; @@ -642,6 +645,20 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result Result Result { let admission = tempfile::tempdir().context("create prepared-catalog admission root")?; materialize_projection(&desired, admission.path())?; - validate_full_catalog(admission.path())?; + // Bootstrap publishes an absent catalog: there is no incumbent structural archive. + validate_full_catalog(admission.path(), &[])?; let desired_config = crate::catalog::load(admission.path())?; validate_external_pty_root( &catalog, @@ -1337,7 +1356,8 @@ pub fn bootstrap(request: BootstrapRequest) -> Result { desired.root_sha256, staged.root_sha256 ); - validate_full_catalog(&stage)?; + // A fresh bootstrap has no control plane yet, therefore no structural archive. + validate_full_catalog(&stage, &[])?; let lock = initialize_bootstrap_control(&stage)?; sync_tree_dirs(&stage)?; Ok(lock) @@ -1405,7 +1425,10 @@ fn inspect_existing_bootstrap( &catalog, &desired.workspace_dirs, )?; - validate_full_catalog(&retained_catalog)?; + validate_full_catalog( + &retained_catalog, + &crate::catalog_archive::archived_subjects(&retained_catalog)?, + )?; anyhow::ensure!( current.root_sha256 == desired.root_sha256, "catalog bootstrap target already exists with root sha256 {}, expected {}", @@ -1616,7 +1639,8 @@ pub fn apply(request: ApplyRequest) -> Result { // are mirrored as empty directories; their live content is never copied or hashed. let admission = tempfile::tempdir().context("create prepared-catalog admission root")?; materialize_projection(&desired, admission.path())?; - validate_full_catalog(admission.path())?; + let archived = crate::catalog_archive::archived_subjects(&catalog)?; + validate_full_catalog(admission.path(), &archived)?; let desired_config = crate::catalog::load(admission.path())?; validate_external_pty_root( &catalog, @@ -1728,7 +1752,7 @@ pub fn apply(request: ApplyRequest) -> Result { staged.root_sha256, verified.root_sha256 ); - validate_full_catalog(&catalog).context("validate applied live catalog")?; + validate_full_catalog(&catalog, &archived).context("validate applied live catalog")?; sync_dir(&catalog)?; generation.commit()?; test_checkpoint("before-clear"); @@ -1754,8 +1778,17 @@ pub fn apply(request: ApplyRequest) -> Result { }) } -/// Full structural and host-scoped validation for a complete prospective catalog. -pub(crate) fn validate_full_catalog(root: &Path) -> Result<()> { +/// Full structural, host-scoped, and identity-uniqueness validation for a complete prospective +/// catalog. +/// +/// `archived` carries the live catalog's structural archive as address-book subjects. IDs are +/// catalog-global across the live plane *and* the archive, so a prospective plane is only +/// admissible against both. A caller that legitimately has no archive to consider (a fresh +/// bootstrap) passes an empty slice. +pub(crate) fn validate_full_catalog( + root: &Path, + archived: &[agent_spec::Subject], +) -> Result<()> { let found = crate::discover(root); let mut hosts = BTreeSet::new(); for spec in &found.specs { @@ -1789,6 +1822,36 @@ pub(crate) fn validate_full_catalog(root: &Path) -> Result<()> { "catalog fails full validation:\n{}", errors.into_iter().collect::>().join("\n") ); + validate_identity_uniqueness(&found.specs, archived)?; + Ok(()) +} + +/// Enforce catalog-global agent-ID uniqueness and host-local effective-address uniqueness. +/// +/// The rule itself lives in [`agent_spec::AddressBook::conflicts`]; this only supplies the +/// complete prospective subject set. Live subjects are projected from the canonical declaration +/// plane — every one carries an explicit host, so the `this_host` fallback is never consulted — +/// and `archived` adds the structural archive, whose subjects are non-routable and therefore +/// occupy the ID namespace without occupying any host's address namespace. +pub(crate) fn validate_identity_uniqueness( + specs: &[agent_spec::AgentSpec], + archived: &[agent_spec::Subject], +) -> Result<()> { + let mut subjects = agent_spec::address_book(specs, "") + .context("project the prospective catalog into its address book")? + .subjects() + .to_vec(); + subjects.extend(archived.iter().cloned()); + let conflicts = agent_spec::AddressBook::new(subjects).conflicts(); + anyhow::ensure!( + conflicts.is_empty(), + "catalog fails identity uniqueness:\n{}", + conflicts + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); Ok(()) } diff --git a/src/claude_session.rs b/src/claude_session.rs index b87f4a2c..fa198997 100644 --- a/src/claude_session.rs +++ b/src/claude_session.rs @@ -111,7 +111,7 @@ fn development_channel_argv( catalog_root, "driver", "claude-mcp", - "--identity", + "--id", identity ] } @@ -722,7 +722,7 @@ mod tests { "/var/lib/st2/catalog", "driver", "claude-mcp", - "--identity", + "--id", "host.worker" ]) ); diff --git a/src/ding/mod.rs b/src/ding/mod.rs index fa055d26..4ad4a0d6 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -28,7 +28,7 @@ mod harness; use crate::message::{self, Message}; use crate::run::{CAPTURE_CAP_BYTES, read_bounded_tail, reap_detached}; use crate::status; -use crate::supervisor_chain::{SUPERVISOR_CHAIN_LIMIT, chain_bus_ids, resolve_spec}; +use crate::supervisor_chain::chain_agent_ids; use composer::{ComposerState, classify_composer, classify_located_composer, classify_receipt}; use harness::ReceiptState; @@ -99,19 +99,175 @@ fn normalize_field(value: Option<&str>, fallback: &str, max_chars: usize) -> Str struct RelationshipResolver { specs: Vec, + /// The current address book DING projects a sender's immutable ID through. Empty when the + /// catalog did not read coherently, which is one of the degradation cases in 01-ding. + book: crate::AddressBook, + /// Migration's durable collision metadata, read once per resolver. + collisions: crate::catalog_migrate::LegacyIdCollisions, valid: bool, } impl RelationshipResolver { - fn read(catalog_root: &Path) -> Self { - let discovered = crate::discover_strict(catalog_root); + /// Read declarations, the address book, and the collision metadata as ONE bundle. + /// + /// Migration publishes those three in stages, so an unfenced read can pair fresh declarations + /// with stale collision metadata and attribute a sender to the wrong subject. The fence is the + /// catalog generation sampled around the bundle, NOT the authoring lock: a shared acquisition + /// would create `/.st2` inside a catalog-less flat `ST_ROOT` — permanently converting + /// that bus into a catalog whose flat delivery then refuses — and it would queue the sidecar's + /// single-threaded loop behind any authoring writer, suspending presence refresh for the length + /// of an apply. `read_fence` neither initializes control state nor blocks, and it additionally + /// refuses while an apply or a generation publication is incomplete. + /// + /// A bundle that will not settle degrades to `valid = false`, which renders the immutable ID — + /// never a guessed address — and never blocks delivery. + fn read(catalog_root: &Path, this_host: &str) -> Self { + for _ in 0..3 { + let Ok(before) = crate::catalog_lock::read_fence(catalog_root) else { + continue; + }; + let discovered = crate::discover_strict(catalog_root); + let Ok(collisions) = crate::catalog_migrate::load_legacy_id_collisions(catalog_root) + else { + // An unreadable or foreign-schema collision record is not "no collisions": every + // legacy endpoint would look frozen. Degrade instead of attributing. + break; + }; + let Ok(after) = crate::catalog_lock::read_fence(catalog_root) else { + continue; + }; + if before != after { + continue; + } + let valid = discovered.errors.is_empty(); + // An incoherent book is indistinguishable from an absent one for display purposes: + // both degrade to the immutable ID rather than guessing an address. + let book = crate::spec::address_book(&discovered.specs, this_host).unwrap_or_default(); + return Self { + specs: discovered.specs, + book, + collisions, + valid, + }; + } Self { - specs: discovered.specs, - valid: discovered.errors.is_empty(), + specs: Vec::new(), + book: crate::AddressBook::default(), + collisions: crate::catalog_migrate::LegacyIdCollisions::default(), + valid: false, + } + } + + /// The declaration carrying exactly this immutable agent ID. + /// + /// Exact-only on purpose: an attributed sender is already an ID, and running it back through + /// an ID-or-address resolver would let a replacement subject that has since taken the released + /// address answer for the original sender. + fn spec_by_id<'a>(&'a self, agent_id: &str, this_host: &str) -> Option<&'a crate::AgentSpec> { + self.specs + .iter() + .find(|spec| spec.agent_id(this_host) == agent_id) + } +} + +/// How DING displays one message's sender (01-ding, "Sender projection"). +/// +/// The displayed sender is the current bus address only when resolving the immutable ID against a +/// coherent current address book succeeds. Everything else degrades to the ID, which is always +/// displayable. The publication-time snapshot is never shown alone as the current sender: a +/// released address is immediately reusable, so those bytes may already route elsewhere. +#[derive(Debug, Clone, PartialEq, Eq)] +enum SenderProjection { + /// A proved current bus address. + Current(String), + /// Lookup failed. The immutable ID, optionally with the publication-time snapshot marked as a + /// historical address. + Degraded { + id: String, + historical: Option, + }, + /// A typed non-Agent endpoint's canonical address, shown without any Agent lookup. + Typed(String), + /// Colliding version-1 legacy bytes this inbox row does not own. Rendered as a historical + /// address and never as the live subject that kept those bytes. + Historical(String), +} + +impl SenderProjection { + fn display(&self) -> String { + match self { + Self::Current(address) | Self::Typed(address) => address.clone(), + Self::Degraded { + id, + historical: None, + } => id.clone(), + Self::Degraded { + id, + historical: Some(address), + } => format!("{id} (was {address})"), + Self::Historical(address) => format!("{address} (historical address)"), } } } +/// Attribute one inbox row's sender endpoint. +/// +/// An inbox row's state owner is its RECIPIENT, so a colliding version-1 sender endpoint is never +/// the owner and is therefore unattributed — it carries no reply or automation authority and must +/// not be reattributed to the live subject that kept those legacy bytes (`MESSAGE-R04`). +fn attribute_sender( + msg: &Message, + recipient_agent_id: &str, + collisions: &impl message::LegacyCollisionIndex, +) -> Option { + let (version, value) = match msg.from_id.as_deref() { + Some(id) => (message::MESSAGE_RECORD_VERSION_2, id), + None => (message::MESSAGE_RECORD_VERSION_1, msg.from.as_deref()?), + }; + Some( + message::DurableEndpoint { + version, + value, + kind: None, + owns_row: false, + owner_agent_id: recipient_agent_id, + } + .attribute(collisions), + ) +} + +/// Project an attributed sender for display. Delivery never blocks on this lookup. +fn project_sender( + resolver: &RelationshipResolver, + attribution: Option<&message::EndpointAttribution>, + snapshot: Option<&str>, +) -> Option { + Some(match attribution? { + message::EndpointAttribution::AgentId(id) => { + match resolver + .valid + .then(|| resolver.book.resolve_id(id).ok()) + .flatten() + .and_then(crate::Subject::bus_address) + { + Some(address) => SenderProjection::Current(address), + None => SenderProjection::Degraded { + id: id.clone(), + historical: snapshot + .filter(|address| *address != id) + .map(str::to_owned), + }, + } + } + message::EndpointAttribution::TypedAddress { address, .. } => { + SenderProjection::Typed(address.clone()) + } + message::EndpointAttribution::UnattributedLegacyAddress(address) => { + SenderProjection::Historical(address.clone()) + } + }) +} + fn relationship_marker( resolver: &RelationshipResolver, this_host: &str, @@ -121,22 +277,23 @@ fn relationship_marker( if !resolver.valid { return "?".to_string(); } - let Some(sender) = claimed_sender.and_then(|id| resolve_spec(&resolver.specs, id, this_host)) + let Some(sender) = + claimed_sender.and_then(|id| resolver.spec_by_id(id, this_host)) else { return "?".to_string(); }; - let Some(recipient) = resolve_spec(&resolver.specs, recipient, this_host) else { + let Some(recipient) = resolver.spec_by_id(recipient, this_host) else { return "?".to_string(); }; - let sender_id = sender.bus_id(this_host); - let recipient_id = recipient.bus_id(this_host); + let sender_id = sender.agent_id(this_host); + let recipient_id = recipient.agent_id(this_host); if sender_id == recipient_id { return "↺".to_string(); } - let Ok(recipient_chain) = chain_bus_ids(&resolver.specs, recipient, this_host) else { + let Ok(recipient_chain) = chain_agent_ids(&resolver.specs, recipient, this_host) else { return "?".to_string(); }; - let Ok(sender_chain) = chain_bus_ids(&resolver.specs, sender, this_host) else { + let Ok(sender_chain) = chain_agent_ids(&resolver.specs, sender, this_host) else { return "?".to_string(); }; @@ -162,29 +319,44 @@ fn relationship_marker( /// The `[DING] …` line an agent sees for one newly arrived message. Consumers must key on the /// prefix and stable id rather than descriptive words. Subject and sender are bounded, normalized -/// untrusted fields. The marker describes the relationship implied by the claimed sender identity; +/// untrusted fields. The marker describes the relationship implied by the attributed sender ID; /// it does not authenticate that identity. +/// +/// `recipient` is the seat's exact immutable agent ID: it is the owner of this inbox row, which is +/// what decides whether a colliding legacy sender endpoint may be attributed at all. pub fn poke_text(catalog_root: &Path, this_host: &str, recipient: &str, msg: &Message) -> String { - poke_text_with_resolver( - &RelationshipResolver::read(catalog_root), - this_host, - recipient, - msg, - ) + let resolver = RelationshipResolver::read(catalog_root, this_host); + poke_text_with_resolver(&resolver, &resolver.collisions, this_host, recipient, msg) } fn poke_text_with_resolver( resolver: &RelationshipResolver, + collisions: &impl message::LegacyCollisionIndex, this_host: &str, recipient: &str, 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 attribution = attribute_sender(msg, recipient, collisions); + let projected = project_sender(resolver, attribution.as_ref(), msg.from.as_deref()); + let from = normalize_field( + projected.as_ref().map(SenderProjection::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()) + // Only an attributed sender may imply a relationship: an unattributed legacy endpoint + // carries no automation authority, so it stays "?". + relationship_marker( + resolver, + this_host, + recipient, + attribution + .as_ref() + .and_then(message::EndpointAttribution::agent_id), + ) }; format!( "[DING] {marker} {from}: {subject} [id:{}]", @@ -870,12 +1042,18 @@ impl PendingNotice { ) -> String { match self { Self::Recovery { .. } => RECOVERY_POKE.to_string(), - Self::Message { message, .. } => poke_text_with_resolver( - resolver.get_or_insert_with(|| RelationshipResolver::read(context.catalog_root)), - context.this_host, - context.recipient, - message, - ), + Self::Message { message, .. } => { + let resolver = resolver.get_or_insert_with(|| { + RelationshipResolver::read(context.catalog_root, context.this_host) + }); + poke_text_with_resolver( + resolver, + &resolver.collisions, + context.this_host, + context.recipient, + message, + ) + } Self::Adopted { staged_text: Some(text), } => text.clone(), @@ -1016,10 +1194,16 @@ pub fn run_ding( let mut seen = HashSet::new(); let backlog = new_arrivals(inbox_dir, &mut seen); let mut startup_candidates = (!backlog.is_empty()).then(|| { - let resolver = RelationshipResolver::read(context.catalog_root); + let resolver = RelationshipResolver::read(context.catalog_root, context.this_host); std::iter::once(RECOVERY_POKE.to_string()) .chain(backlog.iter().map(|message| { - poke_text_with_resolver(&resolver, context.this_host, context.recipient, message) + poke_text_with_resolver( + &resolver, + &resolver.collisions, + context.this_host, + context.recipient, + message, + ) })) .collect::>() }); @@ -1279,6 +1463,8 @@ fn drain(rx: &Receiver<()>) { mod tests { use super::*; use crate::message::{archive_dir, archive_msg, inbox_dir, send_to_inbox}; + // Exercised only by the supervisor-chain proofs below; shared code resolves exact IDs. + use crate::supervisor_chain::{SUPERVISOR_CHAIN_LIMIT, resolve_selector}; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; @@ -1290,6 +1476,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![], @@ -1317,6 +1504,271 @@ mod tests { .unwrap(); } + /// Declare a migrated subject: an explicit immutable ID plus a mutable address. + /// + /// A migrated declaration's `supervisor` value is its parent's immutable ID, which is the only + /// namespace a migrated child's edge is read in. + fn declare_migrated_agent(root: &Path, host: &str, identity: &str, id: &str, address: &str) { + declare_migrated_agent_supervised(root, host, identity, id, address, None); + } + + fn declare_migrated_agent_supervised( + root: &Path, + host: &str, + identity: &str, + id: &str, + address: &str, + supervisor: Option<&str>, + ) { + let directory = root.join(host).join(identity); + std::fs::create_dir_all(&directory).unwrap(); + let supervisor = supervisor + .map(|value| format!(" supervisor {value:?}\n")) + .unwrap_or_default(); + std::fs::write( + directory.join("agent.kdl"), + format!( + "agent {identity:?} {{\n identity {identity:?}\n id {id:?}\n address {address:?}\n host {host:?}\n{supervisor} type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" + ), + ) + .unwrap(); + } + + /// An agent message carrying a version-2 immutable sender ID plus its publication-time route. + fn agent_msg(filename: &str, from_id: &str, snapshot: &str, subject: &str) -> Message { + Message { + from_id: Some(from_id.to_owned()), + ..msg(filename, snapshot, Some(subject)) + } + } + + /// Every legacy endpoint in this index was reassigned at migration. + struct AllReassigned; + + impl message::LegacyCollisionIndex for AllReassigned { + fn is_reassigned(&self, _legacy_bus_identity: &str) -> bool { + true + } + } + + /// The displayed sender is the CURRENT address, not the saved one: an address change must be + /// visible immediately, because the snapshot may already route to a different subject. + #[test] + fn a_resolvable_sender_id_displays_its_current_bus_address() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", None); + declare_migrated_agent(catalog.path(), "h", "sender", "sender-uuid", "renamed"); + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.old-name", "hi"); + + assert_eq!( + poke_text(catalog.path(), "h", "h.recipient", &message), + "[DING] ? h.renamed: hi [id:abc123]" + ); + } + + /// `DING-R…`/01-ding sender projection: when the current-address lookup fails, degrade to the + /// immutable ID — which is always displayable — and mark the saved route as historical. The + /// snapshot is NEVER shown alone as the current sender. + #[test] + fn an_unresolvable_sender_id_degrades_to_the_id_and_marks_the_snapshot_historical() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", None); + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.old-name", "hi"); + + let rendered = poke_text(catalog.path(), "h", "h.recipient", &message); + assert_eq!(rendered, "[DING] ? sender-uuid (was h.old-name): hi [id:abc123]"); + assert!( + !rendered.contains("? h.old-name:"), + "a publication-time snapshot must never stand alone as the current sender: {rendered}" + ); + } + + /// DING also serves a catalog-less flat bus (an eval's `ST_ROOT`). Rendering a notice there + /// must not materialize `/.st2`: that control directory is what `catalogless()` keys on, + /// so creating it converts the bus into a catalog whose flat delivery then refuses every send. + #[test] + fn a_notice_on_a_catalogless_bus_creates_no_control_directory() { + let bus = tempfile::tempdir().unwrap(); + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.sender", "hi"); + + let rendered = poke_text(bus.path(), "h", "h.recipient", &message); + assert!( + rendered.contains("sender-uuid"), + "a catalog-less bus has no address book, so the sender degrades to its ID: {rendered}" + ); + assert!( + !bus.path().join(crate::catalog_lock::CONTROL_DIR).exists(), + "reading the relationship bundle must not initialize catalog control state" + ); + + // The property that matters: flat delivery still resolves after the render. + crate::message::send_to_resolved_inbox( + bus.path(), + "peer", + "h", + "requester", + Some("kick"), + None, + &[], + "body", + None, + None, + ) + .expect("a flat bus must still deliver after DING rendered a notice on it"); + assert!(bus.path().join("peer").join("inbox").is_dir()); + } + + /// The sidecar's flush loop is single-threaded and also drives presence refresh, so the + /// snapshot fence must never queue behind an authoring writer: a `catalog apply` would + /// otherwise make every live agent look presence-stale for the length of the write. + #[test] + fn a_notice_renders_while_an_exclusive_catalog_writer_holds_the_lock() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", None); + declare_migrated_agent(catalog.path(), "h", "sender", "sender-uuid", "renamed"); + let _writer = crate::CatalogLock::exclusive(catalog.path()).unwrap(); + + let root = catalog.path().to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel(); + let render = std::thread::spawn(move || { + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.old-name", "hi"); + let _ = tx.send(poke_text(&root, "h", "h.recipient", &message)); + }); + let rendered = rx + .recv_timeout(Duration::from_secs(20)) + .expect("a DING render must not block behind an exclusive catalog writer"); + render.join().unwrap(); + assert_eq!(rendered, "[DING] ? h.renamed: hi [id:abc123]"); + } + + /// An inbox row is owned by its RECIPIENT, so a colliding version-1 sender endpoint is not the + /// owner: it renders as a historical address and carries no automation authority, which is why + /// the relationship marker stays "?" even though the bytes now resolve to a live subject. + #[test] + fn a_colliding_legacy_sender_renders_as_a_historical_address_with_no_authority() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", Some("worker")); + declare_agent(catalog.path(), "h", "worker", None); + let resolver = RelationshipResolver::read(catalog.path(), "h"); + let message = msg("1785070000000-abc123.md", "h.worker", Some("stale")); + + // Without collision metadata the bytes are the frozen ID: an ordinary supervisor DING. + assert_eq!( + poke_text_with_resolver( + &resolver, + &message::NoLegacyCollisions, + "h", + "h.recipient", + &message + ), + "[DING] ↓ h.worker: stale [id:abc123]" + ); + + // Once those bytes are known to have been reassigned, this row cannot claim them. + assert_eq!( + poke_text_with_resolver(&resolver, &AllReassigned, "h", "h.recipient", &message), + "[DING] ? h.worker (historical address): stale [id:abc123]" + ); + } + + /// Display degradation is cosmetic. It must not change what the notice classifies against or + /// which subject owns the row, so the same message yields one stable notice per attempt. + #[test] + fn display_fallback_does_not_change_the_notice_used_for_receipt_classification() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", None); + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.old-name", "hi"); + let notice = PendingNotice::message(message.clone()); + + let direct = poke_text(catalog.path(), "h", "h.recipient", &message); + let staged = notice.text( + DingContext { + catalog_root: catalog.path(), + this_host: "h", + recipient: "h.recipient", + }, + &mut None, + ); + assert_eq!(direct, staged); + } + + /// An attributed sender is already an immutable ID, so binding it must be exact. + /// + /// Here the sender released `worker` and a DIFFERENT subject took that address. Resolving the + /// attributed ID through an ID-or-address resolver would bind the replacement and render the + /// replacement's relationship to the recipient — durable DING output describing the wrong + /// subject entirely. + #[test] + fn an_attributed_sender_id_never_binds_the_subject_that_reused_its_old_address() { + let catalog = tempfile::tempdir().unwrap(); + // A supervisor edge is ownership, so it is declared by immutable ID — which only a + // MIGRATED child may do: the recipient carries its frozen legacy ID explicitly. + declare_migrated_agent_supervised( + catalog.path(), + "h", + "recipient", + "h.recipient", + "recipient", + Some("replacement-uuid"), + ); + // The original sender kept its ID and moved to a new address. + declare_migrated_agent(catalog.path(), "h", "origin", "origin-uuid", "moved"); + // A different subject now answers to `worker`, and it supervises the recipient. + declare_migrated_agent( + catalog.path(), + "h", + "replacement", + "replacement-uuid", + "worker", + ); + let resolver = RelationshipResolver::read(catalog.path(), "h"); + + // The recipient's supervisor is `replacement`, so a sender bound to it would render "↓". + assert_eq!( + relationship_marker(&resolver, "h", "h.recipient", Some("replacement-uuid")), + "↓" + ); + // The real sender is `origin`, which has no supervisor relationship to the recipient. + assert_eq!( + relationship_marker(&resolver, "h", "h.recipient", Some("origin-uuid")), + "?" + ); + // And a stale ROUTE is not an ID: it binds nothing at all. + assert_eq!( + relationship_marker(&resolver, "h", "h.recipient", Some("worker")), + "?", + "an address must never be accepted where an immutable ID is required" + ); + } + + /// The declaration bundle DING reads is one fenced snapshot, so a torn read degrades to the + /// immutable ID rather than pairing fresh declarations with stale collision metadata. + #[test] + fn an_unreadable_catalog_bundle_degrades_to_the_immutable_id() { + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "h", "recipient", None); + std::fs::create_dir_all(catalog.path().join("h/broken")).unwrap(); + std::fs::write( + catalog.path().join("h/broken/agent.kdl"), + "agent \"broken\" { this is not kdl", + ) + .unwrap(); + + let resolver = RelationshipResolver::read(catalog.path(), "h"); + assert!(!resolver.valid, "a malformed declaration is not a coherent catalog"); + let message = agent_msg("1785070000000-abc123.md", "sender-uuid", "h.old-name", "hi"); + assert_eq!( + poke_text_with_resolver( + &resolver, + &message::NoLegacyCollisions, + "h", + "h.recipient", + &message + ), + "[DING] ? sender-uuid (was h.old-name): hi [id:abc123]" + ); + } + fn render_without_catalog(message: &Message) -> String { let catalog = tempfile::tempdir().unwrap(); poke_text(catalog.path(), "h", "h.recipient", message) @@ -1556,11 +2008,16 @@ mod tests { declare_agent(catalog.path(), "h", "loop", Some("recipient")); let message = msg("1785070000000-abc123.md", "h.loop", Some("cycle")); let expected = "[DING] ? h.loop: cycle [id:abc123]"; - let resolver = RelationshipResolver::read(catalog.path()); - let recipient = resolve_spec(&resolver.specs, "h.recipient", "h").unwrap(); + let resolver = RelationshipResolver::read(catalog.path(), "h"); + let recipient = resolve_selector( + &resolver.specs, + &crate::AgentSelector::address("h.recipient"), + "h", + ) + .unwrap(); assert_eq!( - chain_bus_ids(&resolver.specs, recipient, "h"), + chain_agent_ids(&resolver.specs, recipient, "h"), Err(crate::supervisor_chain::SupervisorChainError::Cycle), "cycle detection must be distinct from the independent depth limit" ); @@ -3250,7 +3707,7 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"; crate::event::emit( root, "hetz", - "hetz.worker", + &crate::AgentSelector::address("hetz.worker"), "gh-ci", event_id, Some("pr-42"), diff --git a/src/driver.rs b/src/driver.rs index b4fa9be7..29585ed8 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -165,19 +165,21 @@ pub fn expand_driver(spec: &AgentSpec, this_host: &str) -> Result { "agent '{}' has no host and driver expansion received no host fallback", spec.identity ); - let bus_id = spec.bus_id(this_host); + // Generated wrapper arguments carry the immutable agent ID as an exact-ID selector. They are + // never handed to an address parser: a driver expansion is automation, not a human route. + let agent_id = spec.agent_id(this_host); let mut output = match driver { - Driver::Claude(driver) => expand_claude(driver, &bus_id)?, - Driver::Codex(driver) => expand_codex(driver, &bus_id), - Driver::Pi(driver) => expand_pi(driver, &bus_id), - Driver::OpenCode(driver) => expand_opencode(driver, &bus_id), - Driver::Omp(driver) => expand_omp(driver, &bus_id), + Driver::Claude(driver) => expand_claude(driver, &agent_id)?, + Driver::Codex(driver) => expand_codex(driver, &agent_id), + Driver::Pi(driver) => expand_pi(driver, &agent_id), + Driver::OpenCode(driver) => expand_opencode(driver, &agent_id), + Driver::Omp(driver) => expand_omp(driver, &agent_id), }; output.autoformat(); Ok(output) } -fn expand_codex(driver: &CodexDriver, bus_id: &str) -> KdlDocument { +fn expand_codex(driver: &CodexDriver, agent_id: &str) -> KdlDocument { let mut provider = vec!["codex".to_string()]; if let Some(model) = &driver.model { provider.extend(["--model".to_string(), model.clone()]); @@ -194,10 +196,10 @@ fn expand_codex(driver: &CodexDriver, bus_id: &str) -> KdlDocument { CATALOG.to_string(), "driver".to_string(), "codex".to_string(), - "--identity".to_string(), - bus_id.to_string(), + "--id".to_string(), + agent_id.to_string(), "--runtime-id".to_string(), - bus_id.to_string(), + agent_id.to_string(), "--".to_string(), ]; argv.extend(provider); @@ -208,7 +210,7 @@ fn expand_codex(driver: &CodexDriver, bus_id: &str) -> KdlDocument { /// that extension from this binary's verified hook set rather than writing a machine-local path /// into the declaration. `-a` accepts the workspace for this run only, which is why no pi analogue /// of [`crate::pretrust`] exists — nothing in the operator's ambient pi config is mutated. -fn expand_pi(driver: &PiDriver, bus_id: &str) -> KdlDocument { +fn expand_pi(driver: &PiDriver, agent_id: &str) -> KdlDocument { let mut provider = vec!["pi".to_string(), "-a".to_string()]; if let Some(model) = &driver.model { provider.extend(["--model".to_string(), model.clone()]); @@ -225,10 +227,10 @@ fn expand_pi(driver: &PiDriver, bus_id: &str) -> KdlDocument { CATALOG.to_string(), "driver".to_string(), "pi-session".to_string(), - "--identity".to_string(), - bus_id.to_string(), + "--id".to_string(), + agent_id.to_string(), "--runtime-id".to_string(), - bus_id.to_string(), + agent_id.to_string(), "--".to_string(), ]; argv.extend(provider); @@ -238,7 +240,7 @@ fn expand_pi(driver: &PiDriver, bus_id: &str) -> KdlDocument { /// omp needs no rendered configuration file either: its channel is a pi-style extension the /// wrapper injects from this binary's verified hook set. omp has no pi `-a` equivalent it needs /// for a workspace launch — the wrapper already runs with the workspace as cwd. -fn expand_omp(driver: &OmpDriver, bus_id: &str) -> KdlDocument { +fn expand_omp(driver: &OmpDriver, agent_id: &str) -> KdlDocument { let mut provider = vec!["omp".to_string()]; if let Some(model) = &driver.model { provider.extend(["--model".to_string(), model.clone()]); @@ -255,10 +257,10 @@ fn expand_omp(driver: &OmpDriver, bus_id: &str) -> KdlDocument { CATALOG.to_string(), "driver".to_string(), "omp-session".to_string(), - "--identity".to_string(), - bus_id.to_string(), + "--id".to_string(), + agent_id.to_string(), "--runtime-id".to_string(), - bus_id.to_string(), + agent_id.to_string(), "--".to_string(), ]; argv.extend(provider); @@ -267,7 +269,7 @@ fn expand_omp(driver: &OmpDriver, bus_id: &str) -> KdlDocument { /// OpenCode's server surface is wrapper-owned runtime state: the wrapper allocates the port and /// password at launch, so the expansion stays a pure declaration with no machine-local values. -fn expand_opencode(driver: &OpenCodeDriver, bus_id: &str) -> KdlDocument { +fn expand_opencode(driver: &OpenCodeDriver, agent_id: &str) -> KdlDocument { let mut provider = vec!["opencode".to_string()]; if let Some(model) = &driver.model { provider.extend(["--model".to_string(), model.clone()]); @@ -283,17 +285,17 @@ fn expand_opencode(driver: &OpenCodeDriver, bus_id: &str) -> KdlDocument { CATALOG.to_string(), "driver".to_string(), "opencode-session".to_string(), - "--identity".to_string(), - bus_id.to_string(), + "--id".to_string(), + agent_id.to_string(), "--runtime-id".to_string(), - bus_id.to_string(), + agent_id.to_string(), "--".to_string(), ]; argv.extend(provider); document([node("argv", argv)]) } -fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { +fn expand_claude(driver: &ClaudeDriver, agent_id: &str) -> Result { // The same registration a hand-authored seat carries: without it a driver-declared // seat has no observed-state producer and no lifecycle hooks at all. let settings = serde_json::to_string_pretty(&crate::hooks::claude_settings_registration())?; @@ -333,10 +335,10 @@ fn expand_claude(driver: &ClaudeDriver, bus_id: &str) -> Result { CATALOG.to_string(), "driver".to_string(), "claude-session".to_string(), - "--identity".to_string(), - bus_id.to_string(), + "--id".to_string(), + agent_id.to_string(), "--runtime-id".to_string(), - bus_id.to_string(), + agent_id.to_string(), "--".to_string(), ]; argv.extend(provider); @@ -367,6 +369,8 @@ mod tests { fn spec(driver: Driver) -> AgentSpec { AgentSpec { + id: None, + address: None, identity: "worker".into(), name: None, description: None, @@ -497,7 +501,7 @@ mod tests { "$CATALOG", "driver", "codex", - "--identity", + "--id", "host.worker", "--runtime-id", "host.worker", @@ -514,6 +518,39 @@ mod tests { ); } + /// A driver expansion is automation: it must select the immutable ID, so a later address + /// change cannot repoint a generated wrapper at a different subject. + #[test] + fn driver_expansion_selects_the_immutable_id_and_never_the_mutable_address() { + let mut declared = spec(Driver::Codex(CodexDriver { + model: None, + effort: None, + prompt: "Start work.".into(), + args: Vec::new(), + })); + declared.id = + Some(agent_spec::AgentId::parse("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1").unwrap()); + declared.address = Some(agent_spec::AgentAddress::parse("keymap.verifier").unwrap()); + + let output = expand_driver(&declared, "unused").unwrap(); + let argv = strings(output.get("argv").unwrap()); + + let id = argv.iter().position(|arg| *arg == "--id").unwrap(); + assert_eq!(argv[id + 1], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + let runtime = argv.iter().position(|arg| *arg == "--runtime-id").unwrap(); + assert_eq!(argv[runtime + 1], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + assert!( + !argv.iter().any(|arg| *arg == "--identity"), + "an exact-ID selector must not be spelled as the address-parsing flag" + ); + assert!( + !argv + .iter() + .any(|arg| arg.contains("keymap.verifier") || arg.contains("host.worker")), + "no generated argument may carry a human route: {argv:?}" + ); + } + #[test] fn opencode_expands_to_the_session_wrapper_with_a_prompt_flag() { let output = expand_driver( @@ -535,7 +572,7 @@ mod tests { "$CATALOG", "driver", "opencode-session", - "--identity", + "--id", "host.worker", "--runtime-id", "host.worker", @@ -587,7 +624,7 @@ mod tests { "$CATALOG", "driver", "claude-session", - "--identity", + "--id", "host.worker", "--runtime-id", "host.worker", diff --git a/src/eval_run.rs b/src/eval_run.rs index 8384e2cf..6e3338f7 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -112,7 +112,12 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec }); } AgentSpec { + // A compact eval agent is an unmigrated positional declaration: no explicit ID and + // no explicit address, so its agent ID is exactly `.` — the same + // bytes `actor_id` above already assigned to its tasks. + id: None, identity, + address: None, name: a.name.clone(), description: a.description.clone(), host: Some(host.to_string()), @@ -171,7 +176,7 @@ fn admitted_route<'a>( fn task_runtime_id(spec: &AgentSpec, task: &Task, host: &str) -> String { task.id .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id(host), task.name)) + .unwrap_or_else(|| format!("{}.{}", spec.agent_id(host), task.name)) } fn task_is_launchable(task: &Task) -> bool { @@ -187,7 +192,7 @@ fn eval_runtime_tasks(specs: &[AgentSpec], host: &str) -> Vec { .iter() .filter(|task| task_is_launchable(task)) .map(|task| EvalRuntimeTask { - agent_id: spec.bus_id(host), + agent_id: spec.agent_id(host), runtime_id: task_runtime_id(spec, task, host), is_pty: task.kind == TaskKind::Pty, }) @@ -255,17 +260,30 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result>(); + if !conflicts.is_empty() { + anyhow::bail!( + "canonical-agents refuses a non-unique Agent Spec catalog: {}", + conflicts.join("; ") + ); + } + let mut runtime_ids = BTreeMap::::new(); let mut routes = BTreeMap::new(); for spec in &local_specs { - let bus_id = spec.bus_id(host); - if !bus_ids.insert(bus_id.clone()) { - anyhow::bail!("canonical-agents found duplicate Agent Spec bus identity `{bus_id}`"); - } + // Runtime task ids and route ownership are the agent ID. + let agent_id = spec.agent_id(host); if !spec.desired_state.is_running() { anyhow::bail!( - "canonical-agents refuses non-running Agent Spec `{bus_id}` ({})", + "canonical-agents refuses non-running Agent Spec `{agent_id}` ({})", spec.desired_state.as_str() ); } @@ -273,7 +291,7 @@ fn load_canonical_eval_team(catalog: &Path, host: &str) -> Result Result Result { entry.insert(route.clone()); } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().inbox == route.inbox => {} std::collections::btree_map::Entry::Occupied(_) => { anyhow::bail!( "canonical-agents found duplicate canonical route spelling `{spelling}`" @@ -593,6 +621,20 @@ fn from_is(from: Option<&str>, id: &str) -> bool { from.is_some_and(|f| f == id || f.ends_with(&format!(".{id}"))) } +/// Whether `message` was written by the participant whose immutable agent ID is `id`. +/// +/// The eval's participant vector and its kickoff target are agent IDs, and `from-id:` is the +/// authoritative sender field of a version-2 record. `from:` is the sender's mutable bus address, +/// which for any subject whose ID differs from its address is neither equal to nor a dotted suffix +/// of the ID — matching on it would burn the full `max_timeout` on a team that actually finished. +/// A record predating `from-id:` still falls back to the route. +fn sender_is(message: &crate::message::Message, id: &str) -> bool { + match message.from_id.as_deref() { + Some(from_id) => from_id == id, + None => from_is(message.from.as_deref(), id), + } +} + /// Wait for the DONE signal, message-driven (not grade-poll). Multi-agent teams require a /// `sup → requester` confirmation whose timestamp follows a `worker → sup` report. A canonical /// singleton instead requires a causally new requester-inbox entry at-or-after the exact kickoff @@ -641,7 +683,7 @@ fn wait_done( .iter() .any(|m| { !before.contains(&m.filename) - && from_is(m.from.as_deref(), sup) + && sender_is(m, sup) && m.ts_ms >= kickoff_ts }); if confirmed { @@ -651,7 +693,7 @@ fn wait_done( let report_ts = sup_msgs .iter() .chain(sup_archived.iter()) - .filter(|m| workers.iter().any(|w| from_is(m.from.as_deref(), w))) + .filter(|m| workers.iter().any(|w| sender_is(m, w))) .map(|m| m.ts_ms) .min(); if let Some(rt) = report_ts { @@ -659,7 +701,7 @@ fn wait_done( let confirmed = crate::message::list_dir(&req_inbox) .unwrap_or_default() .iter() - .any(|m| from_is(m.from.as_deref(), sup) && m.ts_ms >= rt); + .any(|m| sender_is(m, sup) && m.ts_ms >= rt); if confirmed { return true; } @@ -1072,25 +1114,38 @@ fn env_key(id: &str) -> String { .collect() } -/// The supervisor chain of `agent_id`, walked transitively via each agent's `supervisor` field to the -/// root (whose supervisor is `None` — the cos). Returns the ancestor ids, nearest first. A cycle or a -/// supervisor that names no declared agent terminates the walk (the named id is still included — we ding -/// its inbox regardless of whether it has a running task). +/// The supervisor chain of the agent whose ID is `agent_id`, walked transitively via each agent's +/// `supervisor` field to the root (whose supervisor is `None` — the cos). Returns the ancestors' +/// immutable agent IDs, nearest first. A cycle terminates the walk; a supervisor that names no +/// declared agent terminates it with the unresolvable reference included, because we ding its +/// inbox regardless of whether it has a running task and that reference is the only identity it +/// has. Every resolvable hop carries the resolved ID, never the declared reference. fn supervisor_chain(agent_id: &str, specs: &[AgentSpec], host: &str) -> Vec { let mut chain = Vec::new(); let mut seen = std::collections::HashSet::new(); - let find = |identity: &str| { - specs - .iter() - .find(|spec| spec.identity == identity || spec.bus_id(host) == identity) - }; - let mut current = find(agent_id).and_then(|s| s.supervisor.clone()); - while let Some(sup) = current { - if !seen.insert(sup.clone()) { - break; // cycle guard + // The walk carries the CHILD spec, not a bare reference: each edge is resolved in the + // namespace that child's own migration state declares (`supervisor_chain::supervisor_selector`). + let mut current = specs.iter().find(|spec| spec.agent_id(host) == agent_id); + while let Some(child) = current { + let Some(reference) = child.supervisor.as_deref() else { + break; + }; + match crate::supervisor_chain::resolve_supervisor_spec(specs, child, host) { + Some(parent) => { + let id = parent.agent_id(host); + if !seen.insert(id.clone()) { + break; // cycle guard, keyed by subject rather than by spelling + } + chain.push(id); + current = Some(parent); + } + None => { + if seen.insert(reference.to_owned()) { + chain.push(reference.to_owned()); + } + break; + } } - chain.push(sup.clone()); - current = find(&sup).and_then(|s| s.supervisor.clone()); } chain } @@ -1176,14 +1231,14 @@ fn run_eval_inner( let participants = team .specs .iter() - .map(|spec| spec.bus_id(host)) + .map(|spec| spec.agent_id(host)) .collect::>(); (team.specs, participants, Some(team.routes)) } else { let specs = spec_to_agent_specs(&compact_agents, host, catalog); let participants = specs .iter() - .map(|spec| spec.bus_id(host)) + .map(|spec| spec.agent_id(host)) .collect::>(); (specs, participants, None) }; @@ -1216,24 +1271,24 @@ fn run_eval_inner( let msg = eval.message.as_ref().ok_or_else(|| { anyhow::anyhow!("a team eval needs a message{{}} kickoff before any agent can launch") })?; - let matches = specs - .iter() - .filter(|agent| agent.identity == msg.to || agent.bus_id(host) == msg.to) - .map(|agent| agent.bus_id(host)) - .collect::>(); - let [sup] = matches.as_slice() else { - let authority = if eval.canonical_agents { - "canonical-agents" - } else { - "compact" - }; - anyhow::bail!( - "{authority} kickoff target `{}` must resolve to exactly one Agent Spec, found {}", - msg.to, - matches.len() - ); + // The kickoff target is an ordinary human reference written in the eval spec, so it goes + // through the address algorithm — and what travels onward is the resolved agent ID, never + // the reference. Absence and ambiguity carry their own address-specific diagnostics. + let book = crate::spec::address_book(&specs, host)?; + let sup = match book.resolve_address(&msg.to, Some(host)) { + Ok(subject) => subject.id.as_str().to_owned(), + Err(error) => { + let authority = if eval.canonical_agents { + "canonical-agents" + } else { + "compact" + }; + anyhow::bail!( + "{authority} kickoff target `{}` must resolve to exactly one Agent Spec: {error}", + msg.to + ); + } }; - let sup = sup.clone(); if let Some(routes) = canonical_routes.as_ref() { if routes.contains_key(&requester) { @@ -1686,6 +1741,95 @@ mod tests { std::fs::write(path, body).unwrap(); } + /// The participant vector and the kickoff target are immutable agent IDs, so the done-detector + /// must read `from-id:`. A UUID ID is neither equal to nor a dotted suffix of the sender's bus + /// address, so matching on `from:` burns the full `max_timeout` on a team that finished. + #[test] + fn done_detection_matches_a_uuid_sender_by_its_immutable_id() { + const SUP_ID: &str = "0199c0de-0000-7000-8000-0000000000a1"; + const WORKER_ID: &str = "0199c0de-0000-7000-8000-0000000000b2"; + let bus = tempfile::tempdir().unwrap(); + let sup_inbox = bus.path().join(SUP_ID).join("inbox"); + let req_inbox = bus.path().join("requester").join("inbox"); + + // worker → sup report, then the sup → requester confirmation that post-dates it. Both + // carry a bus address in `from:` that shares no bytes with the sender's ID. + crate::message::send_to_inbox_from_agent( + &sup_inbox, + "h.worker", + Some(WORKER_ID), + Some("report"), + None, + &[], + "done", + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2)); + crate::message::send_to_inbox_from_agent( + &req_inbox, + "h.sup", + Some(SUP_ID), + Some("confirmation"), + None, + &[], + "done", + ) + .unwrap(); + + assert!( + wait_done( + bus.path(), + None, + SUP_ID, + "requester", + &[WORKER_ID.to_owned()], + None, + None, + Duration::from_secs(2), + &mut || {}, + ), + "a report and confirmation attributed by from-id must fire done" + ); + } + + /// A record predating `from-id:` still attributes by its route, so unmigrated fixtures and + /// eval-owned flat senders keep working. + #[test] + fn done_detection_falls_back_to_the_route_for_a_version_1_record() { + let bus = tempfile::tempdir().unwrap(); + crate::message::send_to_inbox( + &bus.path().join("sup").join("inbox"), + "h.worker", + Some("report"), + None, + &[], + "done", + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2)); + crate::message::send_to_inbox( + &bus.path().join("requester").join("inbox"), + "h.sup", + Some("confirmation"), + None, + &[], + "done", + ) + .unwrap(); + + assert!(wait_done( + bus.path(), + None, + "sup", + "requester", + &["worker".to_owned()], + None, + None, + Duration::from_secs(2), + &mut || {}, + )); + } + #[test] fn canonical_eval_team_uses_exact_catalog_declarations_and_runtime_ids() { let catalog = tempfile::tempdir().unwrap(); @@ -1800,7 +1944,7 @@ mod tests { assert_eq!( team.specs .iter() - .map(|spec| spec.bus_id("evalhost")) + .map(|spec| spec.agent_id("evalhost")) .collect::>(), ["evalhost.local"] ); @@ -2315,7 +2459,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" } assert_eq!(sup.tasks[1].id.as_deref(), Some("evalhost.mix.sup.ding")); assert_eq!( sup.tasks[1].command.as_deref(), - Some("st2 ding --identity evalhost.mix.sup --root $ST_ROOT") + Some("st2 ding --id evalhost.mix.sup --root $ST_ROOT") ); let plan = reconcile(&specs, &[], "evalhost").unwrap(); @@ -2461,7 +2605,8 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" } [ exact.display().to_string(), "ding".into(), - "--identity".into(), + // The derived companion addresses its agent by exact ID, never by a route. + "--id".into(), "host.worker".into(), "--root".into(), root.join("bus root").display().to_string(), @@ -2488,7 +2633,7 @@ agent "worker" { identity "worker"; host "evalhost"; argv "true" } assert_eq!( std::fs::read_to_string(&exact_receipt).unwrap(), format!( - "ding\n--identity\nhost.worker\n--root\n{}\n", + "ding\n--id\nhost.worker\n--root\n{}\n", root.join("bus root").display() ) ); diff --git a/src/eval_spec.rs b/src/eval_spec.rs index 1c589d92..d491004b 100644 --- a/src/eval_spec.rs +++ b/src/eval_spec.rs @@ -368,7 +368,7 @@ pub fn ding_exec(agent_id: &str) -> SpecExec { id: format!("{agent_id}.ding"), // Identity-only: `st2 ding` defaults its poke target to `--identity` (an agent IS its pty), so // the redundant positional is dropped. - command: format!("st2 ding --identity {agent_id} --root $ST_ROOT"), + command: format!("st2 ding --id {agent_id} --root $ST_ROOT"), env: BTreeMap::new(), derived: true, } @@ -852,7 +852,7 @@ eval { assert_eq!(sup.execs[0].id, "mix.sup.ding"); assert_eq!( sup.execs[0].command, - "st2 ding --identity mix.sup --root $ST_ROOT" + "st2 ding --id mix.sup --root $ST_ROOT" ); assert!(!sup.execs[0].env.contains_key("ST_AGENT")); diff --git a/src/event.rs b/src/event.rs index 035b90cf..4bb34249 100644 --- a/src/event.rs +++ b/src/event.rs @@ -90,7 +90,10 @@ pub enum EventReceiptStatus { Deduplicated, } +#[derive(Debug)] struct ResolvedStream { + /// The recipient's immutable agent ID. Stream state, the derived companion runtime ID, and + /// event provenance are all ownership, so all three key on this and never on a route. recipient: String, } @@ -302,7 +305,7 @@ pub(crate) fn refusal_kind(error: &anyhow::Error) -> Option { fn resolve_stream( root: &Path, this_host: &str, - recipient: &str, + recipient: &crate::AgentSelector, stream: &str, admission: StreamAdmission, ) -> anyhow::Result { @@ -317,42 +320,55 @@ fn resolve_stream( .collect::>() .join("; ") ); - let mut matches = discovered + let input = recipient.as_input(); + let book = crate::spec::address_book(&discovered.specs, this_host)?; + let subject = match book.resolve(recipient) { + Ok(subject) => subject, + // Both ambiguities are decided by the address book, which names every candidate. Neither + // can become admissible by retrying: a duplicated catalog-global ID is a broken catalog, + // and an ambiguous route is a reference that names more than one subject. Publishing into + // whichever one sorted first would silently pick a recipient. + Err( + error @ (crate::ResolveError::AmbiguousAddress { .. } + | crate::ResolveError::AmbiguousId { .. }), + ) => { + return Err(StreamRefusal::new( + RefusalKind::Permanent, + format!("agent recipient '{input}' is ambiguous: {error}"), + )); + } + Err(_) => anyhow::bail!("no agent '{input}' found in catalog {}", root.display()), + }; + let agent_id = subject.id.as_str().to_owned(); + // Back-mapping the resolved subject to its declaration proves ID uniqueness for BOTH selector + // kinds. Only `resolve_id` refuses `AmbiguousId` above; `resolve_address` dedups its + // candidates BY agent ID, so an address naming one of two subjects that share an ID resolves + // cleanly to a single Subject and a first-match scan would publish into — and host-check — + // whichever declaration discovery ordered first. + let mut declarations = discovered .specs - .into_iter() - .filter(|spec| { - spec.bus_id(this_host) == recipient - || (spec.resolved_host(this_host) == this_host && spec.identity == recipient) - }) - .collect::>(); - anyhow::ensure!( - !matches.is_empty(), - "no agent '{recipient}' found in catalog {}", - root.display() - ); - if matches.len() > 1 { + .iter() + .filter(|spec| spec.agent_id(this_host) == agent_id); + let spec = declarations + .next() + .context("resolved subject has no declaration")?; + if let Some(duplicate) = declarations.next() { return Err(StreamRefusal::new( RefusalKind::Permanent, format!( - "agent recipient '{recipient}' is ambiguous; matched {} declarations: {}", - matches.len(), - matches - .iter() - .map(|spec| spec.path.display().to_string()) - .collect::>() - .join(", ") + "agent id '{agent_id}' is declared by more than one subject ({}, {}); refusing to \ + guess which declaration owns this stream", + spec.path.display(), + duplicate.path.display() ), )); } - let spec = matches - .pop() - .context("exactly one matching agent expected")?; 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), + spec.bus_address(this_host), spec.resolved_host(this_host) ), )); @@ -364,7 +380,7 @@ fn resolve_stream( RefusalKind::Permanent, format!( "agent '{}' does not declare stream '{stream}'", - spec.bus_id(this_host) + spec.bus_address(this_host) ), )); } @@ -379,13 +395,13 @@ fn resolve_stream( RefusalKind::RecipientNotRunning, format!( "agent '{}' is {}; refusing event while its eyes are closed", - spec.bus_id(this_host), + spec.bus_address(this_host), spec.desired_state.as_str() ), )); } Ok(ResolvedStream { - recipient: spec.bus_id(this_host), + recipient: agent_id, }) } @@ -415,11 +431,16 @@ pub fn render_event( rendered } +/// Publish one declared stream event. +/// +/// `recipient` is typed, never a raw string: a CLI positional is an ordinary ADDRESS and an +/// explicit `--id` is an EXACT ID, and wrapping an unresolved human reference in +/// `AgentSelector::id` to satisfy this signature would make every migrated subject unreachable. #[allow(clippy::too_many_arguments)] pub fn emit( root: &Path, this_host: &str, - recipient: &str, + recipient: &crate::AgentSelector, stream: &str, event_id: &str, key: Option<&str>, @@ -441,6 +462,8 @@ pub fn emit( ) } +/// The supervisor-only built-in resync admission. `recipient` here is genuinely an exact agent ID: +/// it comes from the watch set's own subscription key, not from anything a human typed. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_builtin_resync( root: &Path, @@ -455,7 +478,7 @@ pub(crate) fn emit_builtin_resync( emit_admitted( root, this_host, - recipient, + &crate::AgentSelector::id(recipient), crate::resync::RESYNC_STREAM, event_id, key, @@ -470,7 +493,7 @@ pub(crate) fn emit_builtin_resync( fn emit_admitted( root: &Path, this_host: &str, - recipient: &str, + recipient: &crate::AgentSelector, stream: &str, event_id: &str, key: Option<&str>, @@ -1191,3 +1214,120 @@ impl Drop for StreamLock { unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) }; } } + + +#[cfg(test)] +mod tests { + use super::*; + use crate::AgentSelector; + + fn declare(root: &Path, directory: &str, body: &str) { + let dir = root.join(directory); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("agent.kdl"), body).unwrap(); + } + + /// A migrated subject: immutable ID `worker-uuid`, mutable route `h.chat`. + fn migrated_catalog() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + declare( + temp.path(), + "h/worker", + "agent \"worker\" {\n identity \"worker\"\n id \"worker-uuid\"\n address \"chat\"\n host \"h\"\n command \"agent\"\n stream \"gh-ci\" {}\n}\n", + ); + temp + } + + /// Decision 2's anti-pattern: the CLI positional is an ordinary ADDRESS. Wrapping it in + /// `AgentSelector::id` made every migrated subject unreachable by the only name a person has + /// for it, while an exact ID must still never fall through to address lookup. + #[test] + fn stream_ingress_resolves_an_address_and_keeps_the_id_namespace_disjoint() { + let temp = migrated_catalog(); + let root = temp.path(); + + for reference in ["chat", "h.chat"] { + let resolved = resolve_stream( + root, + "h", + &AgentSelector::address(reference), + "gh-ci", + StreamAdmission::Declared, + ) + .unwrap_or_else(|error| panic!("address {reference:?} must resolve: {error:#}")); + assert_eq!( + resolved.recipient, "worker-uuid", + "stream state keys on the immutable ID, never the route" + ); + } + + let by_id = resolve_stream( + root, + "h", + &AgentSelector::id("worker-uuid"), + "gh-ci", + StreamAdmission::Declared, + ) + .unwrap(); + assert_eq!(by_id.recipient, "worker-uuid"); + + assert!( + resolve_stream( + root, + "h", + &AgentSelector::id("chat"), + "gh-ci", + StreamAdmission::Declared, + ) + .is_err(), + "an exact-ID selector must not fall through to address lookup" + ); + } + + /// A refusal a person reads names the bus ADDRESS, because that is how they reach the agent. + #[test] + fn a_stream_refusal_names_the_bus_address_not_the_immutable_id() { + let temp = tempfile::tempdir().unwrap(); + declare( + temp.path(), + "h/worker", + "agent \"worker\" {\n identity \"worker\"\n id \"worker-uuid\"\n address \"chat\"\n host \"h\"\n command \"agent\"\n}\n", + ); + let error = resolve_stream( + temp.path(), + "h", + &AgentSelector::address("chat"), + "gh-ci", + StreamAdmission::Declared, + ) + .expect_err("an undeclared stream is refused"); + let rendered = format!("{error}"); + assert!(rendered.contains("h.chat"), "{rendered}"); + assert!(!rendered.contains("worker-uuid"), "{rendered}"); + assert_eq!(refusal_kind(&error), Some(RefusalKind::Permanent)); + } + + /// A catalog-global ID claimed by two declarations is a broken catalog, not a winner-takes-all + /// lookup: publishing into whichever sorted first would silently pick a recipient. + #[test] + fn a_duplicate_agent_id_is_a_permanent_refusal() { + let temp = tempfile::tempdir().unwrap(); + for directory in ["h/plain", "h/twin"] { + declare( + temp.path(), + directory, + "agent \"plain\" {\n identity \"plain\"\n host \"h\"\n command \"agent\"\n stream \"gh-ci\" {}\n}\n", + ); + } + let error = resolve_stream( + temp.path(), + "h", + &AgentSelector::id("h.plain"), + "gh-ci", + StreamAdmission::Declared, + ) + .expect_err("a duplicated agent id cannot name one recipient"); + assert!(format!("{error}").contains("ambiguous"), "{error:#}"); + assert_eq!(refusal_kind(&error), Some(RefusalKind::Permanent)); + } +} \ No newline at end of file diff --git a/src/exec_backend.rs b/src/exec_backend.rs index 072c2484..409464f0 100644 --- a/src/exec_backend.rs +++ b/src/exec_backend.rs @@ -1063,7 +1063,8 @@ mod generation_observation_tests { TaskTarget { kind: crate::spec::TaskKind::Exec, pty_id: id.to_string(), - bus_id: "host.test".to_string(), + agent_id: "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 e0f9514c..74b71998 100644 --- a/src/harness_context.rs +++ b/src/harness_context.rs @@ -44,7 +44,28 @@ use serde::{Deserialize, Serialize}; use crate::harness_state::{lock_exclusive, write_json_atomic}; -const SCHEMA: &str = "st2.harness-context.v1"; +/// Schema version 1: the record's `agent` field carries the subject's bus identity. +pub const SCHEMA_V1: &str = "st2.harness-context.v1"; +/// Schema version 2: the record's `agent` field carries the subject's immutable agent ID. Only +/// that one field's meaning changes; the reading itself is identical in both versions. +pub const SCHEMA_V2: &str = "st2.harness-context.v2"; + +/// Whether the immutable-ID writer is active. **On**, with the rest of the DELTA-003 activation +/// cohort, for the same reason as its sibling [`crate::harness_state::EMIT_SCHEMA_V2`]: the driver +/// wrappers hand this writer a raw immutable agent ID, and version 1's `agent` means a bus +/// identity. [`read`] already accepts both versions and reports which namespace each names, so the +/// reader-first precondition is met. One named constant, one reversal point, defaulted on. +pub const EMIT_SCHEMA_V2: bool = true; + +/// The version this build writes. Coalescing and compaction-counter continuity are scoped to it: +/// a writer continues only the shape it emits. +const SCHEMA: &str = if EMIT_SCHEMA_V2 { SCHEMA_V2 } else { SCHEMA_V1 }; + +/// Whether a record's schema is one this version can interpret. +pub fn is_supported_schema(schema: &str) -> bool { + schema == SCHEMA_V1 || schema == SCHEMA_V2 +} + const LOCK_NAME: &str = ".harness-context.lock"; const TMP_PREFIX: &str = ".harness-context"; const STAGING_DIR_NAME: &str = "harness-context-staging"; @@ -622,6 +643,9 @@ pub struct Observed { /// Derived by the reader from `observed_at_ms`. pub age_ms: u64, pub stale: bool, + /// The subject the record names, with the namespace its schema version decided. Reused from + /// the sibling driver record: both records versioned the same field for the same reason. + pub subject: crate::harness_state::RecordSubject, } impl Observed { @@ -665,9 +689,9 @@ fn read_at(path: &Path, now_ms: u64) -> Option { ); return None; }; - if record.schema != SCHEMA { + if !is_supported_schema(&record.schema) { tracing::warn!( - "st2 harness-context: {} carries schema `{}`, not `{SCHEMA}`", + "st2 harness-context: {} carries schema `{}`, which is neither `{SCHEMA_V1}` nor `{SCHEMA_V2}`", path.display(), record.schema ); @@ -701,6 +725,10 @@ fn read_at(path: &Path, now_ms: u64) -> Option { last_compaction_ms: record.last_compaction_ms, last_compaction_trigger: record.last_compaction_trigger, observed_at_ms: record.observed_at_ms, + subject: crate::harness_state::RecordSubject::for_version( + record.schema == SCHEMA_V2, + record.agent, + ), age_ms, stale: age_ms >= duration_ms(HARNESS_CONTEXT_STALE), }) @@ -1184,10 +1212,13 @@ mod tests { fs::write( &path, - br#"{"schema":"st2.harness-context.v2","agent":"a","harness":"codex","observedAtMs":1}"#, + br#"{"schema":"st2.harness-context.v3","agent":"a","harness":"codex","observedAtMs":1}"#, ) .unwrap(); - assert!(read(&path).is_none(), "a schema this version does not own"); + assert!( + read(&path).is_none(), + "a schema outside the version pair this build understands" + ); fs::write( &path, @@ -1217,6 +1248,77 @@ mod tests { ); } + /// DELTA-003 step 5, reader first: both reserved versions read now, each decoding `agent` in + /// the namespace its own discriminator names, while the writer keeps emitting version 1 until + /// the switch it shares with the sibling driver record and the message/PTY writers flips. + #[test] + fn both_reserved_versions_are_read_with_their_own_agent_meaning() { + let tmp = tempfile::tempdir().unwrap(); + let path = harness_context_path(tmp.path()); + let now = crate::message::now_ms(); + + fs::write( + &path, + format!( + r#"{{"schema":"st2.harness-context.v1","agent":"hetz.worker","harness":"codex","usedPercent":50,"observedAtMs":{now}}}"# + ), + ) + .unwrap(); + assert_eq!( + read(&path).unwrap().subject, + crate::harness_state::RecordSubject::BusIdentity("hetz.worker".into()) + ); + + fs::write( + &path, + format!( + r#"{{"schema":"st2.harness-context.v2","agent":"hetz.worker","harness":"codex","usedPercent":50,"observedAtMs":{now}}}"# + ), + ) + .unwrap(); + let observed = read(&path).unwrap(); + assert_eq!(observed.used_percent, Some(50.0), "the reading is unchanged"); + assert_eq!( + observed.subject, + crate::harness_state::RecordSubject::AgentId("hetz.worker".into()), + "only the meaning of `agent` differs between the versions" + ); + } + + /// The writer receives a raw immutable agent ID from the driver wrappers, so the record must + /// declare version 2; a version-1 record already on disk keeps its bus-identity meaning. + #[test] + fn the_writer_declares_version_two_so_its_agent_field_means_an_immutable_id() { + assert!(EMIT_SCHEMA_V2); + assert_eq!(SCHEMA, SCHEMA_V2); + + let tmp = tempfile::tempdir().unwrap(); + let agent_dir = catalog(tmp.path()); + let path = harness_context_path(&agent_dir); + let mut writer = writer(&agent_dir); + writer.observe(reading(85_000, 33.0)).unwrap(); + let record = read_record(&path).unwrap(); + assert_eq!(record.schema, SCHEMA_V2); + assert_eq!( + read(&path).unwrap().subject, + crate::harness_state::RecordSubject::AgentId(record.agent.clone()) + ); + + let now = crate::message::now_ms(); + fs::write( + &path, + format!( + r#"{{"schema":"st2.harness-context.v1","agent":"hetz.worker","harness":"codex","usedPercent":50,"observedAtMs":{now}}}"# + ), + ) + .unwrap(); + assert_eq!( + read(&path).unwrap().subject, + crate::harness_state::RecordSubject::BusIdentity("hetz.worker".into()), + "writing version 2 must not retype the version-1 records already on disk" + ); + } + /// A writer clock far enough ahead makes the derived age meaningless, so the record reads as /// absent rather than as a value with a nonsense age. #[test] diff --git a/src/harness_state.rs b/src/harness_state.rs index 69f49b4f..f00681a2 100644 --- a/src/harness_state.rs +++ b/src/harness_state.rs @@ -32,7 +32,90 @@ 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); -const SCHEMA: &str = "st2.harness-state.v1"; +/// Schema version 1: the record's `agent` field carries the subject's bus identity. +pub const SCHEMA_V1: &str = "st2.harness-state.v1"; +/// Schema version 2: the record's `agent` field carries the subject's immutable agent ID. Nothing +/// else about the record changes — the version exists solely to make the meaning of that one +/// field decidable from the bytes, because the two namespaces are separate and a v1 reader would +/// otherwise retype a frozen legacy ID as a route. +pub const SCHEMA_V2: &str = "st2.harness-state.v2"; + +/// Whether the immutable-ID writer is active. **On**, with the rest of the DELTA-003 activation +/// cohort (raw-ID `ST_AGENT`, ID-keyed runtime ownership, message record version 2, PTY schema 2). +/// +/// The driver wrappers hand this writer a raw immutable agent ID, and a version suffix is the read +/// contract for this record family: stamping that ID under version 1, whose `agent` means a bus +/// identity, would misattribute it to whichever subject holds those bytes as a route. The +/// reader-first precondition is already met — [`read`] accepts both versions and reports which +/// namespace each one names. The constant stays named so the cohort remains visible and one +/// reversal point exists; it is not a per-record switch to flip alone. +pub const EMIT_SCHEMA_V2: bool = true; + +/// The version this build writes. Every ownership decision below — sequence adoption, own-record +/// coalescing, heartbeat eligibility — is scoped to it: a writer owns only the shape it emits, so +/// a v1 straggler still refuses to replace a v2 record and vice versa. +const SCHEMA: &str = if EMIT_SCHEMA_V2 { SCHEMA_V2 } else { SCHEMA_V1 }; + +/// Whether a record's schema is one this version can interpret. Both versions describe the same +/// axes; only the meaning of `agent` differs, and [`RecordSubject`] carries that difference. +pub fn is_supported_schema(schema: &str) -> bool { + schema == SCHEMA_V1 || schema == SCHEMA_V2 +} + +/// The subject a record names, carrying which typed namespace the value belongs to. +/// +/// ID and address are separate namespaces in which equal bytes never collide, so a consumer that +/// joins records to a catalog must know which one it is holding. An unmigrated subject's two +/// values are byte-identical, which is why this distinction costs nothing during the transition +/// and everything after the first address cutover. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordSubject { + /// From a version 1 record: the writer's `.` bus identity. + BusIdentity(String), + /// From a version 2 record: the subject's catalog-global immutable agent ID. + AgentId(String), +} + +impl RecordSubject { + /// Build the subject from the decision the *record's own* schema discriminator already made. + /// + /// The caller passes the decision rather than the schema string because the two driver + /// records version independently: `st2.harness-state.v2` and `st2.harness-context.v2` are + /// different spellings of the same meaning, and a shared string comparison would silently + /// read one of them as version 1. + pub(crate) fn for_version(carries_agent_id: bool, agent: String) -> Self { + if carries_agent_id { + Self::AgentId(agent) + } else { + Self::BusIdentity(agent) + } + } + + /// The declared value, whichever namespace it belongs to. Diagnostics only: never compare + /// this across namespaces. + pub fn value(&self) -> &str { + match self { + Self::BusIdentity(value) | Self::AgentId(value) => value, + } + } + + /// The immutable agent ID, when the record proved one. + pub fn agent_id(&self) -> Option<&str> { + match self { + Self::AgentId(id) => Some(id), + Self::BusIdentity(_) => None, + } + } + + /// The bus identity, when the record proved one. + pub fn bus_identity(&self) -> Option<&str> { + match self { + Self::BusIdentity(identity) => Some(identity), + Self::AgentId(_) => None, + } + } +} + /// 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"; @@ -382,7 +465,16 @@ impl Writer { persist_floor(&self.path, 1); 1 } - Some(current) if current.incarnation == self.session => current.seq, + // Adoption never crosses schemas: a record is this writer's own only when it + // carries both this build's version and this session's token. A same-token + // record under the other version belongs to a differently-versioned writer of + // this session, and inheriting its sequence would let this build write over a + // meaning it did not produce. + Some(current) + if current.schema == SCHEMA && current.incarnation == self.session => + { + current.seq + } Some(_) => return Ok(false), }, }; @@ -542,6 +634,9 @@ pub struct Observed { pub since_ms: Option, pub exit: Option, pub reason: Option, + /// The subject the record names, with the namespace its version decided. `None` whenever the + /// observation is indeterminate: an uninterpretable record proves no subject either. + pub subject: Option, } impl Observed { @@ -557,6 +652,7 @@ impl Observed { since_ms: None, exit: None, reason: Some(reason.to_string()), + subject: None, } } } @@ -594,11 +690,14 @@ fn read_raw_at( return Observed::indeterminate("malformed-record", None); }; let harness = Some(record.harness.clone()); - // The discriminator gates interpretation: a future schema's words may be spelled like this - // version's while meaning something else, so nothing definite may be derived from them. - if record.schema != SCHEMA { + // The discriminator gates interpretation: a schema outside the pair this version understands + // may spell its words like these while meaning something else, so nothing definite may be + // derived from it. Within the pair, only the meaning of `agent` differs, and that meaning + // travels typed on `subject` rather than being guessed by the consumer. + if !is_supported_schema(&record.schema) { return Observed::indeterminate("unsupported-schema", harness); } + let subject = RecordSubject::for_version(record.schema == SCHEMA_V2, record.agent.clone()); if record.written_at_ms > now_ms { if record.written_at_ms - now_ms > duration_ms(HARNESS_STATE_FUTURE_SKEW) { return Observed::indeterminate("future-skew", harness); @@ -645,6 +744,7 @@ fn read_raw_at( since_ms: Some(record.since_ms), exit: record.exit, reason: record.reason, + subject: Some(subject), } } @@ -748,6 +848,38 @@ fn next_stamp(on_disk: Option<&Record>, now_ms: u64) -> u64 { .map_or(now_ms, |previous| now_ms.max(previous.saturating_add(1))) } +/// Ordinal of a supported schema version, or `None` for a schema this build cannot interpret. +fn schema_rank(schema: &str) -> Option { + match schema { + SCHEMA_V1 => Some(1), + SCHEMA_V2 => Some(2), + _ => None, + } +} + +/// Whether a claim written under `ours` may supersede the record `current`. +/// +/// Takeover across supported versions is ONE-WAY. Same version is ordinary supersession. An older +/// supported version may be superseded: that is the v1 → v2 migration, and it is the only +/// direction in which the record's meaning gains information. A NEWER supported version is +/// refused, because during the coordinated writer cutover a still-running reader-first binary +/// whose writer is v1 would otherwise overwrite a migrated, ID-bearing record with bus-address +/// semantics under a higher ownership sequence — silently destroying the migrated meaning of a +/// live record, with no later act able to tell that it happened. An unsupported schema holds +/// nothing this build could preserve, so an explicit claim still supersedes it. +/// +/// `ours` is a parameter rather than a read of [`SCHEMA`] so the refused direction is provable in +/// one build: the whole defect is about two builds writing different versions at the same time. +fn claim_may_supersede(ours: &str, current: Option<&Record>) -> bool { + let Some(current) = current else { + return true; + }; + match (schema_rank(¤t.schema), schema_rank(ours)) { + (Some(on_disk), Some(ours)) => on_disk <= ours, + _ => true, + } +} + /// Claim session ownership of an agent's record, as a WRITTEN act under the record's lock: the /// takeover record supersedes whatever is on disk — `ended` with reason `superseded`, no exit, /// the new session's token, and the next ownership sequence — and the claimed sequence is @@ -781,6 +913,19 @@ fn claim_locked(writer: &Writer, token: &str) -> anyhow::Result { StoredRecord::Parsed(record) => Some(record), StoredRecord::Absent | StoredRecord::Unreadable => None, }; + // The semantic fence, before anything is minted or written. The SEQUENCE below deliberately + // stays monotonic across the one-way v1 → v2 migration — it is a counter, not a meaning, and + // restarting it at one would sit below a lingering predecessor's claim and fence the new + // session out permanently — but the record's MEANING may only ever move forward. + anyhow::ensure!( + claim_may_supersede(SCHEMA, on_disk.as_ref()), + "refusing to claim a `{}` record while this build writes `{SCHEMA}`: \ + taking it over would downgrade a migrated record's `agent` field to a bus address", + on_disk + .as_ref() + .map(|record| record.schema.as_str()) + .unwrap_or_default() + ); let floor_path = writer.path.with_file_name(SEQ_FLOOR_NAME); let floor = fs::read_to_string(&floor_path) .ok() @@ -889,7 +1034,12 @@ pub fn claim_wrapperless( let wrapperless_owner = record.incarnation.is_empty() || record.incarnation.starts_with(WRAPPERLESS_PREFIX); let real_terminal = record.state == Activity::Ended && record.exit.is_some(); - wrapperless_owner || real_terminal || stale + // The same one-way schema fence as the written claim, applied here so the cautious + // path refuses (`Ok(None)`) instead of reaching `claim_locked` and erroring: a build + // whose writer is behind the record on disk has no eligible takeover at all, however + // stale or orphaned that record looks. + claim_may_supersede(SCHEMA, Some(&record)) + && (wrapperless_owner || real_terminal || stale) } }; if !eligible { @@ -928,6 +1078,21 @@ mod tests { .with_ownership(token, seq) } + /// One record on disk under an explicit schema, with a live stamp and an orphan token, so + /// every eligibility clause except the schema fence says "claimable". + fn planted(dir: &Path, schema: &str, agent: &str, seq: u64) -> PathBuf { + let path = harness_state_path(dir); + fs::write( + &path, + format!( + r#"{{"schema":"{schema}","agent":"{agent}","harness":"codex","state":"ended","blockedOn":"none","inputBuffer":"unknown","exit":"exit 0","incarnation":"","seq":{seq},"sinceMs":1,"writtenAtMs":{},"transitions":4}}"#, + crate::message::now_ms() + ), + ) + .unwrap(); + path + } + fn active() -> Observation { Observation::new(Activity::Active, BlockedOn::None, InputBuffer::Unknown) } @@ -1254,10 +1419,10 @@ mod tests { #[test] fn future_vocabulary_degrades_to_indeterminate_not_none() { - // A future schema gates interpretation entirely — even words spelled exactly like this - // version's must not decode as anything definite, because v2 may have changed what the - // same spelling means. - let raw = br#"{"schema":"st2.harness-state.v2","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":9999999999999,"transitions":3,"novelField":true}"#; + // A schema outside the pair this version understands gates interpretation entirely — even + // words spelled exactly like these must not decode as anything definite, because the + // later version may have changed what the same spelling means. + let raw = br#"{"schema":"st2.harness-state.v3","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":9999999999999,"transitions":3,"novelField":true}"#; let observed = read_raw_at(raw, None, 9_999_999_999_999); assert_eq!(observed.state, Activity::Unknown); assert_eq!(observed.reason.as_deref(), Some("unsupported-schema")); @@ -1271,6 +1436,221 @@ mod tests { assert_eq!(observed.input_buffer, InputBuffer::Unknown); } + /// DELTA-003 step 5, reader first: this version reads both record versions *before* any writer + /// emits version 2, and each one's `agent` field is decoded in the namespace its own + /// discriminator names. A version outside that pair stays `unsupported-schema`, so the + /// tolerance is exactly two versions wide rather than "anything that parses". + #[test] + fn both_reserved_versions_are_read_with_their_own_agent_meaning() { + let v1 = br#"{"schema":"st2.harness-state.v1","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":1000,"transitions":3}"#; + let observed = read_raw_at(v1, None, 1_000); + assert_eq!(observed.state, Activity::Active); + assert_eq!( + observed.subject, + Some(RecordSubject::BusIdentity("hetz.worker".into())), + "version 1 `agent` is the bus identity" + ); + assert_eq!(observed.subject.as_ref().unwrap().agent_id(), None); + + // Same bytes in `agent`, different namespace — which is precisely why the version exists: + // a frozen legacy ID and a bus identity are indistinguishable without the discriminator. + let v2 = br#"{"schema":"st2.harness-state.v2","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":1000,"transitions":3}"#; + let observed = read_raw_at(v2, None, 1_000); + assert_eq!(observed.state, Activity::Active); + assert_eq!( + observed.subject, + Some(RecordSubject::AgentId("hetz.worker".into())), + "version 2 `agent` is the immutable agent ID" + ); + assert_eq!(observed.subject.as_ref().unwrap().bus_identity(), None); + + let v3 = br#"{"schema":"st2.harness-state.v3","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":1000,"transitions":3}"#; + let observed = read_raw_at(v3, None, 1_000); + assert_eq!(observed.reason.as_deref(), Some("unsupported-schema")); + assert_eq!(observed.state, Activity::Unknown); + assert!( + observed.subject.is_none(), + "an uninterpretable record proves no subject either" + ); + } + + /// The activation cohort is on: the driver wrappers hand this writer a raw immutable agent ID, + /// so the record it writes must DECLARE version 2. Stamping that ID under version 1 — whose + /// `agent` means a bus identity — is the misattribution this version exists to prevent, and it + /// is exactly what the old writer default did. + #[test] + fn the_writer_declares_version_two_so_its_agent_field_means_an_immutable_id() { + assert!(EMIT_SCHEMA_V2); + assert_eq!(SCHEMA, SCHEMA_V2); + + let tmp = tempfile::tempdir().unwrap(); + let path = harness_state_path(tmp.path()); + let mut writer = takeover(tmp.path(), "codex"); + writer.observe(active()).unwrap(); + let record: Record = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(record.schema, SCHEMA_V2); + // And the reader hands that value back in the ID namespace, not as a route. + assert_eq!( + read(&path, None).unwrap().subject, + Some(RecordSubject::AgentId(record.agent.clone())) + ); + + // Writing version 2 does not retype the version-1 records already on disk: a v1 record + // still reads with bus-identity meaning, which is the whole point of keeping the pair. + let v1 = format!( + r#"{{"schema":"st2.harness-state.v1","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"empty","sinceMs":1,"writtenAtMs":{},"transitions":3}}"#, + crate::message::now_ms() + ); + fs::write(&path, v1).unwrap(); + let observed = read(&path, None).unwrap(); + assert_eq!(observed.state, Activity::Active); + assert_eq!( + observed.subject, + Some(RecordSubject::BusIdentity("hetz.worker".into())) + ); + } + + /// The takeover fence is ONE-WAY across supported versions. The defect this pins: during the + /// coordinated writer cutover, a still-running reader-first binary whose writer is v1 could + /// claim a migrated v2 record and rewrite its `agent` field as a bus address under a HIGHER + /// ownership sequence — permanently fencing out the true owner and leaving no trace that the + /// migrated meaning was destroyed. + #[test] + fn a_claim_never_downgrades_a_record_to_an_older_schema() { + let v2 = Record { + schema: SCHEMA_V2.to_string(), + agent: "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".to_string(), + harness: "codex".to_string(), + state: Activity::Active, + blocked_on: BlockedOn::None, + input_buffer: InputBuffer::Unknown, + ask: Ask::None, + reason: None, + exit: None, + pty_session: Some("worker".to_string()), + incarnation: "other".to_string(), + seq: 3, + since_ms: 1, + written_at_ms: 1, + transitions: 1, + }; + let v1 = Record { + schema: SCHEMA_V1.to_string(), + agent: "hetz.worker".to_string(), + ..v2.clone() + }; + + // Refused: a v1 writer must not take over a v2 record. + assert!(!claim_may_supersede(SCHEMA_V1, Some(&v2))); + // Allowed, and one-way: the v1 → v2 migration, plus same-version supersession. + assert!(claim_may_supersede(SCHEMA_V2, Some(&v1))); + assert!(claim_may_supersede(SCHEMA_V1, Some(&v1))); + assert!(claim_may_supersede(SCHEMA_V2, Some(&v2))); + // Nothing on disk, and bytes no build can interpret, hold nothing to preserve. + assert!(claim_may_supersede(SCHEMA_V1, None)); + let unsupported = Record { + schema: "st2.harness-state.v3".to_string(), + ..v2.clone() + }; + assert!(claim_may_supersede(SCHEMA_V1, Some(&unsupported))); + } + + /// This build writes v2, so the v1 → v2 migration claim is the one it can exercise + /// end-to-end: it lands exactly once, keeps the sequence monotonic across the version change + /// (the counter is not a meaning, and restarting it would sit below a lingering predecessor's + /// claim and fence the new session out), and leaves the record declaring v2. + #[test] + fn a_version_two_claim_migrates_a_version_one_record_once_and_keeps_the_sequence() { + assert_eq!(SCHEMA, SCHEMA_V2, "this build writes the newer version"); + let tmp = tempfile::tempdir().unwrap(); + let path = planted(tmp.path(), SCHEMA_V1, "hetz.worker", 7); + + let token = session_token(); + let seq = claim(tmp.path(), "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "codex", &token).unwrap(); + assert_eq!(seq, 8, "the sequence continues past the version change"); + let record: Record = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(record.schema, SCHEMA_V2); + assert_eq!(record.agent, "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + + // The claim itself is a fence, not an observation, so it reads indeterminate (`claimed`) + // and proves no subject. The session's first real observation is what a consumer joins to + // a catalog, and that must land in the ID namespace. + assert!(read(&path, None).unwrap().subject.is_none()); + let mut owner = Writer::new( + tmp.path(), + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", + "codex", + Some("worker".into()), + ) + .with_ownership(token, seq); + owner.observe(active()).unwrap(); + let observed = read(&path, None).unwrap(); + assert_eq!(observed.state, Activity::Active); + assert_eq!( + observed.subject, + Some(RecordSubject::AgentId( + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".into() + )), + "the migrated record names the ID namespace" + ); + } + + /// Wrapperless eligibility respects the same fence. The planted record is exit-bearing + /// terminal AND orphan-tokened, so every other clause votes "claimable"; only the schema + /// decides. A same-version plant proves the test is not vacuous. + #[test] + fn wrapperless_eligibility_respects_the_schema_fence() { + let same = tempfile::tempdir().unwrap(); + planted(same.path(), SCHEMA, "hetz.worker", 2); + assert!( + claim_wrapperless(same.path(), "hetz.worker", "codex", &session_token()) + .unwrap() + .is_some(), + "an orphaned terminal record of this build's own version is claimable" + ); + + // The refused direction is not reachable from a build that writes the newest version, so + // the fence itself is asserted where it is decidable. + assert!(!claim_may_supersede( + SCHEMA_V1, + Some(&serde_json::from_slice::( + br#"{"schema":"st2.harness-state.v2","agent":"id","harness":"codex","state":"ended","blockedOn":"none","inputBuffer":"unknown","exit":"exit 0","incarnation":"","seq":2,"sinceMs":1,"writtenAtMs":1,"transitions":1}"# + ).unwrap()) + )); + } + + /// Ownership-sequence ADOPTION never crosses schemas: a same-token record under the other + /// version is a differently-versioned writer of this session, and inheriting its sequence + /// would let this build write over a meaning it did not produce. + #[test] + fn sequence_adoption_never_crosses_schemas() { + let tmp = tempfile::tempdir().unwrap(); + let path = harness_state_path(tmp.path()); + let token = session_token(); + // A v1 record carrying THIS session's token: same token, wrong version. + fs::write( + &path, + format!( + r#"{{"schema":"st2.harness-state.v1","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"unknown","incarnation":"{token}","seq":5,"sinceMs":1,"writtenAtMs":{},"transitions":1}}"#, + crate::message::now_ms() + ), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + + let mut token_only = Writer::new(tmp.path(), "hetz.worker", "codex", Some("worker".into())); + token_only.session = token.clone(); + assert!( + !token_only.observe_unless_ended(active()).unwrap(), + "a non-claiming writer must refuse a record of the other version" + ); + assert_eq!( + fs::read(&path).unwrap(), + before, + "the cross-version record is left byte-identical" + ); + } + #[test] fn interrupt_forces_a_fresh_transition_even_for_a_restated_fresh_tuple() { let tmp = tempfile::tempdir().unwrap(); @@ -1497,7 +1877,10 @@ mod tests { fn foreign_schemas_are_never_coalesced_restamped_or_treated_as_terminal() { let tmp = tempfile::tempdir().unwrap(); let path = harness_state_path(tmp.path()); - let foreign = br#"{"schema":"st2.harness-state.v2","agent":"hetz.worker","harness":"codex","state":"ended","blockedOn":"none","inputBuffer":"unknown","sinceMs":5,"writtenAtMs":99999999999999,"transitions":7,"novel":true}"#; + // Now that the writer emits version 2, a version-1 straggler is the foreign shape: its + // `agent` means a route, so adopting it as this writer's own record would coalesce an + // address onto an ID. + let foreign = br#"{"schema":"st2.harness-state.v1","agent":"hetz.worker","harness":"codex","state":"ended","blockedOn":"none","inputBuffer":"unknown","sinceMs":5,"writtenAtMs":99999999999999,"transitions":7,"novel":true}"#; fs::write(&path, foreign).unwrap(); // Heartbeat leaves a foreign record byte-identical rather than stripping its fields — @@ -1813,13 +2196,14 @@ mod tests { assert_eq!(read(&path, None).unwrap().state, Activity::Active); } - /// W8-6: a v2 record's serde-default sequence of zero is below every claim — but a v1 - /// straggler must not replace a record it cannot read. Only the written claim supersedes. + /// W8-6: a foreign record's serde-default sequence of zero is below every claim — but a writer + /// of the other version must not replace a record whose `agent` it would misread. Only the + /// written claim supersedes. #[test] fn non_claiming_writers_refuse_foreign_schemas_outright() { let tmp = tempfile::tempdir().unwrap(); let path = harness_state_path(tmp.path()); - let v2 = br#"{"schema":"st2.harness-state.v2","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"unknown","incarnation":"future","sinceMs":1,"writtenAtMs":1,"transitions":1}"#; + let v2 = br#"{"schema":"st2.harness-state.v1","agent":"hetz.worker","harness":"codex","state":"active","blockedOn":"none","inputBuffer":"unknown","incarnation":"future","sinceMs":1,"writtenAtMs":1,"transitions":1}"#; fs::write(&path, v2).unwrap(); let token = session_token(); diff --git a/src/lib.rs b/src/lib.rs index adfd10f9..a7da54e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog; pub mod catalog_archive; pub mod catalog_graph; pub mod catalog_lock; +pub mod catalog_migrate; pub mod catalog_transaction; pub mod claude_channel; pub mod claude_mcp; @@ -61,7 +62,14 @@ mod watch; // The declaration model and the catalog walk live in the `agent-spec` crate, so st2 and any other // reader of the same catalog share one implementation. Re-exported under their original paths: // `st2::spec::…` / `st2::discovery::…` keep working for the binary and the test suite. -pub use agent_spec::{discovery, spec}; +pub use agent_spec::{discovery, identity, spec}; + +// The typed identity contract: an immutable catalog-global agent ID, a mutable host-local address, +// and the fail-closed address book ordinary human references resolve through (decision 0015). +pub use agent_spec::identity::{ + AddressBook, AgentAddress, AgentId, AgentSelector, IdentityError, ResolveError, Subject, + UniquenessConflict, bus_address, legacy_bus_identity, +}; pub use agent_spec::discovery::{Discovered, SpecError, discover, discover_file, discover_strict}; pub use agent_spec::spec::{ diff --git a/src/main.rs b/src/main.rs index bf1be025..06d27078 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,14 +107,15 @@ enum Command { /// job now); it is the exact same command. #[command(visible_alias = "ping")] Ding { - /// The target pty session to poke (a `pty` session ref). Optional — defaults to `--identity` - /// (an agent IS its pty, so the session to poke is the identity), so `st2 ding --identity X` - /// is the common form. + /// The target pty session to poke (a `pty` session ref). Optional — defaults to the + /// selected agent's own id, since an agent IS its pty. session: Option, - /// Whose inbox to watch — bus id or identity. Defaults to `$ST_AGENT`. Also the default poke - /// target when no positional session is given. - #[arg(long)] + /// Whose inbox to watch, as an ordinary agent address. Disjoint from `--id`. + #[arg(long, conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. Defaults to `$ST_AGENT`. + #[arg(long, conflicts_with = "identity")] + id: Option, /// Catalog root. Defaults to `$CATALOG`. #[arg(long, conflicts_with = "catalog_path")] root: Option, @@ -128,9 +129,9 @@ enum Command { /// Internal controlled Codex launch. Generated only for `deliver "app-server"` tasks. #[command(hide = true)] CodexAppServer { - /// Exact agent bus identity that owns the controlled thread. - #[arg(long)] - identity: String, + /// Exact catalog-global agent ID that owns the controlled thread. + #[arg(long, alias = "identity")] + id: String, /// Exact reconciled PTY task identity for this runtime. #[arg(long)] runtime_id: String, @@ -141,14 +142,18 @@ enum Command { /// Internal Claude MCP channel server started by Claude from its rendered project declaration. #[command(hide = true)] ClaudeMcp { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, }, /// Get or set an agent's presence status. No `--set` prints the status; no identity means yours /// (`$ST_AGENT`). Settable: offline | available | busy | away | dnd (`unknown` is derived). Status { - /// Whose status — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). + /// Whose status, as an ordinary agent address. Defaults to you (`--as` / `$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// Set your status to this state instead of printing it. #[arg(long = "set")] set: Option, @@ -274,9 +279,12 @@ enum Command { /// Only agents whose effective status matches (offline|available|busy|away|dnd|unknown). #[arg(long = "status")] status: Option, - /// Select one exact Agent Spec by its fully qualified `.`. - #[arg(long, value_name = "HOST.IDENTITY")] + /// Select one agent by an ordinary address — bare or host-qualified. Disjoint from `--id`. + #[arg(long, value_name = "ADDRESS", conflicts_with = "id")] identity: Option, + /// Select one agent by its exact catalog-global agent ID. Never parsed as an address. + #[arg(long, value_name = "AGENT-ID", conflicts_with = "identity")] + id: Option, /// Machine-readable JSON array, including retirement and declared Resource bindings. #[arg(long)] json: bool, @@ -321,17 +329,25 @@ enum DriverCmd { Expand { /// KDL declaration that contains the typed driver block. spec: PathBuf, - /// Select one local or fully qualified identity when the file contains multiple agents. - #[arg(long)] + /// Ordinary agent address — bare or host-qualified — when the file declares several agents. + #[arg(long, conflicts_with = "id")] agent: Option, + /// Exact catalog-global agent ID. Disjoint from `--agent`; never parsed as an address. + #[arg(long, conflicts_with = "agent")] + id: Option, /// Host fallback when neither the declaration nor its catalog path supplies one. #[arg(long)] host: Option, }, + // Every wrapper below is invoked by st2's own reconciled argv or by a rendered hook, never by a + // human typing a route. Their selector is therefore the exact catalog-global agent ID, which + // never reaches an address parser. `--identity` stays accepted as the pre-activation spelling of + // that same typed ID so a hook file rendered by an older binary keeps resolving; it is an old + // flag name for one namespace, not an address fallback. /// Run the existing controlled Codex app-server path. Codex { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, #[arg(long)] runtime_id: String, #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] @@ -339,20 +355,20 @@ enum DriverCmd { }, /// Run the Claude session-owned MCP server over stdio. ClaudeMcp { - #[arg(long)] - identity: Option, + #[arg(long, alias = "identity")] + id: Option, }, /// Deprecated name for the Claude MCP server. // Keep this hidden command until no rendered configuration uses the old name. #[command(hide = true)] Claude { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, }, /// Run Claude under the session-owned presence wrapper. ClaudeSession { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, #[arg(long)] runtime_id: String, #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] @@ -360,9 +376,9 @@ enum DriverCmd { }, /// Apply one Claude hook event (payload on stdin) to observed harness state. ClaudeObserve { - #[arg(long)] - identity: String, - /// The wrapper's runtime/task ID; the record's pty session. Defaults to the identity. + #[arg(long, alias = "identity")] + id: String, + /// The wrapper's runtime/task ID; the record's pty session. Defaults to the agent ID. #[arg(long)] runtime_id: Option, /// The Claude hook event name, e.g. `Stop` or `PermissionRequest`. @@ -372,13 +388,13 @@ enum DriverCmd { /// Tee Claude's status-line payload (stdin JSON) into harness context, then chain to the /// operator's own renderer. ClaudeStatusline { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, }, /// Run pi under the session-owned presence wrapper. PiSession { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, #[arg(long)] runtime_id: String, #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] @@ -386,13 +402,13 @@ enum DriverCmd { }, /// Run the pi native message channel over stdio, owned by the shipped pi extension. PiChannel { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, }, /// Run omp under the session-owned presence wrapper with a hard version gate. OmpSession { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, #[arg(long)] runtime_id: String, #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] @@ -400,14 +416,14 @@ enum DriverCmd { }, /// Run the omp native message channel over stdio, owned by the shipped omp extension. OmpChannel { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, }, /// Run OpenCode under the session-owned wrapper: presence, observed harness state, and native /// server delivery over the wrapper-allocated local port. OpencodeSession { - #[arg(long)] - identity: String, + #[arg(long, alias = "identity")] + id: String, #[arg(long)] runtime_id: String, #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] @@ -417,10 +433,16 @@ enum DriverCmd { #[derive(Subcommand)] enum AgentCmd { + /// Assign or clear the agent's mutable host-local address in one atomic route cutover. + /// + /// The old address stops resolving as soon as the new catalog generation is visible and may be + /// claimed by another subject. There is no alias, redirect, history, or expiry. + Address(AddressArgs), /// 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, + /// Exact catalog-global agent ID of the declaration to edit. + #[arg(long, value_name = "AGENT-ID")] + id: String, /// Desired whole-agent lifecycle state. #[arg(value_parser = ["running", "suspended", "retired"])] state: String, @@ -618,6 +640,22 @@ enum CatalogCmd { #[arg(long)] json: bool, }, + /// Freeze an explicit immutable agent ID onto every legacy declaration in one transaction. + /// + /// Additive and idempotent: a live subject receives its existing `.` bus + /// identity, so no runtime or declaration-anchored state moves. An archived collision receives + /// UUIDv7, and every supervisor reference is rewritten to its parent's migrated ID. + MigrateIds { + /// Host used only to resolve declarations whose host is omitted. + #[arg(long)] + host: Option, + /// Compute and report the complete plan without writing anything. + #[arg(long)] + dry_run: bool, + /// Emit the stable JSON migration receipt or classified refusal. + #[arg(long)] + json: bool, + }, /// Move one archived identity back into the live catalog. The exact reverse of `archive`. Unarchive { /// Archived identity to restore. @@ -649,10 +687,14 @@ struct MsgCtx { host: Option, } +/// Identity-authoring commands select by explicit immutable agent ID (R25). There is deliberately +/// no positional selector: a slot that sometimes means an address and sometimes an ID is the +/// precedence-based resolver decision 0015 rejected. #[derive(Args)] struct PresentationArgs { - /// Exact bus identity, or a bare stable identity only when unique in the selected catalog. - identity: String, + /// Exact catalog-global agent ID of the declaration to edit. + #[arg(long, value_name = "AGENT-ID")] + id: String, /// Presentation text. Use --clear to remove the field. #[arg( value_name = "TEXT", @@ -671,6 +713,30 @@ struct PresentationArgs { host: Option, } +/// `st2 agent address --id (
| --clear)`. +#[derive(Args)] +struct AddressArgs { + /// Exact catalog-global agent ID of the declaration to edit. + #[arg(long, value_name = "AGENT-ID")] + id: String, + /// The new host-local agent address. Use --clear to restore the identity fallback. + #[arg( + value_name = "ADDRESS", + required_unless_present = "clear", + conflicts_with = "clear" + )] + value: Option, + /// Restore the positional identity fallback as the effective address. + #[arg(long)] + clear: bool, + /// Emit a stable JSON receipt or classified refusal. + #[arg(long)] + json: bool, + /// Host used only to resolve declarations whose host is omitted. + #[arg(long)] + host: Option, +} + #[derive(Subcommand)] enum ServiceCmd { /// Write the `st2.service` systemd-user unit, enable it (start on boot), and start it now. @@ -743,8 +809,12 @@ enum HooksCmd { enum ResourceCmd { /// List an agent's declared Resource bindings. Defaults to your own. Ls { - /// Whose declaration to read — bus id or bare identity. Defaults to you (`$ST_AGENT`). + /// Whose declaration to read, as an ordinary agent address. Defaults to you (`$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// Emit the bindings as a JSON array. #[arg(long)] json: bool, @@ -838,8 +908,12 @@ enum ResourceCmd { enum ContextCmd { /// Print an agent's context. Default = `now.md` (working state); `--decisions` the log; `--full` both. Read { - /// Whose context — bus id or identity. Defaults to you (`$ST_AGENT`). + /// Whose context, as an ordinary agent address. Defaults to you (`$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// Print the decision log instead of the working state. #[arg(long)] decisions: bool, @@ -854,13 +928,23 @@ enum ContextCmd { }, /// Overwrite an agent's working state (`now.md`) from stdin. Write { + /// Whose context, as an ordinary agent address. Defaults to you (`$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, #[command(flatten)] ctx: MsgCtx, }, /// Append a single decision (with its reasoning) to the log. Append { + /// Whose context, as an ordinary agent address. Defaults to you (`$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// The decision — a single line. #[arg(long)] decision: String, @@ -912,8 +996,12 @@ enum MessageCmd { }, /// List an inbox (or `--archive`), sorted by send time. Defaults to your own. Ls { - /// Whose inbox — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). + /// Whose inbox, as an ordinary agent address. Defaults to you (`--as` / `$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// List the archive instead of the inbox. #[arg(long)] archive: bool, @@ -940,8 +1028,12 @@ enum MessageCmd { }, /// List sender-owned durable message history. Defaults to your own index. Sent { - /// Whose sent index — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). + /// Whose sent index, as an ordinary agent address. Defaults to you (`--as` / `$ST_AGENT`). + #[arg(conflicts_with = "id")] identity: Option, + /// Exact catalog-global agent ID. Never parsed as an address. + #[arg(long, conflicts_with = "identity")] + id: Option, /// Print only the indexed message count. Refuses unavailable or partial coverage. #[arg(long)] count: bool, @@ -1194,123 +1286,143 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< Command::Ding { session, identity, + id, root, host, interval, - } => ding_cmd(session, identity, root, host, interval), + } => ding_cmd(session, identity, id, root, host, interval), Command::CodexAppServer { - identity, + id, runtime_id, codex_argv, } => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::codex_app_server::run_controlled(&catalog, identity, runtime_id, codex_argv) + st2::codex_app_server::run_controlled(&catalog, id, runtime_id, codex_argv) } - Command::ClaudeMcp { identity } => { + Command::ClaudeMcp { id } => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::claude_mcp::run(&catalog, &identity) + st2::claude_mcp::run(&catalog, &id) } Command::Driver(DriverCmd::Codex { - identity, + id, runtime_id, argv, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::codex_app_server::run_controlled(&catalog, identity, runtime_id, argv) + st2::codex_app_server::run_controlled(&catalog, id, runtime_id, argv) } - Command::Driver(DriverCmd::PiChannel { identity }) => { + Command::Driver(DriverCmd::PiChannel { id }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::pi_channel::run(&catalog, &identity) + st2::pi_channel::run(&catalog, &id) } Command::Driver(DriverCmd::PiSession { - identity, + id, runtime_id, argv, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::pi_session::run(&catalog, identity, runtime_id, argv) + st2::pi_session::run(&catalog, id, runtime_id, argv) } - Command::Driver(DriverCmd::ClaudeMcp { identity }) => { + Command::Driver(DriverCmd::ClaudeMcp { id }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - let identity = identity + // `ST_AGENT` already carries the exact agent ID, so this default stays in the ID + // namespace and is never reparsed as an address. + let id = id .or_else(|| std::env::var("ST_AGENT").ok()) - .context("--identity is required when ST_AGENT is not set")?; - st2::claude_mcp::run(&catalog, &identity) + .context("--id is required when ST_AGENT is not set")?; + st2::claude_mcp::run(&catalog, &id) } - Command::Driver(DriverCmd::OmpChannel { identity }) => { + Command::Driver(DriverCmd::OmpChannel { id }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::pi_channel::run_omp(&catalog, &identity) + st2::pi_channel::run_omp(&catalog, &id) } Command::Driver(DriverCmd::OmpSession { - identity, + id, runtime_id, argv, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::omp_session::run(&catalog, identity, runtime_id, argv) + st2::omp_session::run(&catalog, id, runtime_id, argv) } - Command::Driver(DriverCmd::Claude { identity }) => { + Command::Driver(DriverCmd::Claude { id }) => { eprintln!("warning: `st2 driver claude` is deprecated; use `st2 driver claude-mcp`"); let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::claude_mcp::run(&catalog, &identity) + st2::claude_mcp::run(&catalog, &id) } Command::Driver(DriverCmd::ClaudeSession { - identity, + id, runtime_id, argv, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::claude_session::run(&catalog, identity, runtime_id, argv) + st2::claude_session::run(&catalog, id, runtime_id, argv) } Command::Driver(DriverCmd::ClaudeObserve { - identity, + id, runtime_id, event, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::claude_session::run_observe(&catalog, &identity, runtime_id.as_deref(), &event) + st2::claude_session::run_observe(&catalog, &id, runtime_id.as_deref(), &event) } - Command::Driver(DriverCmd::ClaudeStatusline { identity }) => { + Command::Driver(DriverCmd::ClaudeStatusline { id }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::claude_session::run_statusline(&catalog, &identity) + st2::claude_session::run_statusline(&catalog, &id) } Command::Driver(DriverCmd::OpencodeSession { - identity, + id, runtime_id, argv, }) => { let catalog = catalog_arg(None)?; let catalog = catalog.canonicalize().unwrap_or(catalog); - st2::opencode_session::run(&catalog, identity, runtime_id, argv) + st2::opencode_session::run(&catalog, id, runtime_id, argv) } - Command::Driver(DriverCmd::Expand { spec, agent, host }) => { + Command::Driver(DriverCmd::Expand { + spec, + agent, + id, + host, + }) => { let catalog = catalog_arg(None)?; - driver_expand_cmd(&catalog, &spec, agent.as_deref(), host.as_deref()) + driver_expand_cmd( + &catalog, + &spec, + agent.as_deref(), + id.as_deref(), + host.as_deref(), + ) } - Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), + Command::Status { + identity, + id, + set, + ctx, + } => status_cmd(identity, 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, + id, state, reason, host, json, - }) => desired_state_cmd(identity, state, reason, host, json), + }) => desired_state_cmd(id, state, reason, host, json), Command::Agent(AgentCmd::Publish { spec, bundle, @@ -1345,7 +1457,7 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< st2::agent_publish::PublishStatus::Published => "published", st2::agent_publish::PublishStatus::Unchanged => "unchanged", }, - result.bus_id, + result.agent_id, result.path.display() ); } @@ -1542,6 +1654,55 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< } Ok(()) } + Command::Catalog(CatalogCmd::MigrateIds { + host, + dry_run, + json, + }) => { + let catalog = catalog_arg(None)?; + let host = host.unwrap_or_else(detect_host); + match st2::catalog_migrate::migrate(st2::catalog_migrate::MigrateRequest { + catalog, + host, + dry_run, + }) { + Ok(receipt) => { + if json { + println!("{}", serde_json::to_string(&receipt)?); + } else { + println!( + "catalog id migration {}: {} declaration(s), {} supervisor \ + reference(s), {} legacy collision(s){}", + match receipt.status { + st2::catalog_migrate::MigrateStatus::Migrated => "migrated", + st2::catalog_migrate::MigrateStatus::Unchanged => "unchanged", + }, + receipt.migrated.len(), + receipt.supervisors.len(), + receipt.collisions.len(), + if receipt.dry_run { " (dry run)" } else { "" } + ); + } + Ok(()) + } + Err(error) => { + if json + && let Some(refusal) = + error.downcast_ref::() + { + println!( + "{}", + serde_json::json!({ + "result": "error", + "code": refusal.code, + "message": refusal.message, + }) + ); + } + Err(error) + } + } + } Command::Catalog(CatalogCmd::Unarchive { identity, host, @@ -1563,10 +1724,11 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< catalog, status, identity, + id, json, enrich, ctx, - } => agents_cmd(catalog, status, identity, json, enrich, ctx), + } => agents_cmd(catalog, status, identity, id, json, enrich, ctx), Command::Tasks { host, json } => { if !json { anyhow::bail!("`st2 tasks` v1 requires --json"); @@ -1632,6 +1794,7 @@ fn driver_expand_cmd( catalog: &Path, path: &Path, agent: Option<&str>, + id: Option<&str>, host: Option<&str>, ) -> Result<()> { let (mut specs, warnings) = st2::discover_file(catalog, path) @@ -1639,12 +1802,19 @@ fn driver_expand_cmd( for warning in warnings { eprintln!("warning: {warning}"); } - if let Some(agent) = agent { - specs.retain(|spec| spec.identity == agent || spec.bus_id(host.unwrap_or("")) == agent); + let selected = agent.is_some() || id.is_some(); + if selected { + // Declaration inspection resolves the caller's chosen namespace against the file's own + // subjects; the expansion then carries that subject's immutable ID. `--agent` and `--id` + // are disjoint, so neither input is ever reinterpreted in the other namespace. + let selector = agent_selector(id, agent, host)?; + let book = st2::spec::address_book(&specs, host.unwrap_or(""))?; + let resolved = book.resolve(&selector)?.id.as_str().to_owned(); + specs.retain(|spec| spec.agent_id(host.unwrap_or("")) == resolved); } anyhow::ensure!( specs.len() == 1, - if agent.is_some() { + if selected { format!( "{} contains {} matching agent blocks; expected exactly one", path.display(), @@ -1652,7 +1822,7 @@ fn driver_expand_cmd( ) } else { format!( - "{} contains {} agent blocks; use --agent when it contains more than one", + "{} contains {} agent blocks; use --agent or --id when it contains more than one", path.display(), specs.len() ) @@ -2056,19 +2226,21 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re if spec.resolved_host(&this_host) != this_host { continue; } - let bus_id = spec.bus_id(&this_host); + // Ownership keys off the immutable ID; the report labels rows with the human route. + let agent_id = spec.agent_id(&this_host); + let bus_address = spec.bus_address(&this_host); if let Some(dir) = spec.path.parent() { match message::inspect_sent(dir, false) { Ok(_) => report_check( &mut problems, true, - &format!("{bus_id} outbound message ledger"), + &format!("{bus_address} outbound message ledger"), "", ), Err(error) => report_check( &mut problems, false, - &format!("{bus_id} outbound message ledger"), + &format!("{bus_address} outbound message ledger"), &format!("cannot send: {error:#}"), ), } @@ -2080,7 +2252,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re .map(|task| { task.id .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)) + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)) }) .filter_map(|id| { present @@ -2091,7 +2263,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re report_check( &mut problems, still_present.is_empty(), - &format!("{bus_id} retirement complete (all declared tasks absent)"), + &format!("{bus_address} retirement complete (all declared tasks absent)"), &format!("still present: {}", still_present.join(", ")), ); continue; @@ -2104,7 +2276,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re let id = task .id .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)); present.get(&id).and_then(|alive| { (*alive || !(task.keep || spec.keep)).then(|| { format!("{id} ({})", if *alive { "alive" } else { "dead non-keep" }) @@ -2115,14 +2287,14 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re report_check( &mut problems, not_converged.is_empty(), - &format!("{bus_id} suspension effective (no live tasks)"), + &format!("{bus_address} suspension effective (no live tasks)"), &format!("still present: {}", not_converged.join(", ")), ); continue; } if !spec.has_delivery_transport() { report_advisory( - &format!("{bus_id} delivery transport missing"), + &format!("{bus_address} delivery transport missing"), "declare `ding`, `deliver`, or a driver block; agent receives no DING", ); } @@ -2130,11 +2302,11 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re let id = task .id .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)); report_check( &mut problems, live.contains(&id), - &format!("{bus_id} task '{}' alive", task.name), + &format!("{bus_address} task '{}' alive", task.name), "session dead/missing", ); } @@ -2144,7 +2316,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re report_check( &mut problems, false, - &format!("{bus_id} presence missing"), + &format!("{bus_address} presence missing"), "no status file — is its session owner refreshing presence?", ); } else { @@ -2152,7 +2324,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re report_check( &mut problems, state != st2::status::State::Unknown, - &format!("{bus_id} presence fresh (is `{}`)", state.as_str()), + &format!("{bus_address} presence fresh (is `{}`)", state.as_str()), "rotted to `unknown` — is its session owner refreshing presence?", ); } @@ -2163,12 +2335,12 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re let probe = |session: &str| st2::ding::session_liveness_in(&pty_root, session); match st2::harness_state::read(&observed_path, Some(&probe)) { None => report_advisory( - &format!("{bus_id} observed harness state absent"), + &format!("{bus_address} observed harness state absent"), "no driver has published a harness-state record for this agent", ), Some(observed) if observed.state == st2::harness_state::Activity::Unknown => { report_advisory( - &format!("{bus_id} observed harness state indeterminate"), + &format!("{bus_address} observed harness state indeterminate"), &format!( "derived `unknown` ({}) — is its driver still observing the harness?", observed.reason.as_deref().unwrap_or("unstated") @@ -2180,7 +2352,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re && spec.desired_state.as_str() == "running" => { report_advisory( - &format!("{bus_id} observed harness state ended"), + &format!("{bus_address} observed harness state ended"), &format!( "session ended ({}) while desired state is running — crashed seat?", observed @@ -2201,7 +2373,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re &mut problems, true, &format!( - "{bus_id} observed harness state fresh (is `{}`)", + "{bus_address} observed harness state fresh (is `{}`)", observed.state.as_str() ), "", @@ -2222,7 +2394,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re }) { report_advisory( &format!( - "{bus_id} harness context at {}%", + "{bus_address} harness context at {}%", context.used_percent.unwrap_or_default().round() ), &format!( @@ -2235,7 +2407,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re } if context.stale && spec.desired_state.as_str() == "running" { report_advisory( - &format!("{bus_id} harness context stale"), + &format!("{bus_address} harness context stale"), &format!( "the numbers are {} old while desired state is running — is its \ driver still reading the harness?", @@ -2249,7 +2421,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re match &diagnostic { st2::driver_diagnostic::Observed::Failure(failure) => report_advisory( &format!( - "{bus_id} native driver diagnostic: {}/{}", + "{bus_address} native driver diagnostic: {}/{}", failure.stage.as_str(), failure.reason.as_str() ), @@ -2257,7 +2429,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re ), st2::driver_diagnostic::Observed::Indeterminate(reason) => report_advisory( &format!( - "{bus_id} native driver diagnostic indeterminate ({})", + "{bus_address} native driver diagnostic indeterminate ({})", reason.as_str() ), st2::driver_diagnostic::repair_text(&diagnostic), @@ -2267,7 +2439,7 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re st2::driver_diagnostic::Observed::Absent => { if st2::driver_diagnostic::absence_is_a_fault(spec) { report_advisory( - &format!("{bus_id} native driver diagnostic absent"), + &format!("{bus_address} native driver diagnostic absent"), st2::driver_diagnostic::repair_text(&diagnostic), ); } @@ -2407,7 +2579,7 @@ fn presentation_cmd( args: PresentationArgs, ) -> Result<()> { let PresentationArgs { - identity, + id, value, clear, json, @@ -2415,15 +2587,13 @@ fn presentation_cmd( } = args; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); - let actor = std::env::var("ST_AGENT") - .ok() - .filter(|value| !value.is_empty()); + let actor = ambient_author_actor(); let requested = if clear { None } else { value.as_deref() }; match st2::agent_author::set_presentation( &root, - &identity, + &id, &host, - actor.as_deref(), + actor.as_ref(), field, requested, ) { @@ -2454,7 +2624,7 @@ fn presentation_cmd( serde_json::json!({ "result": "error", "code": error.code(), - "identity": identity, + "id": id, "field": field, "error": error.to_string(), }) @@ -2466,7 +2636,7 @@ fn presentation_cmd( } fn desired_state_cmd( - identity: String, + id: String, state: String, reason: Option, host: Option, @@ -2480,14 +2650,12 @@ fn desired_state_cmd( }; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); - let actor = std::env::var("ST_AGENT") - .ok() - .filter(|value| !value.is_empty()); + let actor = ambient_author_actor(); match st2::agent_author::set_desired_state( &root, - &identity, + &id, &host, - actor.as_deref(), + actor.as_ref(), state, reason.as_deref(), ) { @@ -2519,7 +2687,7 @@ fn desired_state_cmd( serde_json::json!({ "result": "error", "code": error.code(), - "identity": identity, + "id": id, "desiredState": state, "error": error.to_string(), }) @@ -2530,12 +2698,82 @@ fn desired_state_cmd( } } -fn status_cmd(identity: Option, set: Option, ctx: MsgCtx) -> Result<()> { - let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(&ctx)?, +/// `st2 agent address --id (
| --clear)`. +/// +/// One atomic address-book cutover: the old effective address becomes unclaimed as soon as the new +/// catalog generation is visible, and st2 keeps no alias, redirect, or route history. +fn address_cmd(args: AddressArgs) -> Result<()> { + let AddressArgs { + id, + value, + clear, + json, + host, + } = args; + let root = catalog_arg(None)?; + let host = host.unwrap_or_else(detect_host); + // `ST_AGENT` is the caller's own immutable agent ID, used as the trusted-fleet guardrail. + let actor = ambient_author_actor(); + let requested = if clear { + None + } else { + Some( + st2::AgentAddress::parse(value.as_deref().unwrap_or_default()) + .map_err(|error| anyhow::anyhow!("{error}"))?, + ) }; + match st2::agent_author::set_address( + &root, + &id, + &host, + actor.as_ref(), + requested.as_ref(), + ) { + Ok(receipt) => { + if json { + println!("{}", serde_json::to_string(&receipt)?); + } else { + println!( + "{} address: {} ({}{})", + receipt.id, + receipt.address, + match receipt.result { + st2::agent_author::AuthorOutcome::Changed => "changed", + st2::agent_author::AuthorOutcome::Unchanged => "unchanged", + }, + match receipt.bus_address.as_deref() { + Some(route) => format!(", routable at {route}"), + None => ", non-routable".to_owned(), + } + ); + } + Ok(()) + } + Err(error) => { + if json { + println!( + "{}", + serde_json::json!({ + "result": "error", + "code": error.code(), + "id": id, + "error": error.to_string(), + }) + ); + } + Err(error.into()) + } + } +} + +fn status_cmd( + identity: Option, + id: Option, + set: Option, + ctx: MsgCtx, +) -> Result<()> { + let (root, host) = resolve_ctx(&ctx)?; + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), &ctx)?; let sp = st2::status::status_path(&agent_dir_of(&root, &id, &host)?); match set { None => println!("{}", st2::status::read_state(&sp).as_str()), @@ -2556,10 +2794,12 @@ fn agents_cmd( catalog: Option, status_filter: Option, identity: Option, + id: Option, json: bool, enrich: bool, mut ctx: MsgCtx, ) -> Result<()> { + let selected = identity.is_some() || id.is_some(); if enrich && !json { anyhow::bail!("--enrich requires --json"); } @@ -2570,12 +2810,12 @@ 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 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() @@ -2587,21 +2827,29 @@ fn agents_cmd( found.errors.len() ); } + // An exact-ID query returns one subject; an ordinary address query fails on absence or + // ambiguity. Either way the row is then selected by immutable ID, never by a route. + let selected_id = selected + .then(|| { + let selector = agent_selector(id.as_deref(), identity.as_deref(), ctx.host.as_deref())?; + Ok::<_, anyhow::Error>(resolve_agent_spec(&found, &selector, &host)?.agent_id(&host)) + }) + .transpose()?; let mut rows = st2::agents::roster_from_discovered(&found, &root, &host); - if let Some(identity) = &identity { - rows.retain(|row| row.identity == *identity); + if let Some(selected_id) = &selected_id { + rows.retain(|row| row.id == *selected_id); anyhow::ensure!( rows.len() == 1, - "expected exactly one Agent Spec with identity `{identity}`, found {}", + "expected exactly one Agent Spec with id `{selected_id}`, found {}", rows.len() ); } if let Some(f) = &status_filter { rows.retain(|r| r.status.as_str() == f); - if let Some(identity) = &identity { + if let Some(selected_id) = &selected_id { anyhow::ensure!( rows.len() == 1, - "Agent Spec `{identity}` does not match status `{f}`" + "Agent Spec `{selected_id}` does not match status `{f}`" ); } } @@ -2687,6 +2935,7 @@ fn context_column(context: Option<&st2::harness_context::Observed>) -> String { fn ding_cmd( session: Option, identity: Option, + id: Option, root: Option, host: Option, interval: u64, @@ -2697,10 +2946,26 @@ fn ding_cmd( host, }; let (catalog_root, this_host) = resolve_ctx(&ctx)?; - let id = acting_id(&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. + // `--id` is the exact agent ID, `--identity` is an ordinary address, and an omitted selector + // defaults from `ST_AGENT` through the ID path. + let selector = match id { + Some(id) => st2::AgentSelector::id(id), + None => acting_selector(&ctx)?, + }; + // DING also serves a catalog-less flat bus (an eval's `ST_ROOT`), which has no address book at + // all. Only a catalog that declares no subject at all admits the literal selector as its box + // name; a populated catalog resolves or refuses. + let found = st2::discover(&catalog_root); + let id = match resolve_agent_spec(&found, &selector, &this_host) { + Ok(spec) => spec.agent_id(&this_host), + Err(error) if found.specs.is_empty() => { + let _ = &error; + selector.as_input().to_owned() + } + Err(error) => return Err(error), + }; + // The pty to poke defaults to the subject's own id — an agent IS its pty, so the session id + // equals the agent id. An explicit positional still overrides for the rare non-agent case. let session = session.unwrap_or_else(|| id.clone()); // 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. @@ -2738,12 +3003,73 @@ fn resolve_ctx(ctx: &MsgCtx) -> Result<(PathBuf, String)> { Ok((root, host)) } -/// The acting identity (`from` / whose inbox is "mine"): `--as`, else `$ST_AGENT`. -fn acting_id(ctx: &MsgCtx) -> Result { +/// The acting subject as a typed selector. +/// +/// `--as` is a human-supplied reference and resolves through the ordinary address algorithm. +/// `$ST_AGENT` is supplied by reconciliation as an exact actor selector carrying the immutable +/// agent ID, so it is consumed through the typed ID path and is never reparsed as an address. +fn acting_selector(ctx: &MsgCtx) -> Result { + if let Some(reference) = ctx.as_id.as_deref().filter(|value| !value.is_empty()) { + return Ok(match ctx.host.as_deref() { + Some(host) => st2::AgentSelector::address_on_host(reference, host), + None => st2::AgentSelector::address(reference), + }); + } + std::env::var("ST_AGENT") + .ok() + .filter(|value| !value.is_empty()) + .map(st2::AgentSelector::id) + .context("no acting identity: pass --as or set $ST_AGENT") +} + +/// The acting subject's immutable agent ID, resolved against the selected catalog. +fn acting_id_in(root: &Path, host: &str, ctx: &MsgCtx) -> Result { + let selector = acting_selector(ctx)?; + let found = st2::discover(root); + Ok(resolve_agent_spec(&found, &selector, host)?.agent_id(host)) +} + +/// The typed selector for the subject a command acts on. +/// +/// `reference` is an ordinary human address, `id` is an exact catalog-global agent ID, and the two +/// are disjoint. When the caller supplies neither, the acting subject defaults from `ST_AGENT` +/// through the typed ID path. Commands that can legitimately address a box outside the catalog's +/// subject namespace (a catalog-less flat root, `--orphan` recovery) keep the selector instead of +/// forcing resolution here, so the box layer decides. +fn subject_selector( + reference: Option<&str>, + id: Option<&str>, + ctx: &MsgCtx, +) -> Result { + match (reference, id) { + (None, None) => acting_selector(ctx), + (reference, id) => agent_selector(id, reference, ctx.host.as_deref()), + } +} + +/// The immutable agent ID of the subject a command acts on. +fn subject_id( + root: &Path, + host: &str, + reference: Option<&str>, + id: Option<&str>, + ctx: &MsgCtx, +) -> Result { + let selector = subject_selector(reference, id, ctx)?; + let found = st2::discover(root); + Ok(resolve_agent_spec(&found, &selector, host)?.agent_id(host)) +} + +/// The acting *service principal* bus endpoint. +/// +/// A principal is not an agent (R10): it has no agent ID, no address book entry, and no place in +/// the catalog's subject namespace. Its endpoint stays a typed principal route and is never +/// resolved through the agent address book. +fn acting_principal(ctx: &MsgCtx) -> Result { ctx.as_id .clone() .or_else(|| std::env::var("ST_AGENT").ok()) - .filter(|s| !s.is_empty()) + .filter(|value| !value.is_empty()) .context("no acting identity: pass --as or set $ST_AGENT") } @@ -2782,12 +3108,21 @@ fn body_or_stdin(body: Option) -> Result { Ok(body) } -/// `[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)> { +/// `[agent] ` positionals resolved to `(agent-id, filename)`. +/// +/// When `second` is present, `first` is an ordinary agent address; otherwise the box belongs to the +/// acting subject, which defaults from `ST_AGENT` through the typed ID path. Either way the caller +/// receives an immutable agent ID, so nothing downstream reparses a route. +fn box_target( + root: &Path, + host: &str, + first: String, + second: Option, + ctx: &MsgCtx, +) -> Result<(String, String)> { match second { - Some(filename) => Ok((first, filename)), - None => Ok((acting_id(ctx)?, first)), + Some(filename) => Ok((subject_id(root, host, Some(&first), None, ctx)?, filename)), + None => Ok((subject_id(root, host, None, None, ctx)?, first)), } } @@ -2803,11 +3138,16 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + // `message send` is the one command whose sender resolution is load-bearing for the + // catalog-generation fence: the message layer must resolve the recipient first and the + // sender second, both inside its own fenced snapshots (R27). Resolving `--as` at the + // CLI boundary would short-circuit the send before the fence ever runs, so this + // command passes the acting selector through instead of a pre-resolved id. + let from = acting_selector(&ctx)?; let body = body_or_stdin(body)?; - let filename = send_resolved_message( + let filename = send_resolved_message_from( &root, - &to, + &st2::AgentSelector::address(&to), &host, &from, subject.as_deref(), @@ -2827,7 +3167,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + let from = acting_id_in(&root, &host, &ctx)?; let my_inbox = resolve_message_inbox(&root, &from, &host)?; let original = message::read_msg(&my_inbox, &filename) .or_else(|inbox_error| { @@ -2838,17 +3178,33 @@ 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"))?; + // Replies are routed by the sender's immutable ID, never by the display-only `from` + // route: a released address is immediately reusable, so an address-routed reply can be + // captured by a different subject. `Err` means the durable collision record exists but + // cannot be read, so no legacy sender can be attributed at all; `None` means the row + // carries no authoritative sender (or an unattributable colliding legacy one). Both + // refuse the reply. + let to = message::reply_target_in(&root, &original, &from) + .with_context(|| { + format!( + "message '{filename}' cannot be attributed to a sender: the catalog's \ + legacy-id-collision record cannot be read" + ) + })? + .with_context(|| { + format!( + "message '{filename}' has no authoritative sender to reply to: it carries \ + no `from-id` and its legacy `from` route cannot be attributed to one \ + subject" + ) + })?; let subject = subject.or_else(|| message::reply_subject(original.subject.as_deref())); let body = body_or_stdin(body)?; - let sent = send_resolved_message( + let sent = send_resolved_message_from( &root, - &to, + &st2::AgentSelector::id(&to), &host, - &from, + &st2::AgentSelector::id(&from), subject.as_deref(), Some(&filename), &[], @@ -2860,6 +3216,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Sent { identity, + id, count, include_body, to, @@ -2868,7 +3225,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = identity.unwrap_or(acting_id(&ctx)?); + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), &ctx)?; let mut view = message::with_resolved_agent_dir(&root, &id, &host, |agent_dir| { message::list_sent(agent_dir, include_body) })?; @@ -2916,6 +3273,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Ls { identity, + id, archive, orphan, count, @@ -2926,11 +3284,11 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(id) => id, - None => acting_id(&ctx)?, - }; - let dir = message::resolve_list_box(&root, &id, &host, archive, orphan)?; + // The box layer owns subject resolution here: it is the only place that knows a + // catalog-less flat root and `--orphan` recovery are legitimate boxes with no subject. + let selector = subject_selector(identity.as_deref(), id.as_deref(), &ctx)?; + let subject = selector.as_input().to_owned(); + let dir = message::select_list_box(&root, &selector, &host, archive, orphan)?; let mut msgs = if archive { message::list_dir(&dir)? } else { @@ -2956,7 +3314,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } let box_name = if archive { "archive" } else { "inbox" }; println!( - "# {} message{} in {id} {box_name}", + "# {} message{} in {subject} {box_name}", msgs.len(), plural(msgs.len()) ); @@ -2976,7 +3334,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; + let (id, filename) = box_target(&root, &host, first, second, &ctx)?; let dir = if archive { message::resolve_archive(&root, &id, &host) } else { @@ -2992,6 +3350,11 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { return Ok(()); } println!("from: {}", m.from.as_deref().unwrap_or("")); + // The authoritative sender identity, when the record carries one. `from` above is a + // display-only route; automation and replies key on this. + if let Some(from_id) = &m.from_id { + println!("from-id: {from_id}"); + } if let Some(s) = &m.subject { println!("subject: {s}"); } @@ -3007,7 +3370,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Archive { first, second, ctx } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; + let (id, filename) = box_target(&root, &host, first, second, &ctx)?; message::archive_resolved_message(&root, &id, &host, &filename)?; println!("archived"); Ok(()) @@ -3046,11 +3409,14 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } } -fn send_resolved_message( +/// Send through the message layer's own fenced resolution, carrying both endpoints as typed +/// selectors so the recipient is resolved first and the sender second. +#[allow(clippy::too_many_arguments)] +fn send_resolved_message_from( root: &Path, - to: &str, + to: &st2::AgentSelector, host: &str, - from: &str, + from: &st2::AgentSelector, subject: Option<&str>, in_reply_to: Option<&str>, tags: &[String], @@ -3061,7 +3427,7 @@ fn send_resolved_message( .ok() .map(|identity| message::ExternalInbox::new(root, &identity)) .transpose()?; - message::send_to_resolved_inbox( + message::send_to_resolved_inbox_from( root, to, host, @@ -3090,6 +3456,10 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { } => { let (root, host) = resolve_ctx(&ctx)?; let body = body_or_stdin(body)?; + let recipient = match ctx.host.as_deref() { + Some(host) => st2::AgentSelector::address_on_host(recipient, host), + None => st2::AgentSelector::address(recipient), + }; let receipt = st2::event::emit( &root, &host, @@ -3137,17 +3507,11 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { } => (name, agent, json, ctx, None, true), }; let (root, host) = resolve_ctx(&ctx)?; - let actor = ctx - .as_id - .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 actor = author_actor(&ctx); + let target = author_target(&ctx, agent, "stream")?; if remove { let receipt = - st2::agent_author::remove_stream(&root, &target, &host, actor.as_deref(), &name)?; + st2::agent_author::remove_stream(&root, &target, &host, actor.as_ref(), &name)?; if json { println!("{}", serde_json::to_string(&receipt)?); } else { @@ -3158,7 +3522,7 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { } } else { let receipt = - st2::agent_author::add_stream(&root, &target, &host, actor.as_deref(), &name, launch)?; + st2::agent_author::add_stream(&root, &target, &host, actor.as_ref(), &name, launch)?; if json { println!("{}", serde_json::to_string(&receipt)?); } else { @@ -3182,8 +3546,12 @@ fn request_cmd(cmd: RequestCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let principal = acting_id(&ctx)?; + let principal = acting_principal(&ctx)?; let body = parse_json_body(body)?; + let to = match ctx.host.as_deref() { + Some(host) => st2::AgentSelector::address_on_host(to, host), + None => st2::AgentSelector::address(to), + }; let receipt = st2::request::publish( &root, &host, @@ -3203,7 +3571,7 @@ fn request_cmd(cmd: RequestCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let agent = acting_id(&ctx)?; + let agent = st2::AgentSelector::id(acting_id_in(&root, &host, &ctx)?); let body = parse_json_body(body)?; let receipt = st2::request::reply( &root, @@ -3221,7 +3589,7 @@ fn request_cmd(cmd: RequestCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let agent = acting_id(&ctx)?; + let agent = st2::AgentSelector::id(acting_id_in(&root, &host, &ctx)?); let request = st2::request::read(&root, &host, &agent, &request_filename)?; if json { println!("{}", serde_json::to_string(&request)?); @@ -3239,7 +3607,7 @@ fn request_cmd(cmd: RequestCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let principal = acting_id(&ctx)?; + let principal = acting_principal(&ctx)?; let status = st2::request::status(&root, &host, &principal, &idempotency_key)?; if json { println!("{}", serde_json::to_string(&status)?); @@ -3298,6 +3666,10 @@ struct LsItemJson<'a> { filename: &'a str, ts: u64, from: Option<&'a str>, + /// `from-id:` — the sender's immutable agent ID. The authoritative sender identity for + /// automation; `from` is a display-only, mutable route. + #[serde(rename = "fromId", skip_serializing_if = "Option::is_none")] + from_id: Option<&'a str>, subject: Option<&'a str>, #[serde(rename = "inReplyTo")] in_reply_to: Option<&'a str>, @@ -3321,6 +3693,7 @@ impl<'a> From<&'a st2::message::Message> for LsItemJson<'a> { filename: &m.filename, ts: m.ts_ms, from: m.from.as_deref(), + from_id: m.from_id.as_deref(), subject: m.subject.as_deref(), in_reply_to: m.in_reply_to.as_deref(), tags: &m.tags, @@ -3350,6 +3723,9 @@ struct MessageJson<'a> { filename: &'a str, ts: u64, from: Option<&'a str>, + /// `from-id:` — the sender's immutable agent ID; the authoritative sender identity. + #[serde(rename = "fromId", skip_serializing_if = "Option::is_none")] + from_id: Option<&'a str>, subject: Option<&'a str>, #[serde(rename = "inReplyTo")] in_reply_to: Option<&'a str>, @@ -3372,6 +3748,7 @@ impl<'a> From<&'a st2::message::Message> for MessageJson<'a> { filename: &m.filename, ts: m.ts_ms, from: m.from.as_deref(), + from_id: m.from_id.as_deref(), subject: m.subject.as_deref(), in_reply_to: m.in_reply_to.as_deref(), tags: &m.tags, @@ -3390,12 +3767,13 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { match cmd { ContextCmd::Read { identity, + id, decisions, full, fresh_within, ctx, } => { - let dir = resolve_context_dir(identity, &ctx)?; + let dir = resolve_context_dir(identity, id, &ctx)?; let view = if full { View::Full } else if decisions { @@ -3413,12 +3791,9 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { print!("{content}"); Ok(()) } - ContextCmd::Write { identity, ctx } => { + ContextCmd::Write { identity, id, ctx } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(identity) => identity, - None => acting_id(&ctx)?, - }; + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), &ctx)?; let content = std::io::read_to_string(std::io::stdin()).context("reading context from stdin")?; message::with_resolved_state_dir( @@ -3434,15 +3809,13 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { } ContextCmd::Append { identity, + id, decision, why, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(identity) => identity, - None => acting_id(&ctx)?, - }; + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), &ctx)?; let filename = message::with_resolved_state_dir( &root, &id, @@ -3488,12 +3861,13 @@ fn claude_channel_cmd(cmd: ClaudeChannelCmd) -> Result<()> { } } -/// Read one agent's declared Resource bindings. Selector resolution mirrors the mediated author -/// (`bus_id` first, then bare identity, unique or refuse) so `ls` and `add` always name the same -/// declaration, and a malformed catalog refuses rather than silently hiding an agent. +/// Read one agent's declared Resource bindings. +/// +/// Selection is the same typed resolution the mediated author uses, so `ls` and `add` always name +/// the same declaration. A malformed catalog refuses rather than silently hiding an agent. fn resource_bindings( root: &Path, - selector: &str, + selector: &st2::AgentSelector, host: &str, ) -> Result<(String, Vec)> { with_resource_bindings_snapshot(root, selector, host, |identity, bindings| { @@ -3501,11 +3875,116 @@ fn resource_bindings( }) } +/// The authoring actor as a typed selector. +/// +/// `$ST_AGENT` is supplied by reconciliation as an exact immutable agent ID and is consumed through +/// the ID namespace; `--as` is a human reference and resolves as an ordinary address. The two are +/// never reinterpreted in each other's namespace, and `--as` keeps precedence over the ambient +/// value exactly as it always has. `None` is the explicit operator path. +fn author_actor(ctx: &MsgCtx) -> Option { + if let Some(reference) = ctx.as_id.as_deref().filter(|value| !value.is_empty()) { + return Some(match ctx.host.as_deref() { + Some(host) => st2::AgentSelector::address_on_host(reference, host), + None => st2::AgentSelector::address(reference), + }); + } + std::env::var("ST_AGENT") + .ok() + .filter(|value| !value.is_empty()) + .map(st2::AgentSelector::id) +} + +/// The ambient authoring actor for a command that has no `MsgCtx`. +fn ambient_author_actor() -> Option { + std::env::var("ST_AGENT") + .ok() + .filter(|value| !value.is_empty()) + .map(st2::AgentSelector::id) +} + +/// The subject a content-authoring command acts on. +/// +/// An explicit `--agent` value is an ordinary address. Omitting it targets the acting subject, which +/// stays in the namespace its own input arrived in — an ambient `$ST_AGENT` id remains an exact id +/// rather than being reparsed as a route. +fn author_target( + ctx: &MsgCtx, + agent: Option, + what: &str, +) -> Result { + if let Some(reference) = agent { + return Ok(match ctx.host.as_deref() { + Some(host) => st2::AgentSelector::address_on_host(reference, host), + None => st2::AgentSelector::address(reference), + }); + } + author_actor(ctx) + .with_context(|| format!("no {what} target: pass --agent, --as, or set $ST_AGENT")) +} + +/// Build one typed selector from a command's mutually exclusive reference inputs. +/// +/// `--id` is an exact catalog-global agent ID and never reaches the address parser. A positional or +/// `--agent` reference is an ordinary human address: a bare agent address or a host-qualified bus +/// address, resolved by the fail-closed candidate algorithm. `ST_AGENT` is consumed through the ID +/// path because reconciliation supplies it as an exact actor selector, not as a route. +fn agent_selector( + id: Option<&str>, + reference: Option<&str>, + pinned_host: Option<&str>, +) -> Result { + match (id, reference) { + (Some(_), Some(_)) => anyhow::bail!( + "`--id` selects an exact agent id and an agent reference selects an address; pass one" + ), + (Some(id), None) => Ok(st2::AgentSelector::id(id)), + (None, Some(reference)) => Ok(match pinned_host { + Some(host) => st2::AgentSelector::address_on_host(reference, host), + None => st2::AgentSelector::address(reference), + }), + (None, None) => anyhow::bail!("no agent selected; pass an agent address or `--id `"), + } +} + +/// Resolve one typed selector against a complete catalog snapshot and return the declaration. +/// +/// The snapshot, the address book, and the returned declaration are the same catalog generation, so +/// an ambiguity diagnostic describes exactly the book the lookup used. +fn resolve_agent_spec<'a>( + found: &'a st2::Discovered, + selector: &st2::AgentSelector, + this_host: &str, +) -> Result<&'a st2::AgentSpec> { + let book = st2::spec::address_book(&found.specs, this_host)?; + let subject = book.resolve(selector)?; + let id = subject.id.as_str(); + // The back-mapping is itself the uniqueness proof for BOTH selector kinds. `resolve_id` + // refuses `AmbiguousId`, but `resolve_address` dedups its candidates BY agent ID, so an + // address naming one of two subjects that share an ID resolves cleanly to a single Subject + // and a first-match scan would return whichever declaration discovery ordered first. + let mut declarations = found + .specs + .iter() + .filter(|spec| spec.agent_id(this_host) == id); + let spec = declarations + .next() + .ok_or_else(|| anyhow::anyhow!("agent id '{id}' resolved but its declaration vanished"))?; + if let Some(duplicate) = declarations.next() { + anyhow::bail!( + "agent id '{id}' is declared by more than one subject ({}, {}); refusing to act on a \ + catalog whose global id namespace is broken — run `st2 catalog validate`", + spec.path.display(), + duplicate.path.display() + ); + } + Ok(spec) +} + /// Resolve one binding projection and let the caller finish consuming that exact catalog snapshot /// before its shared authoring fence is released. fn with_resource_bindings_snapshot( root: &Path, - selector: &str, + selector: &st2::AgentSelector, host: &str, consume: impl FnOnce(String, Vec) -> Result, ) -> Result { @@ -3519,36 +3998,9 @@ fn with_resource_bindings_snapshot( error.message ); } - let exact = found - .specs - .iter() - .filter(|spec| spec.bus_id(host) == selector) - .collect::>(); - let matches = if exact.is_empty() { - found - .specs - .iter() - .filter(|spec| spec.identity == selector) - .collect::>() - } else { - exact - }; - let (identity, bindings) = match matches.as_slice() { - [] => anyhow::bail!("no agent '{selector}' found in catalog {}", root.display()), - [spec] => (spec.bus_id(host), spec.resources.clone()), - many => { - let mut candidates = many - .iter() - .map(|spec| format!("{} ({})", spec.bus_id(host), spec.path.display())) - .collect::>(); - candidates.sort(); - anyhow::bail!( - "agent selector '{selector}' is ambiguous: {}", - candidates.join(", ") - ) - } - }; - consume(identity, bindings) + let spec = resolve_agent_spec(&found, selector, host)?; + // Resource state is anchored to the subject, so the projection is keyed by immutable ID. + consume(spec.agent_id(host), spec.resources.clone()) } #[cfg(debug_assertions)] @@ -3572,15 +4024,14 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { match cmd { ResourceCmd::Ls { identity, + id, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(&ctx)?, - }; - let (identity, bindings) = resource_bindings(&root, &id, &host)?; + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), &ctx)?; + let (identity, bindings) = + resource_bindings(&root, &st2::AgentSelector::id(id), &host)?; if json { println!("{}", serde_json::to_string(&bindings)?); return Ok(()); @@ -3610,8 +4061,9 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, name) = box_target(first, second, &ctx)?; - let (identity, bindings) = resource_bindings(&root, &id, &host)?; + let (id, name) = box_target(&root, &host, first, second, &ctx)?; + let (identity, bindings) = + resource_bindings(&root, &st2::AgentSelector::id(id), &host)?; let binding = bindings .iter() .find(|binding| binding.name() == name) @@ -3641,17 +4093,20 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { } => { let (root, host) = resolve_ctx(&ctx)?; let (selector, name) = match second { - Some(name) => (first, name), - None => { - let selector = match agent { - Some(agent) => agent, - None => acting_id(&ctx)?, - }; - (selector, first) - } + Some(name) => ( + subject_id(&root, &host, Some(&first), None, &ctx)?, + name, + ), + None => ( + subject_id(&root, &host, agent.as_deref(), None, &ctx)?, + first, + ), }; - let (identity, request) = - with_resource_bindings_snapshot(&root, &selector, &host, |identity, bindings| { + let (identity, request) = with_resource_bindings_snapshot( + &root, + &st2::AgentSelector::id(selector), + &host, + |identity, bindings| { bindings .iter() .find(|binding| binding.name() == name) @@ -3667,7 +4122,8 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { None, )?; Ok((identity, request)) - })?; + }, + )?; let request_id = request.request_id.clone(); let client = st2::resource_observe::submit_request(&root, &host, &request)?; let waited = client.wait_for_terminal(Duration::from_secs(wait))?; @@ -3742,7 +4198,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { &root, &target, &host, - actor.as_deref(), + actor.as_ref(), &name, &uri, &reason, @@ -3767,7 +4223,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { } => { let (root, host, actor, target) = resource_author_target(agent, &ctx)?; let receipt = - st2::agent_author::remove_resource(&root, &target, &host, actor.as_deref(), &name)?; + st2::agent_author::remove_resource(&root, &target, &host, actor.as_ref(), &name)?; if json { println!("{}", serde_json::to_string(&receipt)?); } else { @@ -3790,7 +4246,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { &root, &target, &host, - actor.as_deref(), + actor.as_ref(), &old, &new, )?; @@ -3811,26 +4267,24 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { fn resource_author_target( agent: Option, ctx: &MsgCtx, -) -> Result<(PathBuf, String, Option, String)> { +) -> Result<(PathBuf, String, Option, st2::AgentSelector)> { let (root, host) = resolve_ctx(ctx)?; - let actor = ctx - .as_id - .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 actor = author_actor(ctx); + let target = author_target(ctx, agent, "resource binding")?; 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 { +/// Resolve an agent's context dir (`/resources/context`). +/// +/// `identity` is an ordinary agent address and `id` is the exact agent ID; omitting both defaults +/// to the acting subject through the typed `ST_AGENT` path. +fn resolve_context_dir( + identity: Option, + id: Option, + ctx: &MsgCtx, +) -> Result { let (root, host) = resolve_ctx(ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(ctx)?, - }; + let id = subject_id(&root, &host, identity.as_deref(), id.as_deref(), ctx)?; Ok(st2::context::context_dir(&agent_dir_of(&root, &id, &host)?)) } @@ -3953,10 +4407,14 @@ fn up( .specs .retain(|spec| spec.identity == owner_identity && spec.path == owner_path); } - if let Some(identity) = agent.as_deref() { + if let Some(reference) = agent.as_deref() { + // An ordinary human reference selects exactly one subject through the address book; + // the retained declaration is then matched by its immutable ID. + let book = st2::spec::address_book(&found.specs, &this_host)?; + let selected = book.resolve_address(reference, None)?.id.as_str().to_owned(); found .specs - .retain(|spec| spec.identity == identity || spec.bus_id(&this_host) == identity); + .retain(|spec| spec.agent_id(&this_host) == selected); } for warning in &found.warnings { eprintln!("warning: {warning}"); diff --git a/src/materialize.rs b/src/materialize.rs index 913f45cf..92998422 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -390,7 +390,7 @@ fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result BTreeMap { - let bus_id = spec.bus_id(this_host); + // `ST_AGENT` is the raw immutable agent ID: an exact actor selector, never a route. + let agent_id = spec.agent_id(this_host); let mut env = BTreeMap::from([ ("CATALOG".to_string(), root.display().to_string()), ("ST_ROOT".to_string(), root.display().to_string()), @@ -451,7 +452,7 @@ fn render_env(root: &Path, spec: &AgentSpec, this_host: &str) -> BTreeMap BTreeMap>(); if let Err(error) = crate::reconcile::validate_task_identities(ownership_specs, this_host) { report.failed_agents.extend(selected_ids); @@ -1288,8 +1289,8 @@ pub fn materialize_catalog_against( if !spec.desired_state.is_running() || spec.resolved_host(this_host) != this_host { continue; } - let bus_id = spec.bus_id(this_host); - if report.failed_agents.contains(&bus_id) { + let agent_id = spec.agent_id(this_host); + if report.failed_agents.contains(&agent_id) { continue; } match materialize_agent(root, spec, this_host) { @@ -1306,7 +1307,7 @@ pub fn materialize_catalog_against( report .errors .push(format!("{}: {error:#}", spec.path.display())); - report.failed_agents.insert(bus_id); + report.failed_agents.insert(agent_id); } } } @@ -1549,4 +1550,111 @@ mod tests { let rendered: serde_json::Value = serde_json::from_str(settings.0).unwrap(); assert_eq!(rendered, crate::hooks::claude_settings_registration()); } + + /// A migrated declaration whose address differs from its identity: the source of both the + /// exact-ID MCP selector and the raw-ID `ST_AGENT`. + fn migrated_mcp_catalog() -> tempfile::TempDir { + 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" { + id "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1" + host "h" + address "fractal.keymap.verifier" + command "claude" + deliver "mcp" + workspace "$CATALOG" +}"#, + ) + .unwrap(); + tmp + } + + /// The rendered MCP server is automation: it selects the immutable ID through the exact-ID + /// flag, never the address-parsing `--identity` form. + #[test] + fn claude_mcp_render_selects_the_agent_id_through_the_exact_id_flag() { + let tmp = migrated_mcp_catalog(); + 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 rendered = plan + .ops + .iter() + .find_map(|op| match op { + RenderOp::JsonUpsert { + destination, + content, + .. + } if destination == ".mcp.json" => Some(content), + _ => None, + }) + .expect("an mcp seat renders .mcp.json"); + let parsed: serde_json::Value = serde_json::from_str(rendered).unwrap(); + let args = parsed["mcpServers"]["st2"]["args"].as_array().unwrap(); + let args = args + .iter() + .map(|arg| arg.as_str().unwrap()) + .collect::>(); + + let id = args.iter().position(|arg| *arg == "--id").unwrap(); + assert_eq!(args[id + 1], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + assert!( + !args.iter().any(|arg| *arg == "--identity"), + "the generated selector must not be the address-parsing flag: {args:?}" + ); + assert!( + !args.iter().any(|arg| arg.contains("fractal.keymap")), + "no rendered argument may carry a mutable route: {args:?}" + ); + } + + /// `ST_AGENT` in the render environment is the raw immutable agent ID: not the bus address, + /// and not host-concatenated on top of the ID. + #[test] + fn render_env_st_agent_is_the_raw_agent_id() { + let tmp = migrated_mcp_catalog(); + let found = crate::discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let env = render_env(tmp.path(), &found.specs[0], "h"); + + assert_eq!( + env.get("ST_AGENT").map(String::as_str), + Some("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1") + ); + } + + /// Ownership keys are IDs, so an address change cannot repoint a workspace claim or a + /// materialization failure record at a different subject. + #[test] + fn render_ownership_is_keyed_by_agent_id() { + let tmp = tempfile::tempdir().unwrap(); + for (identity, address) in [("left", "shared.route"), ("right", "other.route")] { + let declaration = tmp.path().join(format!("agents/h/{identity}/agent.kdl")); + std::fs::create_dir_all(declaration.parent().unwrap()).unwrap(); + std::fs::write( + &declaration, + format!( + "agent {identity:?} {{\n id \"0199b8f4-8d3a-7c21-9a44-6f85b73200{}\"\n host \"h\"\n address {address:?}\n command \"true\"\n workspace \"$CATALOG\"\n render {{ file \"shared.txt\" {identity:?} }}\n}}", + if identity == "left" { "01" } else { "02" } + ), + ) + .unwrap(); + } + let found = crate::discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + let conflicts = render_ownership_conflicts(tmp.path(), &found.specs, "h"); + assert_eq!(conflicts.len(), 1, "{conflicts:?}"); + assert_eq!( + conflicts[0].owners, + BTreeSet::from([ + "0199b8f4-8d3a-7c21-9a44-6f85b7320001".to_owned(), + "0199b8f4-8d3a-7c21-9a44-6f85b7320002".to_owned(), + ]), + "conflict owners are immutable IDs, not routes" + ); + } } diff --git a/src/message.rs b/src/message.rs index 5b81ccce..ab34baf6 100644 --- a/src/message.rs +++ b/src/message.rs @@ -22,8 +22,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context as _; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; -use st2_wire::message::{SentCoverage, SentMessageRow, SentMessages}; +use st2_wire::message::{EndpointKind, SentCoverage, SentMessageRow, SentMessages}; +use crate::{AddressBook, AgentId, AgentSelector, ResolveError, Subject}; + +/// Version of the sender ledger's head, active pointer, commit nodes, and idempotency receipts. +/// Those shapes are unchanged by DELTA-003; only the message *record* gained a second version. const SENT_VERSION: u32 = 1; const SENT_DIR: &str = "sent"; const SENT_HEAD: &str = "index.json"; @@ -34,6 +38,33 @@ const SENT_PENDING: &str = "pending"; const SENT_LOCK: &str = ".lock"; const SENT_KEYS: &str = "keys"; +/// Durable message-record schema versions (03-message spec, "State ownership"). +/// +/// Version 1 keeps legacy bus identities in `from`/`to`. Version 2 carries an immutable agent ID +/// plus a publication-time bus-address snapshot for an Agent endpoint, and an explicit endpoint +/// kind with a canonical typed address for a principal or external endpoint. +pub const MESSAGE_RECORD_VERSION_1: u32 = 1; +pub const MESSAGE_RECORD_VERSION_2: u32 = 2; + +/// The single switch for the version-2 durable message writer, one member of the DELTA-003 +/// activation cohort. +/// +/// Reader-first was satisfied first: every reader in this module accepts version 1 and version 2, +/// and that landed before this flipped. It is ON because a record whose `from`/`to` carry immutable +/// agent IDs must DECLARE version 2 — under version 1 those fields mean bus identities, and storing +/// an ID there would silently misattribute the endpoint to whatever subject now answers that route. +/// Nothing writes an ID under version 1, and nothing writes a route under version 2. +/// +/// Named and reversible on purpose: activation step 5 flips this together with harness-state v2, +/// harness-context v2, and PTY schema 2, and it must be revertible with them. +pub const WRITE_MESSAGE_RECORD_VERSION_2: bool = true; + +const SENT_RECORD_WRITE_VERSION: u32 = if WRITE_MESSAGE_RECORD_VERSION_2 { + MESSAGE_RECORD_VERSION_2 +} else { + MESSAGE_RECORD_VERSION_1 +}; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct SentHead { @@ -77,6 +108,8 @@ struct SentRecord { version: u32, filename: String, ts: u64, + /// The canonical endpoint. An Agent endpoint is an immutable agent ID; a typed non-Agent + /// endpoint is that endpoint's own canonical address. Never a mutable agent address. from: String, to: String, subject: Option, @@ -86,6 +119,17 @@ struct SentRecord { idempotency_key: Option, body: String, rendered_message: String, + /// Version 2 only: the endpoint's bus address at publication time. Display-only, because a + /// released address is immediately reusable and may already route to a different subject. + #[serde(default, skip_serializing_if = "Option::is_none")] + from_address: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + to_address: Option, + /// Version 2 only: what the endpoint value is. Absent is the version-1 meaning, `agent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + from_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + to_kind: Option, } impl SentRecord { @@ -100,12 +144,47 @@ 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, + } + } + + /// This row's recipient endpoint, as a reader must interpret it. `owner_agent_id` is the + /// current immutable ID of the subject that owns this sender ledger. + fn recipient_endpoint<'a>(&'a self, owner_agent_id: &'a str) -> DurableEndpoint<'a> { + DurableEndpoint { + version: self.version, + value: &self.to, + kind: self.to_kind, + // A sender-owned row's state owner is its sender, never its recipient. + owns_row: false, + owner_agent_id, + } + } + + /// This row's sender endpoint. A sender ledger row is owned by its sender, so a colliding + /// version-1 sender endpoint resolves to that owner's migrated ID. + /// + /// The publication path already knows its own sender, so only the attribution proofs read + /// this; it exists to state the ownership side of `MESSAGE-R04` in one place. + #[cfg(test)] + fn sender_endpoint<'a>(&'a self, owner_agent_id: &'a str) -> DurableEndpoint<'a> { + DurableEndpoint { + version: self.version, + value: &self.from, + kind: self.from_kind, + owns_row: true, + owner_agent_id, } } fn same_operation(&self, candidate: &Self) -> bool { self.from == candidate.from && self.to == candidate.to + && self.from_kind == candidate.from_kind + && self.to_kind == candidate.to_kind + && self.from_address == candidate.from_address + && self.to_address == candidate.to_address && self.subject == candidate.subject && self.in_reply_to == candidate.in_reply_to && self.tags == candidate.tags @@ -116,6 +195,156 @@ impl SentRecord { } } +/// Accept both durable record versions, and refuse a record that mixes them. +/// +/// A version-1 record carrying version-2 fields is not a tolerable older shape, it is a record +/// whose endpoint meaning is undecidable, so it fails closed like any other corruption. +fn validate_sent_record(record: &SentRecord) -> anyhow::Result<()> { + match record.version { + MESSAGE_RECORD_VERSION_1 => anyhow::ensure!( + record.from_kind.is_none() + && record.to_kind.is_none() + && record.from_address.is_none() + && record.to_address.is_none(), + "version-1 sent record carries version-2 endpoint fields" + ), + MESSAGE_RECORD_VERSION_2 => anyhow::ensure!( + record.from_kind.is_some() && record.to_kind.is_some(), + "version-2 sent record is missing an explicit endpoint kind" + ), + version => anyhow::bail!("unsupported sent record version {version}"), + } + Ok(()) +} + +/// The durable collision metadata the legacy-ID migration transaction writes. +/// +/// Migration froze each unmigrated subject's `.` bytes as its agent ID, so a +/// version-1 endpoint is that subject's agent ID by construction — not a fallback, an identity. +/// The one exception is a reassigned legacy bus identity: one subject kept the bytes while an +/// archived subject received a generated ID, so those bytes in a version-1 row denote either +/// subject and cannot be retyped universally (03-message spec, `MESSAGE-R04`). +/// +/// `src/catalog_migrate.rs` owns the on-disk format and implements this for its record; nothing +/// here parses that format. +pub trait LegacyCollisionIndex { + /// True when migration reassigned these legacy `.` bytes. + fn is_reassigned(&self, legacy_bus_identity: &str) -> bool; +} + +/// A catalog that has not run the legacy-ID migration reassigned nothing. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoLegacyCollisions; + +impl LegacyCollisionIndex for NoLegacyCollisions { + fn is_reassigned(&self, _legacy_bus_identity: &str) -> bool { + false + } +} + +/// The migration transaction's durable record, read as a collision index. +/// +/// Only contested endpoints are recorded, because a non-contested one needs no record: its bytes +/// already are that subject's frozen legacy ID. `keeper` and the reassigned IDs are deliberately +/// unused here — the owner's ID comes from the state being read, never from this record, which is +/// what makes it impossible to mis-attribute an archived sender to the live keeper. +impl LegacyCollisionIndex for crate::catalog_migrate::LegacyIdCollisions { + fn is_reassigned(&self, legacy_bus_identity: &str) -> bool { + matches!( + self.attribution(legacy_bus_identity), + crate::catalog_migrate::LegacyEndpoint::Collision { .. } + ) + } +} + +impl LegacyCollisionIndex for &T { + fn is_reassigned(&self, legacy_bus_identity: &str) -> bool { + (**self).is_reassigned(legacy_bus_identity) + } +} + +/// What one durable endpoint denotes, and therefore what authority it carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EndpointAttribution { + /// A canonical immutable agent ID. Carries reply and automation authority. + AgentId(String), + /// A typed non-Agent endpoint's canonical address. Replyable on that transport; never an + /// agent-automation key. + TypedAddress { kind: EndpointKind, address: String }, + /// Colliding version-1 legacy bytes that this row's state owner does not own. Rendered as a + /// historical address, never as the keeping subject's ID, and carrying no reply or automation + /// authority: addressing the live replacement would deliver to the wrong subject. + UnattributedLegacyAddress(String), +} + +impl EndpointAttribution { + /// The canonical target a reply or automation may address, or `None` when this endpoint + /// carries no such authority. + pub fn reply_target(&self) -> Option<&str> { + match self { + Self::AgentId(id) => Some(id), + Self::TypedAddress { address, .. } => Some(address), + Self::UnattributedLegacyAddress(_) => None, + } + } + + /// The immutable agent ID, when this endpoint is an attributed Agent. + pub fn agent_id(&self) -> Option<&str> { + match self { + Self::AgentId(id) => Some(id), + Self::TypedAddress { .. } | Self::UnattributedLegacyAddress(_) => None, + } + } + + /// Human rendering. An unattributed legacy endpoint is explicitly marked as historical so it + /// is never read as a live route. + pub fn display(&self) -> String { + match self { + Self::AgentId(id) => id.clone(), + Self::TypedAddress { address, .. } => address.clone(), + Self::UnattributedLegacyAddress(address) => format!("{address} (historical address)"), + } + } +} + +/// One endpoint read back from a durable message record, with everything a reader needs to decide +/// its attribution. +#[derive(Debug, Clone, Copy)] +pub struct DurableEndpoint<'a> { + /// The record's schema version. + pub version: u32, + /// The persisted endpoint value. + pub value: &'a str, + /// The persisted endpoint kind. Absent means `agent`, the only version-1 meaning. + pub kind: Option, + /// True when this endpoint is the row's own state owner: the sender for a sender-owned row, + /// the recipient for an inbox row. + pub owns_row: bool, + /// That state owner's current immutable agent ID. + pub owner_agent_id: &'a str, +} + +impl DurableEndpoint<'_> { + /// Attribute this endpoint against the migration's durable collision metadata. + pub fn attribute(&self, collisions: &impl LegacyCollisionIndex) -> EndpointAttribution { + let kind = EndpointKind::or_version_1_default(self.kind); + if kind != EndpointKind::Agent { + return EndpointAttribution::TypedAddress { + kind, + address: self.value.to_owned(), + }; + } + // A version-2 Agent endpoint already IS an immutable ID; there is nothing to reinterpret. + if self.version >= MESSAGE_RECORD_VERSION_2 || !collisions.is_reassigned(self.value) { + return EndpointAttribution::AgentId(self.value.to_owned()); + } + if self.owns_row { + return EndpointAttribution::AgentId(self.owner_agent_id.to_owned()); + } + EndpointAttribution::UnattributedLegacyAddress(self.value.to_owned()) + } +} + /// The alphabet st2 *generates* `` from — Crockford base32 (`0-9a-z` minus `i l o u`). This is /// a strict subset of what the reader accepts: the frozen bus grammar is `[0-9a-z]{6}`, so a peer /// may legally use i/l/o/u and [`is_message_filename`] must not reject those. @@ -129,8 +358,13 @@ 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 route. For an Agent sender this is the sender's bus + /// address at publication time: display only, never authority, because an address is mutable + /// and a released one is immediately reusable. pub from: Option, + /// `from-id:` — the sender's immutable agent ID, written by the version-2 message writer. + /// This, not `from`, is the sender's identity for provenance, replies, and automation. + pub from_id: Option, /// `subject:`. pub subject: Option, /// `in-reply-to:` — the filename of the message this replies to. @@ -149,6 +383,67 @@ pub struct Message { pub body: String, } +impl Message { + /// How this inbox row's sender must be attributed. + /// + /// The authoritative field is `from_id`; `from` is a mutable, immediately reusable route and is + /// never authority. The collision-aware version-1 path is used only when the record predates + /// `from_id`. `recipient_agent_id` is the row's own state owner — an inbox row is owned by its + /// recipient — which is what decides whether a colliding legacy endpoint may be attributed. + pub fn sender_attribution( + &self, + recipient_agent_id: &str, + collisions: &impl LegacyCollisionIndex, + ) -> Option { + let (version, value) = match self.from_id.as_deref() { + Some(id) => (MESSAGE_RECORD_VERSION_2, id), + None => (MESSAGE_RECORD_VERSION_1, self.from.as_deref()?), + }; + Some( + DurableEndpoint { + version, + value, + kind: None, + owns_row: false, + owner_agent_id: recipient_agent_id, + } + .attribute(collisions), + ) + } + + /// The canonical endpoint a reply to this message must address. + /// + /// `None` means refuse: either there is no sender at all, or the sender is a colliding + /// version-1 endpoint this row does not own, in which case replying would deliver to whichever + /// subject kept the bytes rather than to the subject that actually wrote the message. + pub fn reply_target( + &self, + recipient_agent_id: &str, + collisions: &impl LegacyCollisionIndex, + ) -> Option { + self.sender_attribution(recipient_agent_id, collisions)? + .reply_target() + .map(str::to_owned) + } +} + +/// [`Message::reply_target`] against the catalog's own durable collision metadata. +/// +/// This is the accessor a reply command should call: it never reads `from`, so an address that has +/// since been released and reused cannot capture the reply. +/// +/// `Err` means the collision record exists but could not be read as this schema, so no legacy +/// endpoint can be attributed at all; `Ok(None)` is the ordinary refusal (no sender, or a +/// contested version-1 endpoint this row does not own). +pub fn reply_target_in( + catalog_root: &Path, + message: &Message, + recipient_agent_id: &str, +) -> anyhow::Result> { + let collisions = crate::catalog_migrate::load_legacy_id_collisions(catalog_root)?; + Ok(message.reply_target(recipient_agent_id, &collisions)) +} + /// Current unix time in milliseconds. pub fn now_ms() -> u64 { SystemTime::now() @@ -202,7 +497,9 @@ pub fn is_message_filename(name: &str) -> bool { .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()) } -/// Render a message file's contents (frontmatter + body). +/// Render a message file's contents (frontmatter + body) for a sender that has no separate +/// immutable ID to declare — a service principal or an external requester, whose canonical +/// endpoint IS the route in `from`. pub fn render_message( from: &str, subject: Option<&str>, @@ -210,11 +507,28 @@ 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) +} + +/// Render a message whose sender IS an Agent, so the route goes in `from:` and the authoritative +/// immutable ID goes in `from-id:`. A reader must key on `from-id`, never on the mutable route. +pub fn render_agent_message( + from_address: &str, + from_id: Option<&str>, + subject: Option<&str>, + in_reply_to: Option<&str>, + tags: &[String], + body: &str, +) -> String { + render_message_with_idempotency(from_address, from_id, subject, in_reply_to, tags, body, None) } +/// `from_id` is written as a separate `from-id:` line only by the version-2 writer. Keeping it out +/// of the version-1 shape is what makes the reader-first rollout byte-identical: an older reader +/// sees exactly the frontmatter it always saw. fn render_message_with_idempotency( from: &str, + from_id: Option<&str>, subject: Option<&str>, in_reply_to: Option<&str>, tags: &[String], @@ -223,6 +537,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")); } @@ -254,6 +571,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(), @@ -283,6 +601,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" => { @@ -321,9 +640,28 @@ pub fn send_to_inbox( in_reply_to: Option<&str>, tags: &[String], body: &str, +) -> anyhow::Result { + send_to_inbox_from_agent(inbox_dir, from, None, subject, in_reply_to, tags, body) +} + +/// [`send_to_inbox`] for a sender that owns an immutable agent ID, which is written as `from-id:`. +/// +/// The ledger-less delivery paths (an external mailbox recipient, or an external sender) must +/// still carry the authoritative sender identity: a reply keys on `from-id`, and for any subject +/// whose ID differs from its address — every subject created after identity migration — the +/// mutable route alone cannot resolve back to the sender. +#[allow(clippy::too_many_arguments)] +pub fn send_to_inbox_from_agent( + 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_agent_message(from, from_id, subject, in_reply_to, tags, body); // 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) { @@ -718,10 +1056,7 @@ fn read_sent_records(directory: &Path) -> anyhow::Result> { anyhow::ensure!(name.ends_with(".json"), "unexpected sent record entry"); let record: SentRecord = serde_json::from_slice(&fs::read(entry.path())?) .with_context(|| format!("reading sent record {}", entry.path().display()))?; - anyhow::ensure!( - record.version == SENT_VERSION, - "unsupported sent record version" - ); + validate_sent_record(&record)?; anyhow::ensure!( sent_record_name(&record.filename) == name, "sent record filename does not match its payload" @@ -754,10 +1089,7 @@ fn read_pending_records(directory: &Path) -> anyhow::Result> { anyhow::ensure!(is_sha256(digest), "invalid pending sent record digest"); let record: SentRecord = serde_json::from_slice(&fs::read(entry.path())?) .with_context(|| format!("reading pending sent record {}", entry.path().display()))?; - anyhow::ensure!( - record.version == SENT_VERSION, - "unsupported sent record version" - ); + validate_sent_record(&record)?; anyhow::ensure!( is_message_filename(&record.filename), "invalid sent record filename" @@ -908,10 +1240,10 @@ impl ExternalInbox { } } -/// Resolve an inbox by stable identity. A proven catalog-less root retains the legacy flat bus; -/// inside a catalog an absent identity always fails closed. -pub fn resolve_inbox(root: &Path, id: &str, host: &str) -> anyhow::Result { - resolve_list_box(root, id, host, false, false) +/// Resolve an inbox by exact immutable agent ID. A proven catalog-less root retains the legacy +/// flat bus; inside a catalog an unknown ID always fails closed. +pub fn resolve_inbox(root: &Path, agent_id: &str, host: &str) -> anyhow::Result { + resolve_list_box(root, agent_id, host, false, false) } /// Resolve a normal declared inbox or one exact eval-owned external requester capability. @@ -934,23 +1266,36 @@ pub fn resolve_inbox_with_external( } } -/// Archive companion to [`resolve_inbox`], with the same stable-ID and catalog-less boundaries. -pub fn resolve_archive(root: &Path, id: &str, host: &str) -> anyhow::Result { - resolve_list_box(root, id, host, true, false) +/// Archive companion to [`resolve_inbox`], with the same exact-ID and catalog-less boundaries. +pub fn resolve_archive(root: &Path, agent_id: &str, host: &str) -> anyhow::Result { + resolve_list_box(root, agent_id, host, true, false) +} + +/// Resolve one box for `message ls` by exact immutable agent ID. +pub fn resolve_list_box( + root: &Path, + agent_id: &str, + host: &str, + archive: bool, + orphan: bool, +) -> anyhow::Result { + select_list_box(root, &AgentSelector::id(agent_id), host, archive, orphan) } /// Resolve one box for `message ls`. /// -/// The permissive flat layout is automatic only when discovery proves that `root` is catalog-less. -/// Once any valid or malformed declaration makes it a catalog, an absent identity is an error. +/// The permissive flat layout is automatic only when discovery proves that `root` is catalog-less: +/// a DING or eval seat under a catalog-less `ST_ROOT` names its box by the literal selector. +/// Once any valid or malformed declaration makes it a catalog, an absent subject is an error. /// `orphan` is the explicit recovery path for inspecting a raw flat box inside such a root. -pub fn resolve_list_box( +pub fn select_list_box( root: &Path, - id: &str, + selector: &AgentSelector, host: &str, archive: bool, orphan: bool, ) -> anyhow::Result { + let id = selector.as_input(); let flat = || { root.join(id) .join(if archive { "archive" } else { "inbox" }) @@ -959,7 +1304,7 @@ pub fn resolve_list_box( return Ok(flat()); } if apply_incomplete(root) { - return resolve_agent_dir(root, id, host)? + return select_agent_dir(root, selector, host)? .map(|agent_dir| { if archive { archive_dir(&agent_dir) @@ -972,17 +1317,23 @@ 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) - }); + let book = crate::spec::address_book(&discovered.specs, host)?; + match book.resolve(selector) { + Ok(subject) => { + let agent_dir = discovered + .specs + .iter() + .find(|spec| spec.agent_id(host) == subject.id.as_str()) + .and_then(|spec| spec.path.parent()) + .context("resolved subject has no identity directory")?; + return Ok(if archive { + archive_dir(agent_dir) + } else { + inbox_dir(agent_dir) + }); + } + Err(ResolveError::UnknownId { .. } | ResolveError::UnknownAddress { .. }) => {} + Err(ambiguous) => return Err(anyhow::Error::new(ambiguous)), } if discovered.specs.is_empty() && discovered.errors.is_empty() { @@ -991,34 +1342,73 @@ 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 exact immutable agent ID to its agent folder. Returns `None` if no subject carries +/// that ID. A retired subject keeps its ID and stays reachable here. pub fn resolve_agent_dir( catalog_root: &Path, - recipient: &str, + agent_id: &str, this_host: &str, ) -> anyhow::Result> { - Ok(resolve_agent_handle(catalog_root, recipient, this_host)?.map(|agent| agent.path)) + select_agent_dir(catalog_root, &AgentSelector::id(agent_id), this_host) +} + +/// [`resolve_agent_dir`] for an already typed selector: an exact immutable ID never falls through +/// to address lookup, and an ordinary human reference never reaches the ID namespace. +pub fn select_agent_dir( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result> { + Ok(resolve_agent_handle(catalog_root, selector, this_host)?.map(|agent| agent.path)) } pub fn with_resolved_agent_dir( catalog_root: &Path, - identity: &str, + agent_id: &str, + this_host: &str, + operation: impl FnOnce(&Path) -> anyhow::Result, +) -> anyhow::Result { + with_resolved_state_dir(catalog_root, agent_id, this_host, &[], true, operation) +} + +pub fn with_selected_agent_dir( + catalog_root: &Path, + selector: &AgentSelector, this_host: &str, operation: impl FnOnce(&Path) -> anyhow::Result, ) -> anyhow::Result { - with_resolved_state_dir(catalog_root, identity, this_host, &[], true, operation) + with_selected_state_dir(catalog_root, selector, this_host, &[], true, operation) } +/// Run an operation against one subject's durable state directory, keyed by exact agent ID. pub fn with_resolved_state_dir( catalog_root: &Path, - identity: &str, + agent_id: &str, + this_host: &str, + components: &[&str], + create: bool, + operation: impl FnOnce(&Path) -> anyhow::Result, +) -> anyhow::Result { + with_selected_state_dir( + catalog_root, + &AgentSelector::id(agent_id), + this_host, + components, + create, + operation, + ) +} + +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 { - match resolve_agent_handle(catalog_root, identity, this_host)? { + let identity = selector.as_input(); + match resolve_agent_handle(catalog_root, selector, this_host)? { Some(agent) => { test_capability_checkpoint(); let path = match agent.capability.as_ref() { @@ -1065,11 +1455,26 @@ pub fn with_resolved_state_dir( /// operation outside the catalog after recipient resolution. pub(crate) fn with_resolved_message_boxes( catalog_root: &Path, - identity: &str, + agent_id: &str, this_host: &str, operation: impl FnOnce(&Path, &Path) -> anyhow::Result, ) -> anyhow::Result { - let agent = resolve_agent_handle(catalog_root, identity, this_host)?.with_context(|| { + with_selected_message_boxes( + catalog_root, + &AgentSelector::id(agent_id), + this_host, + operation, + ) +} + +pub(crate) fn with_selected_message_boxes( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, + operation: impl FnOnce(&Path, &Path) -> anyhow::Result, +) -> anyhow::Result { + let identity = selector.as_input(); + let agent = resolve_agent_handle(catalog_root, selector, this_host)?.with_context(|| { format!( "no agent '{identity}' found in catalog {}", catalog_root.display() @@ -1089,26 +1494,47 @@ pub(crate) fn with_resolved_message_boxes( ) } +/// Resolve one typed selector against a fenced snapshot of the catalog's address book. +/// +/// Ambiguity is an error, not an absent subject: two subjects answering one human reference is a +/// catalog the caller must not silently pick from (root `spec.md`, fail-closed resolution). fn resolve_agent_handle( catalog_root: &Path, - recipient: &str, + selector: &AgentSelector, this_host: &str, ) -> 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))); + let book = AddressBook::new( + candidates + .iter() + .map(|candidate| candidate.subject.clone()) + .collect(), + ); + let id = match book.resolve(selector) { + Ok(subject) => subject.id.clone(), + Err(ResolveError::UnknownId { .. } | ResolveError::UnknownAddress { .. }) => { + return Ok(None); + } + Err(ambiguous) => return Err(anyhow::Error::new(ambiguous)), + }; + let mut matched = candidates + .into_iter() + .filter(|candidate| candidate.subject.id == id) + .collect::>(); + matched.sort_by(|left, right| left.path.cmp(&right.path)); + matched.dedup_by(|left, right| left.path == right.path); + return Ok((matched.len() == 1).then(|| matched.remove(0))); } - anyhow::bail!("catalog address book changed repeatedly while resolving {recipient:?}") + anyhow::bail!( + "catalog address book changed repeatedly while resolving {:?}", + selector.as_input() + ) } fn address_fence( @@ -1154,12 +1580,24 @@ fn test_address_fence_checkpoint() {} #[derive(Debug)] struct AddressableAgent { - bus_id: String, - identity: String, + /// The subject as the address book sees it: immutable ID, host, effective address, routability. + subject: Subject, path: PathBuf, capability: Option, } +impl AddressableAgent { + /// The immutable agent ID — the durable endpoint, ownership key, and automation selector. + fn agent_id(&self) -> &str { + self.subject.id.as_str() + } + + /// The current human route, or `None` for a proved non-routable subject. + fn bus_address(&self) -> Option { + self.subject.bus_address() + } +} + fn addressable_agent_dirs( catalog_root: &Path, this_host: &str, @@ -1177,8 +1615,7 @@ fn addressable_agent_dirs( .to_path_buf(); let capability = crate::catalog_transaction::open_dir_beneath(catalog_root, &path)?; Ok(AddressableAgent { - bus_id: spec.bus_id(this_host), - identity: spec.identity, + subject: spec.subject(this_host)?, path, capability: Some(capability), }) @@ -1208,17 +1645,29 @@ fn addressable_agent_dirs( let path = identity.path(); let capability = crate::catalog_transaction::open_dir_beneath(catalog_root, &path)?; let retained = crate::catalog_transaction::retained_dir_path(&capability)?; - let current_spec = marker_spec_matches(&retained, &key)?; + let declared = marker_subject(&retained, &key)?; let retained_state = transition.original_agents.contains(&key) && marker_state_exists(&retained)?; - if current_spec || retained_state { - result.push(AddressableAgent { - bus_id: format!("{}.{}", key.host, key.identity), - identity: key.identity, - path, - capability: Some(capability), - }); - } + // A current declaration is authoritative even mid-transition: it is the only place the + // subject's explicit `id` and `address` exist, and resolving a migrated subject by the + // positional key alone would make it unreachable exactly while an apply is incomplete. + // A retained-state-only entry has no readable declaration, so the positional key is + // all there is — which is also the identity migration would have frozen for it. + let subject = match (declared, retained_state) { + (Some(subject), _) => subject, + (None, true) => Subject { + id: AgentId::frozen_legacy(&key.host, &key.identity)?, + host: key.host, + effective_address: key.identity, + routable: true, + }, + (None, false) => continue, + }; + result.push(AddressableAgent { + subject, + path, + capability: Some(capability), + }); } } Ok(result) @@ -1266,14 +1715,19 @@ fn safe_entry_name(entry: &fs::DirEntry, label: &str) -> anyhow::Result Ok(value) } -fn marker_spec_matches( +/// The subject a current canonical declaration at this key declares, if any. +/// +/// `None` means no declaration at that path currently claims this `/` slot; the +/// caller then falls back to retained state. Returning the SUBJECT rather than a bool is what +/// keeps a migrated agent addressable by its explicit `id` while an apply is incomplete. +fn marker_subject( agent_dir: &Path, key: &crate::catalog_transaction::AgentKey, -) -> anyhow::Result { +) -> anyhow::Result> { let path = agent_dir.join("agent.kdl"); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.into()), }; anyhow::ensure!( @@ -1282,9 +1736,27 @@ fn marker_spec_matches( path.display() ); let declared = crate::discovery::parse_declared(&path)?; - Ok(declared.len() == 1 - && declared[0].host.as_deref() == Some(&key.host) - && declared[0].identity.as_deref() == Some(key.identity.as_str())) + if declared.len() != 1 + || declared[0].host.as_deref() != Some(&key.host) + || declared[0].identity.as_deref() != Some(key.identity.as_str()) + { + return Ok(None); + } + let declared = &declared[0]; + Ok(Some(Subject { + id: match declared.id.as_deref() { + Some(id) => AgentId::parse(id)?, + None => AgentId::frozen_legacy(&key.host, &key.identity)?, + }, + host: key.host.clone(), + effective_address: declared + .address + .clone() + .unwrap_or_else(|| key.identity.clone()), + // Mid-transition addressability exists to keep the STATE plane reachable while the + // declaration plane is fenced, so it does not re-derive routability from desired state. + routable: true, + })) } fn marker_state_exists(agent_dir: &Path) -> anyhow::Result { @@ -1437,25 +1909,46 @@ pub fn collect_thread(catalog_root: &Path, filename: &str) -> anyhow::Result &str { + /// The canonical value a durable record persists: an immutable agent ID for an Agent, the + /// canonical address for a typed non-Agent endpoint. + fn endpoint(&self) -> &str { match self { - Self::Agent(agent) => &agent.bus_id, - Self::External { bus_id, .. } | Self::Flat { bus_id, .. } => bus_id, + Self::Agent(agent) => agent.agent_id(), + Self::External { address, .. } | Self::Flat { address, .. } => address, + } + } + + fn kind(&self) -> EndpointKind { + match self { + Self::Agent(_) => EndpointKind::Agent, + Self::External { .. } | Self::Flat { .. } => EndpointKind::External, + } + } + + /// The publication-time bus-address snapshot, for an Agent endpoint that is routable now. + fn address_snapshot(&self) -> Option { + match self { + Self::Agent(agent) => agent.bus_address(), + Self::External { .. } | Self::Flat { .. } => None, } } @@ -1485,29 +1978,35 @@ fn catalogless(root: &Path) -> bool { && discovered.errors.is_empty() } +/// Resolve a recipient named by an ordinary human reference. +/// +/// `--to` is a route by design, so this is the one place inside the message layer that still +/// accepts free-form human input — and it resolves through the address book to an immutable ID +/// before anything durable is written. 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)? { return Ok(DeliveryEndpoint::Agent(agent)); } + let recipient = recipient.as_input(); if let Some(external) = external && external.root == root && external.identity == recipient && external.inbox.is_dir() { return Ok(DeliveryEndpoint::External { - bus_id: external.identity.clone(), + address: external.identity.clone(), inbox: external.inbox.clone(), archive: sibling_archive_dir(&external.inbox), }); } if catalogless(root) { return Ok(DeliveryEndpoint::Flat { - bus_id: recipient.to_string(), + address: recipient.to_string(), inbox: root.join(recipient).join("inbox"), archive: root.join(recipient).join("archive"), }); @@ -1515,6 +2014,31 @@ fn resolve_delivery_endpoint( anyhow::bail!("no agent '{recipient}' found in catalog {}", root.display()) } +/// The acting sender, as a durable record must persist it. +struct SenderEndpoint { + /// Immutable agent ID, or the canonical address of a typed non-Agent sender. + endpoint: String, + kind: EndpointKind, + /// Publication-time bus address, for a routable Agent sender. + address: Option, +} + +impl SenderEndpoint { + /// What the rendered `from:` frontmatter line says: an Agent's human route when it has one, + /// otherwise the canonical endpoint itself. + fn rendered_from(&self) -> &str { + self.address.as_deref().unwrap_or(&self.endpoint) + } + + /// The `from-id:` line, written only by the version-2 writer and only for an Agent. + fn rendered_from_id(&self) -> Option<&str> { + (WRITE_MESSAGE_RECORD_VERSION_2 && self.kind == EndpointKind::Agent) + .then_some(self.endpoint.as_str()) + } +} + +/// Send to `recipient`, an ordinary human reference, on behalf of the exact immutable agent ID +/// `from`. #[allow(clippy::too_many_arguments)] pub fn send_to_resolved_inbox( catalog_root: &Path, @@ -1527,12 +2051,54 @@ pub fn send_to_resolved_inbox( body: &str, idempotency_key: Option<&str>, external: Option<&ExternalInbox>, +) -> anyhow::Result { + send_to_resolved_inbox_from( + catalog_root, + &AgentSelector::address(recipient), + this_host, + &AgentSelector::id(from), + subject, + in_reply_to, + tags, + body, + idempotency_key, + external, + ) +} + +/// [`send_to_resolved_inbox`] where both endpoints are still typed selectors. +/// +/// `recipient` is typed because a reply must address the original sender's *immutable ID* — an +/// ordinary address lookup would deliver to whoever holds those route bytes now. +/// +/// Both endpoints are resolved HERE, and the recipient is resolved first. That ordering is the +/// `R27` fencing contract: the recipient lookup is the one that must observe a catalog generation +/// change and refuse a stale recipient. Resolving the acting sender earlier — at the CLI boundary, +/// say — moves the first fenced read off the recipient and lets an unrelated `--as` failure +/// short-circuit the send before the fence is ever crossed. +#[allow(clippy::too_many_arguments)] +pub fn send_to_resolved_inbox_from( + catalog_root: &Path, + recipient: &AgentSelector, + this_host: &str, + from: &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)?; } + // Unpinned: `message send` takes no `--host` for the recipient, so a fully qualified + // `.
` must reach a subject on ANY admitted logical host, exactly as the + // positional path did. Pinning the sending host here would make every cross-host qualified + // recipient unresolvable. let recipient = resolve_delivery_endpoint(catalog_root, recipient, this_host, external)?; let sender = resolve_agent_handle(catalog_root, from, this_host)?; + let from = from.as_input(); let external_sender = external.is_some_and(|external| external.root == catalog_root && external.identity == from); if matches!(&recipient, DeliveryEndpoint::External { .. }) || external_sender { @@ -1540,17 +2106,35 @@ 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 - .as_ref() - .map(|agent| agent.bus_id.as_str()) - .unwrap_or(from); anyhow::ensure!( sender.is_some() || external_sender, "no agent '{from}' found in catalog {}", catalog_root.display() ); + let canonical_from = match sender.as_ref() { + Some(agent) => SenderEndpoint { + endpoint: agent.agent_id().to_owned(), + kind: EndpointKind::Agent, + address: agent.bus_address(), + }, + None => SenderEndpoint { + endpoint: from.to_owned(), + kind: EndpointKind::External, + address: None, + }, + }; let (inbox, _) = recipient.boxes()?; - return send_to_inbox(&inbox, canonical_from, subject, in_reply_to, tags, body); + // This path owns no sender ledger, but it must still persist the same immutable sender + // identity the ledger path renders: a reply keys on `from-id`, never on the route. + return send_to_inbox_from_agent( + &inbox, + canonical_from.rendered_from(), + canonical_from.rendered_from_id(), + subject, + in_reply_to, + tags, + body, + ); } let (canonical_from, sender_root) = match sender.as_ref() { Some(agent) => { @@ -1558,9 +2142,23 @@ pub fn send_to_resolved_inbox( Some(capability) => crate::catalog_transaction::retained_dir_path(capability)?, None => agent.path.clone(), }; - (agent.bus_id.clone(), path) + ( + SenderEndpoint { + endpoint: agent.agent_id().to_owned(), + kind: EndpointKind::Agent, + address: agent.bus_address(), + }, + path, + ) } - None if catalogless(catalog_root) => (from.to_string(), catalog_root.join(from)), + None if catalogless(catalog_root) => ( + SenderEndpoint { + endpoint: from.to_string(), + kind: EndpointKind::External, + address: None, + }, + catalog_root.join(from), + ), None => anyhow::bail!( "no agent '{from}' found in catalog {}", catalog_root.display() @@ -1588,7 +2186,7 @@ fn send_with_ledger( this_host: &str, external: Option<&ExternalInbox>, sender_root: &Path, - from: &str, + from: &SenderEndpoint, recipient: DeliveryEndpoint, subject: Option<&str>, in_reply_to: Option<&str>, @@ -1604,15 +2202,25 @@ 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 rendered_message = render_message_with_idempotency( + from.rendered_from(), + from.rendered_from_id(), + subject, + in_reply_to, + tags, + body, + idempotency_key, + ); let parsed = parse_message(&filename, &rendered_message); + // Every version decision in this module funnels through SENT_RECORD_WRITE_VERSION; the typed + // endpoint and snapshot fields exist exactly when the record claims version 2. + let version_2 = SENT_RECORD_WRITE_VERSION == MESSAGE_RECORD_VERSION_2; let candidate = SentRecord { - version: SENT_VERSION, + version: SENT_RECORD_WRITE_VERSION, filename, ts: parsed.ts_ms, - from: from.to_string(), - to: recipient.bus_id().to_string(), + from: from.endpoint.clone(), + to: recipient.endpoint().to_string(), subject: parsed.subject, in_reply_to: parsed.in_reply_to, tags: parsed.tags, @@ -1620,6 +2228,10 @@ fn send_with_ledger( idempotency_key: parsed.idempotency_key, body: parsed.body, rendered_message, + from_address: version_2.then(|| from.address.clone()).flatten(), + to_address: version_2.then(|| recipient.address_snapshot()).flatten(), + from_kind: version_2.then_some(from.kind), + to_kind: version_2.then_some(recipient.kind()), }; if let Some(existing) = keyed_record(&root, &candidate)? { return Ok(existing.filename); @@ -1717,10 +2329,7 @@ fn validate_sent_tip(root: &Path, head: &SentHead) -> anyhow::Result<()> { .join(SENT_MESSAGES) .join(sent_record_name(&node.filename)); let row: SentRecord = serde_json::from_slice(&fs::read(row_path)?)?; - anyhow::ensure!( - row.version == SENT_VERSION, - "unsupported sent record version" - ); + validate_sent_record(&row)?; anyhow::ensure!( row.filename == node.filename, "sent record filename does not match payload" @@ -1788,9 +2397,35 @@ 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)?; + // A persisted endpoint is canonical, never a route, so recovery re-resolves it by exact ID + // rather than re-running address lookup: an address change between the crash and the retry + // must not redirect the interrupted delivery. + // + // A version-1 endpoint is only an ID by construction while its legacy bytes were never + // reassigned. If migration contested them, this row does not own them — a sender ledger row is + // owned by its SENDER — so the bytes denote either the archived subject that wrote the intent + // or the live subject that kept them. Delivering to the keeper would hand a stranger a message + // it was never sent, so an unattributed endpoint refuses instead (`MESSAGE-R04`). + let collisions = crate::catalog_migrate::load_legacy_id_collisions(catalog_root) + .context("read legacy-id collision metadata for an interrupted send")?; + let attribution = record + .recipient_endpoint(&record.from) + .attribute(&collisions); + let target = attribution.reply_target().with_context(|| { + format!( + "pending recipient '{}' is a legacy bus identity migration reassigned; \ + this interrupted send cannot prove which subject it was addressed to", + record.to + ) + })?; + let recipient = resolve_delivery_endpoint( + catalog_root, + &AgentSelector::id(target), + this_host, + external, + )?; anyhow::ensure!( - recipient.bus_id() == record.to, + recipient.endpoint() == target, "pending recipient identity changed" ); deliver_record(&recipient, &record)?; @@ -1870,10 +2505,7 @@ fn read_sent_record(root: &Path, filename: &str) -> anyhow::Result { let path = root.join(SENT_MESSAGES).join(sent_record_name(filename)); let record: SentRecord = serde_json::from_slice(&fs::read(&path)?) .with_context(|| format!("reading sent record {}", path.display()))?; - anyhow::ensure!( - record.version == SENT_VERSION, - "unsupported sent record version" - ); + validate_sent_record(&record)?; anyhow::ensure!( record.filename == filename, "sent record filename does not match payload" @@ -1981,10 +2613,7 @@ fn keyed_record(root: &Path, candidate: &SentRecord) -> anyhow::Result agent, None => { let discovered = crate::discover(catalog_root); @@ -2517,8 +3146,16 @@ mod tests { "agent \"mix.sup\" {\n identity \"mix.sup\"\n name \"Shared Worker\"\n host \"h\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", ) .unwrap(); + // Inside a catalog the box is reached by the subject's exact immutable ID. assert_eq!( - resolve_inbox(root, "mix.sup", "h").unwrap(), + resolve_inbox(root, "h.mix.sup", "h").unwrap(), + ad.join("resources").join("inbox") + ); + // An exact-ID lookup never falls through to address lookup: the bare effective address + // `mix.sup` names this subject as a route, and that is deliberately not an ID. + assert!(resolve_inbox(root, "mix.sup", "h").is_err()); + assert_eq!( + select_list_box(root, &AgentSelector::address("mix.sup"), "h", false, false).unwrap(), ad.join("resources").join("inbox") ); assert!(resolve_inbox(root, "Shared Worker", "h").is_err()); @@ -2536,6 +3173,56 @@ mod tests { assert!(resolve_inbox_with_external(root, "missing", "h", Some(&external)).is_err()); } + /// The external-mailbox path owns no sender ledger, but a reply keys on `from-id`: without it + /// the delivered row carries only the mutable route, and for a subject whose ID differs from + /// its address the reply cannot resolve at all. + #[test] + fn an_external_mailbox_delivery_carries_the_immutable_sender_id() { + const SENDER_ID: &str = "0199c0de-0000-7000-8000-00000000dead"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let directory = root.join("h").join("sender"); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("agent.kdl"), + format!( + "agent \"sender\" {{\n identity \"sender\"\n id {SENDER_ID:?}\n address \"renamed\"\n host \"h\"\n type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" + ), + ) + .unwrap(); + let external = ExternalInbox::provision(root, "requester").unwrap(); + + let filename = send_to_resolved_inbox_from( + root, + &AgentSelector::address("requester"), + "h", + &AgentSelector::id(SENDER_ID), + Some("done"), + None, + &[], + "body", + None, + Some(&external), + ) + .unwrap(); + + let delivered = fs::read_to_string(external.inbox.join(&filename)).unwrap(); + let parsed = parse_message(&filename, &delivered); + assert_eq!(parsed.from.as_deref(), Some("h.renamed")); + assert_eq!( + parsed.from_id.as_deref(), + Some(SENDER_ID), + "the external mailbox row must carry the immutable sender ID: {delivered}" + ); + assert_eq!( + reply_target_in(root, &parsed, "requester") + .unwrap() + .as_deref(), + Some(SENDER_ID), + "a reply from that mailbox must address the sender's immutable ID" + ); + } + fn addressable_catalog(root: &Path) -> PathBuf { let agent = root.join("agents/host/worker"); fs::create_dir_all(&agent).unwrap(); @@ -2562,7 +3249,8 @@ mod tests { let agents = addressable_agent_dirs(root.path(), "host", Some(&transition)).unwrap(); assert_eq!(agents.len(), 1); - assert_eq!(agents[0].bus_id, "host.worker"); + assert_eq!(agents[0].agent_id(), "host.worker"); + assert_eq!(agents[0].bus_address().as_deref(), Some("host.worker")); assert_eq!( fs::read(&legacy).unwrap(), b"stale legacy staging bytes", @@ -2570,6 +3258,52 @@ mod tests { ); } + /// An incomplete apply fences the DECLARATION plane, not the state plane. A migrated subject + /// must therefore stay reachable by its explicit `id` while the marker is up — deriving the + /// subject from the positional `/` key alone made every migrated agent + /// unaddressable exactly when its state was still supposed to be writable. + #[test] + fn transition_addressability_honours_a_declared_id_and_address() { + let root = tempfile::tempdir().unwrap(); + let agent = root.path().join("agents/host/worker"); + fs::create_dir_all(&agent).unwrap(); + fs::write( + agent.join("agent.kdl"), + "agent \"worker\" { identity \"worker\"; id \"worker-uuid\"; address \"chat\"; host \"host\"; command \"true\" }\n", + ) + .unwrap(); + let transition = crate::catalog_transaction::CatalogTransition { + original_agents: BTreeSet::new(), + }; + + let agents = addressable_agent_dirs(root.path(), "host", Some(&transition)).unwrap(); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].agent_id(), "worker-uuid"); + assert_eq!(agents[0].bus_address().as_deref(), Some("host.chat")); + } + + /// With no readable declaration, retained state is all there is — and the positional key is + /// exactly the identity migration would have frozen for that subject. + #[test] + fn transition_addressability_falls_back_to_the_positional_key_for_retained_state() { + let root = tempfile::tempdir().unwrap(); + let agent = root.path().join("agents/host/worker"); + fs::create_dir_all(agent.join("resources")).unwrap(); + let key = crate::catalog_transaction::AgentKey { + host: "host".to_owned(), + identity: "worker".to_owned(), + }; + let transition = crate::catalog_transaction::CatalogTransition { + original_agents: BTreeSet::from([key]), + }; + + let agents = addressable_agent_dirs(root.path(), "host", Some(&transition)).unwrap(); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].agent_id(), "host.worker"); + } + #[cfg(unix)] #[test] fn transition_addressability_rejects_legacy_type_confusion_and_near_misses() { @@ -2623,4 +3357,380 @@ mod tests { ); } } + + /// A minimal committed record. Version-1 shaped; the version-2 tests widen it explicitly. + fn record(from: &str, to: &str) -> SentRecord { + SentRecord { + version: MESSAGE_RECORD_VERSION_1, + filename: "1785000000000-abcdef.md".to_owned(), + ts: 1_785_000_000_000, + from: from.to_owned(), + to: to.to_owned(), + subject: None, + in_reply_to: None, + tags: Vec::new(), + priority: None, + idempotency_key: None, + body: "hello\n".to_owned(), + rendered_message: "---\nfrom: h.sender\n---\nhello\n".to_owned(), + from_address: None, + to_address: None, + from_kind: None, + to_kind: None, + } + } + + /// A version-2 Agent record keeps IDs in `from`/`to` and addresses only as snapshots, so an + /// address change can never rewrite history or retarget a reply. + #[test] + fn a_version_2_agent_record_round_trips_its_id_and_address_snapshot() { + let candidate = SentRecord { + version: MESSAGE_RECORD_VERSION_2, + from_address: Some("dev3.verifier".to_owned()), + to_address: Some("dev4.chat".to_owned()), + from_kind: Some(EndpointKind::Agent), + to_kind: Some(EndpointKind::Agent), + ..record( + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", + "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3", + ) + }; + validate_sent_record(&candidate).expect("a complete version-2 record is readable"); + + let json = serde_json::to_value(&candidate).unwrap(); + assert_eq!(json["version"], 2); + assert_eq!(json["from"], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + assert_eq!(json["fromAddress"], "dev3.verifier"); + assert_eq!(json["toAddress"], "dev4.chat"); + assert_eq!(json["toKind"], "agent"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + candidate + ); + + // The snapshot is display-only: it reaches the wire row but is never the row's `to`. + let row = candidate.row(false); + assert_eq!(row.to, "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3"); + assert_eq!(row.to_address.as_deref(), Some("dev4.chat")); + assert_eq!(row.to_kind, Some(EndpointKind::Agent)); + } + + /// A principal endpoint keeps its canonical address in `to` and says so, so no reader can + /// mistake that address for an agent ID or hand it agent automation authority. + #[test] + fn a_typed_non_agent_endpoint_persists_its_kind_and_canonical_address() { + let candidate = SentRecord { + version: MESSAGE_RECORD_VERSION_2, + from_kind: Some(EndpointKind::Agent), + to_kind: Some(EndpointKind::Principal), + from_address: Some("dev3.verifier".to_owned()), + ..record("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "dev3.billing") + }; + validate_sent_record(&candidate).expect("a typed principal endpoint is readable"); + + let attribution = candidate + .recipient_endpoint("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1") + .attribute(&NoLegacyCollisions); + assert_eq!( + attribution, + EndpointAttribution::TypedAddress { + kind: EndpointKind::Principal, + address: "dev3.billing".to_owned(), + } + ); + assert_eq!(attribution.reply_target(), Some("dev3.billing")); + // Replyable on its own transport, but never an agent-automation key. + assert_eq!(attribution.agent_id(), None); + } + + /// Reader-first: the same reader accepts both shapes. This is the invariant that lets version-2 + /// writers be enabled later without a flag day. + #[test] + fn the_sent_reader_accepts_both_version_1_and_version_2_records() { + let tmp = tempfile::tempdir().unwrap(); + let directory = tmp.path().join(SENT_MESSAGES); + fs::create_dir_all(&directory).unwrap(); + + let legacy = record("h.sender", "h.recipient"); + let modern = SentRecord { + version: MESSAGE_RECORD_VERSION_2, + filename: "1785000000001-bcdefg.md".to_owned(), + from_kind: Some(EndpointKind::Agent), + to_kind: Some(EndpointKind::Agent), + to_address: Some("h.recipient".to_owned()), + ..record("h.sender", "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3") + }; + for candidate in [&legacy, &modern] { + fs::write( + directory.join(sent_record_name(&candidate.filename)), + serde_json::to_vec(candidate).unwrap(), + ) + .unwrap(); + } + + let read = read_sent_records(&directory).expect("both versions are readable"); + assert_eq!(read, vec![legacy, modern]); + } + + /// A record that claims version 1 but carries version-2 endpoint fields has undecidable + /// endpoint meaning. That is corruption, not a tolerable older shape. + #[test] + fn a_version_1_record_carrying_version_2_fields_fails_closed() { + let mixed = SentRecord { + to_kind: Some(EndpointKind::Principal), + ..record("h.sender", "h.recipient") + }; + assert!(validate_sent_record(&mixed).is_err()); + + let incomplete = SentRecord { + version: MESSAGE_RECORD_VERSION_2, + from_kind: Some(EndpointKind::Agent), + ..record("h.sender", "h.recipient") + }; + assert!(validate_sent_record(&incomplete).is_err()); + } + + /// Every legacy endpoint in this index was reassigned at migration. + struct AllReassigned; + + impl LegacyCollisionIndex for AllReassigned { + fn is_reassigned(&self, _legacy_bus_identity: &str) -> bool { + true + } + } + + /// The `MESSAGE-R04` collision rule, both directions. + /// + /// `h.worker` was contested: a live subject kept those bytes and an archived subject was given + /// a UUID. A version-1 row in the ARCHIVED subject's own ledger says `from: h.worker`, and the + /// row's owner is that archived subject — so it must attribute to the archived subject's + /// migrated ID, never to the live keeper that now answers to those bytes. + #[test] + fn a_colliding_legacy_endpoint_attributes_only_to_the_rows_own_state_owner() { + let archived = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + let row = record("h.worker", "h.peer"); + + assert_eq!( + row.sender_endpoint(archived).attribute(&AllReassigned), + EndpointAttribution::AgentId(archived.to_owned()), + "an archived sender's own row must not be reattributed to the live keeper" + ); + + // Without a recorded collision the bytes ARE the frozen ID, so nothing is reinterpreted. + assert_eq!( + row.sender_endpoint(archived).attribute(&NoLegacyCollisions), + EndpointAttribution::AgentId("h.worker".to_owned()) + ); + } + + /// The recipient of a sender-owned row is not that row's state owner, so a colliding recipient + /// endpoint is unattributed: a historical address with no reply or automation authority. + #[test] + fn a_non_owner_colliding_endpoint_is_unattributed_and_carries_no_reply_authority() { + let owner = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + let row = record("h.worker", "h.peer"); + + let attribution = row.recipient_endpoint(owner).attribute(&AllReassigned); + assert_eq!( + attribution, + EndpointAttribution::UnattributedLegacyAddress("h.peer".to_owned()) + ); + assert_eq!( + attribution.reply_target(), + None, + "addressing the live replacement would deliver to the wrong subject" + ); + assert_eq!(attribution.agent_id(), None); + assert_eq!(attribution.display(), "h.peer (historical address)"); + assert_ne!( + attribution.display(), + owner, + "the keeping subject's ID must never stand in for archived bytes" + ); + } + + /// A version-2 Agent endpoint is already an immutable ID, so collision metadata about legacy + /// bytes must not touch it even when those bytes happen to look contested. + #[test] + fn a_version_2_agent_endpoint_is_never_reinterpreted_by_collision_metadata() { + let owner = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + let row = SentRecord { + version: MESSAGE_RECORD_VERSION_2, + from_kind: Some(EndpointKind::Agent), + to_kind: Some(EndpointKind::Agent), + ..record("h.worker", "h.peer") + }; + assert_eq!( + row.recipient_endpoint(owner).attribute(&AllReassigned), + EndpointAttribution::AgentId("h.peer".to_owned()) + ); + } + + /// Decision 1: a record whose endpoints are immutable IDs must DECLARE version 2. The tree was + /// briefly incoherent — IDs stored under a version whose `from`/`to` mean bus identities — and + /// that is exactly the misattribution the schema version exists to prevent. + #[test] + fn the_writer_never_stores_an_id_under_a_version_1_schema() { + assert!( + WRITE_MESSAGE_RECORD_VERSION_2, + "the version-2 writer cohort is on; see DELTA-003 activation step 5" + ); + assert_eq!(SENT_RECORD_WRITE_VERSION, MESSAGE_RECORD_VERSION_2); + + // Whatever the switch says, the two must agree: version 2 iff typed endpoints are written. + let sender = SenderEndpoint { + endpoint: "sender-uuid".to_owned(), + kind: EndpointKind::Agent, + address: Some("h.sender".to_owned()), + }; + assert_eq!(sender.rendered_from(), "h.sender"); + assert_eq!( + sender.rendered_from_id(), + Some("sender-uuid"), + "an Agent sender must publish its immutable ID beside its mutable route" + ); + + let rendered = render_message_with_idempotency( + sender.rendered_from(), + sender.rendered_from_id(), + None, + None, + &[], + "body", + None, + ); + assert!(rendered.contains("from: h.sender\n"), "{rendered}"); + assert!(rendered.contains("from-id: sender-uuid\n"), "{rendered}"); + + // A principal or external sender has no agent ID, so it publishes no `from-id` line. + let principal = SenderEndpoint { + endpoint: "h.billing".to_owned(), + kind: EndpointKind::External, + address: None, + }; + assert_eq!(principal.rendered_from(), "h.billing"); + assert_eq!(principal.rendered_from_id(), None); + } + + /// Decision 3: a reply targets the authoritative `from_id`, never the mutable `from` route. + /// A released address is immediately reusable, so replying by route can hand the reply to a + /// completely different subject. + #[test] + fn a_reply_targets_the_immutable_sender_id_not_the_reusable_route() { + let message = Message { + filename: "1785000000000-abcdef.md".to_owned(), + ts_ms: 1_785_000_000_000, + from: Some("h.chat".to_owned()), + from_id: Some("sender-uuid".to_owned()), + subject: None, + in_reply_to: None, + tags: Vec::new(), + priority: None, + idempotency_key: None, + stream: None, + event_id: None, + event_key: None, + body: String::new(), + }; + assert_eq!( + message.reply_target("recipient-uuid", &NoLegacyCollisions), + Some("sender-uuid".to_owned()), + "the route `h.chat` may already belong to someone else" + ); + // Collision metadata about legacy bytes must not touch an endpoint that is already an ID. + assert_eq!( + message.reply_target("recipient-uuid", &AllReassigned), + Some("sender-uuid".to_owned()) + ); + } + + /// A pre-`from_id` message falls back to the collision-aware version-1 path, and an inbox row + /// is owned by its RECIPIENT — so a colliding sender endpoint carries no reply authority. + #[test] + fn a_legacy_message_replies_by_collision_aware_attribution_and_refuses_when_unattributed() { + let legacy = Message { + filename: "1785000000000-abcdef.md".to_owned(), + ts_ms: 1_785_000_000_000, + from: Some("h.worker".to_owned()), + from_id: None, + subject: None, + in_reply_to: None, + tags: Vec::new(), + priority: None, + idempotency_key: None, + stream: None, + event_id: None, + event_key: None, + body: String::new(), + }; + assert_eq!( + legacy.reply_target("recipient-uuid", &NoLegacyCollisions), + Some("h.worker".to_owned()), + "uncontested legacy bytes ARE the sender's frozen agent ID" + ); + assert_eq!( + legacy.reply_target("recipient-uuid", &AllReassigned), + None, + "replying would deliver to whichever subject kept the reassigned bytes" + ); + } + + /// Interrupted-send recovery must not treat a collided version-1 recipient as an exact ID: the + /// keeper of those bytes is a different subject from the one the send was addressed to. + #[test] + fn recovery_refuses_a_pending_send_whose_legacy_recipient_was_reassigned() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let keeper = catalog.join("h/worker"); + fs::create_dir_all(&keeper).unwrap(); + fs::write( + keeper.join("agent.kdl"), + "agent \"worker\" {\n identity \"worker\"\n host \"h\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", + ) + .unwrap(); + // Migration reassigned `h.worker`: this live subject kept the bytes, an archived subject + // lost them. The pending intent below was written before that split. + let control = crate::catalog_migrate::legacy_id_collisions_path(&catalog); + fs::create_dir_all(control.parent().unwrap()).unwrap(); + fs::write( + &control, + serde_json::to_vec(&crate::catalog_migrate::LegacyIdCollisions { + schema: "st2.catalog-legacy-id-collisions.v1".to_owned(), + entries: vec![crate::catalog_migrate::LegacyIdCollision { + legacy_bus_identity: "h.worker".to_owned(), + keeper: AgentId::parse("h.worker").unwrap(), + reassigned: vec![AgentId::parse("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1").unwrap()], + }], + }) + .unwrap(), + ) + .unwrap(); + + let sender_root = tmp.path().join("sender"); + let root = sent_dir(&sender_root); + let pending = record("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "h.worker"); + fs::create_dir_all(root.join(SENT_PENDING)).unwrap(); + fs::write( + root.join(SENT_PENDING).join(pending_record_name(&pending).unwrap()), + serde_json::to_vec(&pending).unwrap(), + ) + .unwrap(); + publish_active(&root, &pending).unwrap(); + let mut head = ensure_sent_head(&root).unwrap(); + + let error = recover_active(&catalog, "h", None, &root, &mut head) + .expect_err("a reassigned legacy recipient cannot be resolved to one subject"); + assert!( + format!("{error:#}").contains("reassigned"), + "unexpected refusal: {error:#}" + ); + assert!( + !message_dir_has_entries(&inbox_dir(&keeper)), + "the keeper of the legacy bytes must not receive a message addressed to another subject" + ); + } + + fn message_dir_has_entries(dir: &Path) -> bool { + fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_some()) + } } diff --git a/src/pi_channel.rs b/src/pi_channel.rs index 11d2859e..af2fdd11 100644 --- a/src/pi_channel.rs +++ b/src/pi_channel.rs @@ -1311,6 +1311,9 @@ mod tests { filename: "1787042542238-xex2t4.md".into(), ts_ms: 1_787_042_542_238, from: Some("h.supervisor".into()), + // An unmigrated sender's immutable ID is exactly its `.` bytes, so + // the route and the ID coincide here by construction. + from_id: Some("h.supervisor".into()), subject: Some("deploy check".into()), in_reply_to: None, tags: Vec::new(), diff --git a/src/reconcile.rs b/src/reconcile.rs index 1fa33140..ad9828ab 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -18,6 +18,8 @@ use anyhow::{Context, Result}; use agent_spec::spec::{ AgentSpec, DeliveryTransport, Driver, TaskKind, TaskLifecycle, stream_name_of_task, }; +use crate::supervisor_chain::{resolve_edge, supervisor_edge}; +use crate::AddressBook; use kdl::KdlValue; /// Immutable inputs captured once before generated tasks are compiled. @@ -122,7 +124,8 @@ pub fn compile_driver_agent_tasks( let Some(driver) = spec.driver.as_ref() else { continue; }; - let bus_id = spec.bus_id(this_host); + // Diagnostics name the human route; the compiled launch carries the immutable ID. + let address = spec.bus_address(this_host); let expansion = crate::driver::expand_driver(spec, this_host)?; let argv_nodes = expansion .nodes() @@ -131,7 +134,7 @@ pub fn compile_driver_agent_tasks( .collect::>(); let [argv_node] = argv_nodes.as_slice() else { anyhow::bail!( - "agent '{bus_id}' driver expansion produced {} argv nodes; expected exactly one", + "agent '{address}' driver expansion produced {} argv nodes; expected exactly one", argv_nodes.len() ); }; @@ -140,7 +143,7 @@ pub fn compile_driver_agent_tasks( && argv_node.entries().iter().all(|entry| { entry.name().is_none() && matches!(entry.value(), KdlValue::String(_)) }), - "agent '{bus_id}' driver expansion produced a non-string argv" + "agent '{address}' driver expansion produced a non-string argv" ); let mut argv = argv_node .entries() @@ -152,7 +155,7 @@ pub fn compile_driver_agent_tasks( .collect::>(); anyhow::ensure!( !argv.is_empty(), - "agent '{bus_id}' driver expansion produced an empty argv" + "agent '{address}' driver expansion produced an empty argv" ); let wrapper = match driver { @@ -168,7 +171,7 @@ pub fn compile_driver_agent_tasks( && argv.get(2).map(String::as_str) == Some("$CATALOG") && argv.get(3).map(String::as_str) == Some("driver") && argv.get(4).map(String::as_str) == Some(wrapper), - "agent '{bus_id}' driver expansion has an unexpected {wrapper} wrapper prefix" + "agent '{address}' driver expansion has an unexpected {wrapper} wrapper prefix" ); argv[0] = st2_executable.clone(); argv[2] = catalog_root.clone(); @@ -179,14 +182,14 @@ pub fn compile_driver_agent_tasks( .filter(|task| !task.derived && task.name == "agent"); let task = candidates .next() - .with_context(|| format!("agent '{bus_id}' driver has no canonical `agent` task"))?; + .with_context(|| format!("agent '{address}' driver has no canonical `agent` task"))?; anyhow::ensure!( candidates.next().is_none(), - "agent '{bus_id}' driver has more than one canonical `agent` task" + "agent '{address}' driver has more than one canonical `agent` task" ); anyhow::ensure!( task.kind == TaskKind::Pty, - "agent '{bus_id}' driver canonical task is not a PTY" + "agent '{address}' driver canonical task is not a PTY" ); task.command = None; task.argv = Some(argv); @@ -252,23 +255,24 @@ fn compile_session_wrapped_agent_tasks( continue; } let selected = transport.as_str(); - let bus_id = spec.bus_id(this_host); + let agent_id = spec.agent_id(this_host); + let address = spec.bus_address(this_host); let mut candidates = spec .tasks .iter_mut() .filter(|task| !task.derived && task.name == "agent"); let task = candidates.next().with_context(|| { format!( - "agent '{bus_id}' selects `deliver \"{selected}\"` but has no canonical `agent` task" + "agent '{address}' selects `deliver \"{selected}\"` but has no canonical `agent` task" ) })?; anyhow::ensure!( candidates.next().is_none(), - "agent '{bus_id}' selects `deliver \"{selected}\"` with more than one canonical `agent` task" + "agent '{address}' selects `deliver \"{selected}\"` with more than one canonical `agent` task" ); anyhow::ensure!( task.kind == TaskKind::Pty, - "agent '{bus_id}' selects `deliver \"{selected}\"` for a non-PTY canonical task" + "agent '{address}' selects `deliver \"{selected}\"` for a non-PTY canonical task" ); let provider = match (&task.command, &task.argv) { (None, Some(argv)) => argv.clone(), @@ -282,20 +286,20 @@ fn compile_session_wrapped_agent_tasks( }; anyhow::ensure!( !provider.is_empty(), - "agent '{bus_id}' selects `deliver \"{selected}\"` with an empty canonical argv" + "agent '{address}' selects `deliver \"{selected}\"` with an empty canonical argv" ); let runtime_id = task .id .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)); let mut argv = vec![ st2_executable.clone(), "--catalog".to_string(), catalog_root.clone(), "driver".to_string(), wrapper.to_string(), - "--identity".to_string(), - bus_id, + "--id".to_string(), + agent_id, "--runtime-id".to_string(), runtime_id, "--".to_string(), @@ -325,7 +329,7 @@ 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); + let agent_id = spec.agent_id(this_host); for task in &mut spec.tasks { if !task.derived { continue; @@ -358,8 +362,10 @@ pub fn compile_generated_ding_tasks( task.argv = Some(vec![ st2_executable.clone(), "ding".to_string(), - "--identity".to_string(), - bus_id.clone(), + // Agent-spec lowers `st2 ding --id `; the late-bound rewrite keeps that + // exact-ID form rather than handing DING a mutable route. + "--id".to_string(), + agent_id.clone(), "--root".to_string(), effective_root, ]); @@ -397,50 +403,51 @@ pub fn compile_app_server_agent_tasks( if spec.delivery != Some(DeliveryTransport::AppServer) { continue; } - let bus_id = spec.bus_id(this_host); + let agent_id = spec.agent_id(this_host); + let address = spec.bus_address(this_host); let mut candidates = spec .tasks .iter_mut() .filter(|task| !task.derived && task.name == "agent"); let task = candidates.next().with_context(|| { format!( - "agent '{bus_id}' selects `deliver \"app-server\"` but has no canonical `agent` task" + "agent '{address}' selects `deliver \"app-server\"` but has no canonical `agent` task" ) })?; anyhow::ensure!( candidates.next().is_none(), - "agent '{bus_id}' selects `deliver \"app-server\"` with more than one canonical `agent` task" + "agent '{address}' selects `deliver \"app-server\"` with more than one canonical `agent` task" ); anyhow::ensure!( task.kind == TaskKind::Pty, - "agent '{bus_id}' selects `deliver \"app-server\"` for a non-PTY canonical task" + "agent '{address}' selects `deliver \"app-server\"` for a non-PTY canonical task" ); let authored = task.argv.clone().with_context(|| { format!( - "agent '{bus_id}' selects `deliver \"app-server\"`; its canonical task must use structured `argv`, not shell `command`" + "agent '{address}' selects `deliver \"app-server\"`; its canonical task must use structured `argv`, not shell `command`" ) })?; anyhow::ensure!( !authored.is_empty(), - "agent '{bus_id}' selects `deliver \"app-server\"` with an empty canonical argv" + "agent '{address}' selects `deliver \"app-server\"` with an empty canonical argv" ); anyhow::ensure!( !authored .iter() .any(|arg| arg == "--remote" || arg.starts_with("--remote=")), - "agent '{bus_id}' selects `deliver \"app-server\"` but its canonical argv already declares `--remote`" + "agent '{address}' 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)); + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)); let mut argv = vec![ st2_executable.clone(), "--catalog".to_string(), catalog_root.clone(), "codex-app-server".to_string(), - "--identity".to_string(), - bus_id, + "--id".to_string(), + agent_id, "--runtime-id".to_string(), runtime_id, "--".to_string(), @@ -479,10 +486,14 @@ 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 spec's explicit task `id`, or the `.` default). pub pty_id: String, - /// The agent bus id this task belongs to (`.`). - pub bus_id: String, + /// The immutable agent ID that owns this task. Everything keyed off ownership — `ST_AGENT`, + /// default task IDs, adoption, teardown, park accounting — reads this, never the address. + pub agent_id: String, + /// The owning agent's current bus address: the human route, used for presentation only. A + /// change here must never move a task ID, a launch fingerprint, or an adoption decision. + pub bus_address: String, /// The task name (`agent`, `ding`, …). pub name: String, /// Generated from another task rather than authored as an independent sibling. @@ -514,7 +525,20 @@ pub struct PtyPresentation { } pub const AGENT_PRESENTATION_SCHEMA_TAG: &str = "agent.presentation.schema"; -pub const AGENT_ACTOR_PATH_TAG: &str = "agent.actor.path"; +/// Owned-metadata schema carried by every managed PTY: immutable subject ID plus current address. +pub const AGENT_PRESENTATION_SCHEMA: &str = "2"; +/// The immutable Agent Spec ID of the subject this PTY belongs to. +/// +/// This is deliberately NOT `agent.actor.id`: that key names the *external actor* driving the +/// session and belongs to whoever writes it. st2 never writes it and never deletes it. +pub const AGENT_SUBJECT_ID_TAG: &str = "agent.subject.id"; +/// The subject's current bus address. Removed when the subject is non-routable. +pub const AGENT_SUBJECT_ADDRESS_TAG: &str = "agent.subject.address"; +/// The schema-1 owned key this schema replaces. It held a *route*, which after an address cutover +/// may name a different subject entirely, so leaving it behind would strand a stale alias on the +/// session forever. It is st2-owned, so the schema-2 patch deletes it: the projection always +/// carries this key with `None`. +pub const LEGACY_AGENT_ACTOR_PATH_TAG: &str = "agent.actor.path"; 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"; @@ -523,7 +547,7 @@ pub const COMPATIBILITY_ROLE_TAG: &str = "role"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskIdentityAdmissionError { Conflict { - bus_id: String, + agent_id: String, task: String, declared: String, }, @@ -533,12 +557,12 @@ impl fmt::Display for TaskIdentityAdmissionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Conflict { - bus_id, + agent_id, task, declared, } => write!( formatter, - "agent '{bus_id}' task '{task}' declares conflicting ST_AGENT '{declared}'; expected runner-owned value '{bus_id}'" + "agent '{agent_id}' task '{task}' declares conflicting ST_AGENT '{declared}'; expected runner-owned value '{agent_id}'" ), } } @@ -546,7 +570,7 @@ 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 `ST_AGENT` conflicts with the runner-owned agent ID. pub fn validate_task_identities( specs: &[AgentSpec], this_host: &str, @@ -555,13 +579,13 @@ pub fn validate_task_identities( if !spec.desired_state.is_running() || spec.resolved_host(this_host) != this_host { continue; } - let bus_id = spec.bus_id(this_host); + let agent_id = spec.agent_id(this_host); for task in &spec.tasks { if let Some(declared) = task.env.get("ST_AGENT") - && declared != &bus_id + && declared != &agent_id { return Err(TaskIdentityAdmissionError::Conflict { - bus_id, + agent_id, task: task.name.clone(), declared: declared.clone(), }); @@ -572,13 +596,17 @@ pub fn validate_task_identities( } /// Project runner-owned identity and the supervisor source of truth into one launch target. +/// +/// `ST_AGENT` carries the raw immutable agent ID and nothing else: it is consumed downstream +/// through the typed exact-ID path, so it must never be an address or a separately concatenated +/// host prefix. fn runner_task_env( spec: &AgentSpec, task: &crate::spec::Task, - bus_id: &str, + agent_id: &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_id.to_owned()); if let Some(supervisor) = &spec.supervisor { env.insert("ST_SUPERVISOR".to_owned(), supervisor.clone()); } else { @@ -587,28 +615,49 @@ fn runner_task_env( env } +/// The exact owned metadata snapshot (schema 2) desired for one managed PTY. +/// +/// Immutable subject ID, the current bus address, and the optional description. A non-routable +/// subject has released its address, so its owned address tag is removed rather than frozen at +/// the last route. Only the canonical compact agent task — the one whose task ID *is* the agent +/// ID — carries `role=agent` and maps `name` to native display metadata. +/// +/// The snapshot never mentions `agent.actor.id`: that key names the external actor driving the +/// session, is not st2-owned, and is therefore neither written nor deleted here. +/// +/// The snapshot also always carries [`LEGACY_AGENT_ACTOR_PATH_TAG`] with `None`. Schema 1 stored a +/// route under that key; leaving it on a session that predates this schema would strand an alias +/// that a later address cutover can point at a different subject, so the same patch that writes +/// schema 2 deletes it. Removing an owned key is idempotent: once gone, the desired and observed +/// snapshots agree and no further patch is emitted. fn pty_presentation( spec: &AgentSpec, task: &crate::spec::Task, pty_id: &str, - bus_id: &str, + agent_id: &str, + bus_address: Option<&str>, ) -> 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_id; Some(PtyPresentation { pty_id: pty_id.to_owned(), - display_name: (task.name == "agent").then(|| match spec.name.as_ref() { + display_name: canonical_agent.then(|| match spec.name.as_ref() { Some(name) if name == pty_id => None, _ => spec.name.clone(), }), tags: BTreeMap::from([ ( AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), - Some("1".to_owned()), + Some(AGENT_PRESENTATION_SCHEMA.to_owned()), ), - (AGENT_ACTOR_PATH_TAG.to_owned(), Some(bus_id.to_owned())), + (AGENT_SUBJECT_ID_TAG.to_owned(), Some(agent_id.to_owned())), + ( + AGENT_SUBJECT_ADDRESS_TAG.to_owned(), + bus_address.map(str::to_owned), + ), + (LEGACY_AGENT_ACTOR_PATH_TAG.to_owned(), None), (AGENT_DESCRIPTION_TAG.to_owned(), spec.description.clone()), ( COMPATIBILITY_ROLE_TAG.to_owned(), @@ -618,6 +667,11 @@ fn pty_presentation( }) } +/// The owning agent's routable bus address, or `None` once the subject is non-routable. +fn routable_bus_address(spec: &AgentSpec, this_host: &str) -> Option { + (!spec.desired_state.is_retired()).then(|| spec.bus_address(this_host)) +} + fn presentation_matches(desired: &PtyPresentation, observed: &ObservedPtyPresentation) -> bool { let display_name_matches = desired .display_name @@ -630,6 +684,50 @@ fn presentation_matches(desired: &PtyPresentation, observed: &ObservedPtyPresent .all(|(key, value)| observed.tags.get(key) == value.as_ref()) } +/// Who to notify when a task under this launch crash-loops, decided once from the same catalog +/// snapshot the plan was computed from. +/// +/// A declared `supervisor` is a free-form human reference, not a typed selector, so it must be +/// resolved before it can key anything. Resolving it here — rather than at the notification site — +/// means the alert path never hands a route to an exact-ID resolver, and a reference that names +/// nothing is reported instead of silently swallowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SupervisorTarget { + /// The declaration names no supervisor: there is nobody to notify. + Undeclared, + /// The declared reference resolved to exactly one subject, named here by its immutable ID. + Resolved(String), + /// The declared reference resolved to no subject or to more than one. Carries the reference as + /// authored so the diagnostic can name what the operator must fix. + Unresolved(String), +} + +/// Resolve a declaration's `supervisor` edge once, against one catalog generation, so the +/// crash-loop alert reaches exactly the subject the org chart says owns this agent. +/// +/// The value's namespace is decided in exactly one place — +/// [`crate::supervisor_chain::supervisor_edge`] — from the CHILD's migration state, so this path, +/// the org-chart walk, DING, resync, and authoring can never disagree about an edge. +fn resolve_supervisor( + specs: &[AgentSpec], + book: Option<&AddressBook>, + spec: &AgentSpec, + this_host: &str, +) -> SupervisorTarget { + let Some(reference) = spec.supervisor.as_deref() else { + return SupervisorTarget::Undeclared; + }; + let unresolved = || SupervisorTarget::Unresolved(reference.to_owned()); + // A catalog this pass cannot project into one address book cannot attribute the edge either. + let (Some(_book), Some(edge)) = (book, supervisor_edge(specs, spec, this_host)) else { + return unresolved(); + }; + match resolve_edge(specs, &edge, this_host) { + Some(parent) => SupervisorTarget::Resolved(parent.agent_id(this_host)), + None => unresolved(), + } +} + /// A resolved task launch accepted by the execution backends. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskLaunch { @@ -647,6 +745,8 @@ pub struct Launch<'a> { /// Exact derived task IDs proved live in the same inventory snapshot. Execution stops these if /// the canonical agent becomes terminal while applying this launch. pub live_derived: Vec, + /// The already-resolved crash-loop notification target for `spec`. + pub supervisor: SupervisorTarget, } /// Exact live task IDs to stop because their owner retired or their derived target is ineligible. @@ -685,7 +785,9 @@ 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 without mutation: either an explicit task ID or the +/// `.` default. Task selection is exact-ID selection (R19) — it deliberately +/// does not fall through to human-address lookup. pub fn resolve_task<'a>( specs: &'a [AgentSpec], selector: &str, @@ -697,11 +799,9 @@ pub fn resolve_task<'a>( continue; } 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 agent_id = spec.agent_id(this_host); + let qualified = format!("{agent_id}.{}", task.name); + let runtime = task.id.clone().unwrap_or_else(|| qualified.clone()); if selector == runtime || selector == qualified { matches.push((spec, task, runtime)); } @@ -724,6 +824,7 @@ pub fn reconcile_selected<'a>( validate_task_identities(specs, this_host)?; let (owner, task, runtime) = resolve_task(specs, selector, this_host)?; let mut plan = ReconcilePlan::default(); + let book = crate::spec::address_book(specs, this_host).ok(); if owner.desired_state.is_retired() { plan.settle_retirement.push(owner); } @@ -756,12 +857,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 agent_id = owner.agent_id(this_host); + let routable_address = routable_bus_address(owner, this_host); + let env = runner_task_env(owner, task, &agent_id); let target = TaskTarget { kind: task.kind, pty_id: runtime.clone(), - bus_id: bus_id.clone(), + agent_id: agent_id.clone(), + bus_address: owner.bus_address(this_host), name: task.name.clone(), derived: task.derived, launch, @@ -770,7 +873,13 @@ 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, + &agent_id, + routable_address.as_deref(), + ), }; match actual { Some(s) if s.alive => { @@ -793,12 +902,14 @@ pub fn reconcile_selected<'a>( spec: owner, tasks: vec![target], live_derived: Vec::new(), + supervisor: resolve_supervisor(specs, book.as_ref(), owner, this_host), }); } _ => plan.launch.push(Launch { spec: owner, tasks: vec![target], live_derived: Vec::new(), + supervisor: resolve_supervisor(specs, book.as_ref(), owner, this_host), }), } Ok(plan) @@ -820,13 +931,15 @@ 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 -/// 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 { +/// Resolve a task's on-disk id: the explicit task `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. Host placement is never separately concatenated: for an unmigrated +/// declaration the agent ID already *is* the frozen `.` bytes, which is why no +/// legacy task ID or socket path moves. +pub(crate) fn resolve_task_id(agent_id: &str, name: &str, explicit: Option<&str>) -> String { match explicit { Some(id) => id.to_string(), - None => format!("{bus_id}.{name}"), + None => format!("{agent_id}.{name}"), } } @@ -845,6 +958,9 @@ pub fn reconcile<'a>( .iter() .map(|session| (session.pty_id.as_str(), session)) .collect(); + // One book for the whole pass, so every supervisor edge this plan records and the uniqueness + // proof behind it describe the same catalog generation. + let book = crate::spec::address_book(specs, this_host).ok(); let mut plan = ReconcilePlan::default(); for spec in specs { @@ -852,7 +968,8 @@ pub fn reconcile<'a>( plan.other_host.push(spec); continue; } - let bus_id = spec.bus_id(this_host); + let agent_id = spec.agent_id(this_host); + let routable_address = routable_bus_address(spec, this_host); if !spec.desired_state.is_running() { if spec.desired_state.is_retired() { @@ -860,7 +977,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 = resolve_task_id(&agent_id, &t.name, t.id.as_deref()); 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 +1011,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, &agent_id); + let pty_id = resolve_task_id(&agent_id, &t.name, t.id.as_deref()); Some(( TaskTarget { kind: t.kind, pty_id: pty_id.clone(), - bus_id: bus_id.clone(), + agent_id: agent_id.clone(), + bus_address: spec.bus_address(this_host), name: t.name.clone(), derived: t.derived, launch, @@ -909,7 +1027,13 @@ 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, + &agent_id, + routable_address.as_deref(), + ), }, t.lifecycle, )) @@ -994,8 +1118,597 @@ pub fn reconcile<'a>( spec, tasks: to_launch, live_derived, + // Resolved here, once per launched agent, so nothing downstream re-parses the + // authored reference — and only for agents that can actually reach the park path. + supervisor: resolve_supervisor(specs, book.as_ref(), spec, this_host), }); } } Ok(plan) } + +#[cfg(test)] +mod tests { + use agent_spec::spec::{AgentDesiredState, JobType, Task}; + use agent_spec::{AgentAddress, AgentId}; + + use super::*; + + const ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; + + fn task(name: &str, kind: TaskKind, id: Option<&str>) -> Task { + Task { + kind, + derived: false, + name: name.to_owned(), + id: id.map(str::to_owned), + command: Some("true".to_owned()), + argv: None, + cwd: None, + tags: BTreeMap::new(), + env: BTreeMap::new(), + keep: false, + lifecycle: TaskLifecycle::Service, + } + } + + fn spec(tasks: Vec) -> AgentSpec { + AgentSpec { + id: None, + address: None, + identity: "worker".to_owned(), + name: None, + description: None, + host: Some("dev3".to_owned()), + role: None, + job_type: JobType::Service, + workspace: None, + supervisor: None, + desired_state: AgentDesiredState::Running, + keep: false, + restart: None, + delivery: None, + session_driver: None, + driver: None, + delivery_readiness: None, + resources: Vec::new(), + streams: Vec::new(), + tasks, + path: PathBuf::from("/catalog/agents/dev3/worker/agent.kdl"), + } + } + + fn migrated(tasks: Vec) -> AgentSpec { + let mut spec = spec(tasks); + spec.id = Some(AgentId::parse(ID).unwrap()); + spec.address = Some(AgentAddress::parse("fractal.keymap.verifier").unwrap()); + spec + } + + fn target_of<'a>(plan: &'a ReconcilePlan<'_>, pty_id: &str) -> &'a TaskTarget { + plan.launch + .iter() + .flat_map(|launch| &launch.tasks) + .find(|target| target.pty_id == pty_id) + .unwrap_or_else(|| panic!("no launch target {pty_id}")) + } + + /// `ST_AGENT` is consumed through the typed exact-ID path, so it must be the raw agent ID: + /// not the mutable bus address, and not the ID with a host concatenated onto it. + #[test] + fn st_agent_carries_the_raw_immutable_agent_id() { + let declared = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + let plan = reconcile(std::slice::from_ref(&declared), &[], "dev3").unwrap(); + + let target = target_of(&plan, ID); + assert_eq!(target.env.get("ST_AGENT").map(String::as_str), Some(ID)); + assert_eq!(target.agent_id, ID); + assert_eq!(target.bus_address, "dev3.fractal.keymap.verifier"); + } + + /// An unmigrated declaration yields exactly the frozen legacy bytes, which is why no task ID + /// or socket path moves across migration. + #[test] + fn an_unmigrated_declaration_keeps_its_frozen_legacy_st_agent() { + let declared = spec(vec![task("agent", TaskKind::Pty, Some("dev3.worker"))]); + let plan = reconcile(std::slice::from_ref(&declared), &[], "dev3").unwrap(); + + assert_eq!( + target_of(&plan, "dev3.worker") + .env + .get("ST_AGENT") + .map(String::as_str), + Some("dev3.worker") + ); + } + + /// Every long-form named task without an explicit task ID defaults to `.` + /// — including a task named `agent`. An authored task ID stays authoritative. + #[test] + fn default_task_ids_are_agent_id_dot_task_name_including_a_task_named_agent() { + let declared = migrated(vec![ + task("agent", TaskKind::Pty, None), + task("work", TaskKind::Pty, None), + task("authored", TaskKind::Exec, Some("chosen.by.hand")), + ]); + let plan = reconcile(std::slice::from_ref(&declared), &[], "dev3").unwrap(); + + let mut ids = plan + .launch + .iter() + .flat_map(|launch| &launch.tasks) + .map(|target| target.pty_id.as_str()) + .collect::>(); + ids.sort_unstable(); + assert_eq!( + ids, + [ + &format!("{ID}.agent"), + &format!("{ID}.work"), + "chosen.by.hand", + ] + ); + } + + /// The exact owned tag snapshot, schema 2. An absent optional value is removed rather than + /// left stale, and the non-canonical role tag is cleared rather than omitted. + #[test] + fn owned_metadata_is_the_schema_two_snapshot_and_removes_absent_optionals() { + let mut declared = migrated(vec![task("work", TaskKind::Pty, None)]); + declared.description = None; + let projected = pty_presentation( + &declared, + &declared.tasks[0], + &format!("{ID}.work"), + ID, + Some("dev3.fractal.keymap.verifier"), + ) + .unwrap(); + + assert_eq!( + projected.tags, + BTreeMap::from([ + ( + AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), + Some("2".to_owned()) + ), + (AGENT_SUBJECT_ID_TAG.to_owned(), Some(ID.to_owned())), + ( + AGENT_SUBJECT_ADDRESS_TAG.to_owned(), + Some("dev3.fractal.keymap.verifier".to_owned()) + ), + (LEGACY_AGENT_ACTOR_PATH_TAG.to_owned(), None), + (AGENT_DESCRIPTION_TAG.to_owned(), None), + (COMPATIBILITY_ROLE_TAG.to_owned(), None), + ]) + ); + assert_eq!( + projected.tags[LEGACY_AGENT_ACTOR_PATH_TAG], None, + "schema 1's actor path is deleted by this patch, never emitted alongside schema 2" + ); + assert_eq!( + projected.display_name, None, + "a secondary PTY keeps its own display convention" + ); + } + + /// A retired subject is non-routable: it keeps its ID and releases its address, so the owned + /// address tag is removed rather than frozen at the last route. + #[test] + fn a_retired_subject_releases_its_owned_address_tag_but_keeps_its_id() { + let mut declared = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + declared.desired_state = AgentDesiredState::Retired { reason: None }; + let address = routable_bus_address(&declared, "dev3"); + assert_eq!(address, None); + + let projected = + pty_presentation(&declared, &declared.tasks[0], ID, ID, address.as_deref()).unwrap(); + assert_eq!(projected.tags[AGENT_SUBJECT_ADDRESS_TAG], None); + assert_eq!(projected.tags[AGENT_SUBJECT_ID_TAG], Some(ID.to_owned())); + } + + /// Only the canonical compact agent task — the one whose task ID *is* the agent ID — carries + /// `role=agent` and maps `name` to native display metadata. + #[test] + fn only_the_canonical_compact_task_carries_role_agent_and_a_display_name() { + let mut declared = migrated(vec![ + task("agent", TaskKind::Pty, Some(ID)), + task("agent", TaskKind::Pty, None), + ]); + declared.name = Some("Keymap verifier".to_owned()); + let plan = reconcile(std::slice::from_ref(&declared), &[], "dev3").unwrap(); + + let canonical = target_of(&plan, ID).presentation.as_ref().unwrap(); + assert_eq!( + canonical.display_name, + Some(Some("Keymap verifier".to_owned())) + ); + assert_eq!( + canonical.tags[COMPATIBILITY_ROLE_TAG], + Some("agent".to_owned()) + ); + + let long_form = target_of(&plan, &format!("{ID}.agent")) + .presentation + .as_ref() + .unwrap(); + assert_eq!(long_form.display_name, None); + assert_eq!( + long_form.tags[COMPATIBILITY_ROLE_TAG], None, + "a non-canonical PTY must have the role tag cleared" + ); + } + + /// Projection is idempotent: an already-correct PTY produces no patch, so `pty` emits no + /// `metadata_change` event. Unrelated observed tags never provoke one either. + #[test] + fn an_already_correct_pty_produces_no_presentation_patch() { + let mut declared = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + declared.description = Some("Verifies keymaps.".to_owned()); + let observed = Session { + pty_id: ID.to_owned(), + alive: true, + exit_code: None, + presentation: Some(ObservedPtyPresentation { + display_name: None, + tags: BTreeMap::from([ + (AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), "2".to_owned()), + (AGENT_SUBJECT_ID_TAG.to_owned(), ID.to_owned()), + ( + AGENT_SUBJECT_ADDRESS_TAG.to_owned(), + "dev3.fractal.keymap.verifier".to_owned(), + ), + ( + AGENT_DESCRIPTION_TAG.to_owned(), + "Verifies keymaps.".to_owned(), + ), + (COMPATIBILITY_ROLE_TAG.to_owned(), "agent".to_owned()), + ("unrelated".to_owned(), "preserved".to_owned()), + ]), + }), + }; + + let plan = reconcile( + std::slice::from_ref(&declared), + std::slice::from_ref(&observed), + "dev3", + ) + .unwrap(); + assert!( + plan.presentation.is_empty(), + "an unchanged snapshot must emit no patch: {:?}", + plan.presentation + ); + assert_eq!(plan.adopt.len(), 1); + + // A stale address is the one effective delta, and it patches without touching lifecycle. + let mut stale = observed.clone(); + let stale_tags = &mut stale.presentation.as_mut().unwrap().tags; + stale_tags.insert(AGENT_SUBJECT_ADDRESS_TAG.to_owned(), "dev3.worker".to_owned()); + let repaired = reconcile( + std::slice::from_ref(&declared), + std::slice::from_ref(&stale), + "dev3", + ) + .unwrap(); + assert_eq!(repaired.presentation.len(), 1); + assert_eq!( + repaired.presentation[0].tags[AGENT_SUBJECT_ADDRESS_TAG], + Some("dev3.fractal.keymap.verifier".to_owned()) + ); + assert!(repaired.launch.is_empty() && repaired.teardown.is_empty()); + assert_eq!(repaired.gc, Vec::::new()); + } + + /// An address change is a pure cutover: task IDs, launch inputs, workspace, the declaration + /// parent, and the adoption decision are all unmoved, and a healthy task is not restarted. + #[test] + fn an_address_change_moves_no_task_id_and_does_not_restart_a_healthy_task() { + let before = migrated(vec![ + task("agent", TaskKind::Pty, Some(ID)), + task("ding", TaskKind::Exec, None), + ]); + let mut after = before.clone(); + after.address = Some(AgentAddress::parse("renamed.elsewhere").unwrap()); + + let sessions = [ + Session { + pty_id: ID.to_owned(), + alive: true, + exit_code: None, + presentation: None, + }, + Session { + pty_id: format!("{ID}.ding"), + alive: true, + exit_code: None, + presentation: None, + }, + ]; + + let plan_before = reconcile(std::slice::from_ref(&before), &sessions, "dev3").unwrap(); + let plan_after = reconcile(std::slice::from_ref(&after), &sessions, "dev3").unwrap(); + + assert_eq!(plan_before.live, plan_after.live); + assert_eq!(plan_before.live, vec![ID.to_owned(), format!("{ID}.ding")]); + assert_eq!(plan_after.launch.len(), 0, "no relaunch"); + assert_eq!(plan_after.teardown.len(), 0, "no replacement"); + assert_eq!(plan_after.gc, Vec::::new()); + assert_eq!(plan_after.adopt.len(), 1, "the healthy agent is adopted"); + assert_eq!(after.path, before.path, "the state anchor is unmoved"); + + // Only the projected route differs, and only in presentation. + let selected_before = reconcile_selected(std::slice::from_ref(&before), &[], "dev3", ID) + .unwrap(); + let selected_after = + reconcile_selected(std::slice::from_ref(&after), &[], "dev3", ID).unwrap(); + let launch_before = &selected_before.launch[0].tasks[0]; + let launch_after = &selected_after.launch[0].tasks[0]; + assert_eq!(launch_before.pty_id, launch_after.pty_id); + assert_eq!(launch_before.agent_id, launch_after.agent_id); + assert_eq!(launch_before.env, launch_after.env); + assert_eq!(launch_before.launch, launch_after.launch); + assert_eq!(launch_before.workspace, launch_after.workspace); + assert_eq!(launch_after.bus_address, "dev3.renamed.elsewhere"); + } + + /// A declared `ST_AGENT` that is not the runner-owned agent ID refuses before any write. + #[test] + fn a_declared_st_agent_that_is_not_the_agent_id_refuses() { + let mut declared = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + declared.tasks[0] + .env + .insert("ST_AGENT".to_owned(), "dev3.fractal.keymap.verifier".to_owned()); + + let error = validate_task_identities(std::slice::from_ref(&declared), "dev3") + .expect_err("an address in ST_AGENT is not the runner-owned selector"); + assert_eq!( + error, + TaskIdentityAdmissionError::Conflict { + agent_id: ID.to_owned(), + task: "agent".to_owned(), + declared: "dev3.fractal.keymap.verifier".to_owned(), + } + ); + + declared.tasks[0] + .env + .insert("ST_AGENT".to_owned(), ID.to_owned()); + assert!(validate_task_identities(std::slice::from_ref(&declared), "dev3").is_ok()); + } + + /// A session tagged under schema 1 carries `agent.actor.path`, which is a ROUTE: after an + /// address cutover those bytes can name a different subject. The first schema-2 patch must + /// delete that owned key, leave unrelated tags alone — including the external actor's + /// `agent.actor.id`, which st2 neither writes nor deletes — and then stay idempotent. + #[test] + fn the_first_schema_two_patch_deletes_a_stale_schema_one_actor_path() { + let mut declared = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + declared.description = Some("Verifies keymaps.".to_owned()); + + // Exactly what a schema-1 writer left behind, plus somebody else's tag. + let mut observed_tags = BTreeMap::from([ + (AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), "1".to_owned()), + ( + LEGACY_AGENT_ACTOR_PATH_TAG.to_owned(), + "dev3.worker".to_owned(), + ), + ( + AGENT_DESCRIPTION_TAG.to_owned(), + "Verifies keymaps.".to_owned(), + ), + (COMPATIBILITY_ROLE_TAG.to_owned(), "agent".to_owned()), + ("unrelated".to_owned(), "preserved".to_owned()), + // The external actor driving this session. Not st2-owned: never written, never deleted. + ("agent.actor.id".to_owned(), "claude-code".to_owned()), + ]); + let session = |tags: &BTreeMap| Session { + pty_id: ID.to_owned(), + alive: true, + exit_code: None, + presentation: Some(ObservedPtyPresentation { + display_name: None, + tags: tags.clone(), + }), + }; + + let plan = reconcile( + std::slice::from_ref(&declared), + &[session(&observed_tags)], + "dev3", + ) + .unwrap(); + assert_eq!(plan.presentation.len(), 1); + let patch = &plan.presentation[0]; + assert_eq!( + patch.tags[LEGACY_AGENT_ACTOR_PATH_TAG], None, + "the stale schema-1 route alias must be explicitly deleted" + ); + assert_eq!( + patch.tags[AGENT_PRESENTATION_SCHEMA_TAG], + Some("2".to_owned()) + ); + assert_eq!(patch.tags[AGENT_SUBJECT_ID_TAG], Some(ID.to_owned())); + assert!( + !patch.tags.contains_key("unrelated"), + "an unrelated tag is not ours to touch: {:?}", + patch.tags + ); + assert!( + !patch.tags.contains_key("agent.actor.id"), + "the external actor tag is not st2-owned and must never be written or deleted: {:?}", + patch.tags + ); + + // Apply the patch the way `pty metadata patch` would, then reconcile again. + for (key, value) in &patch.tags { + match value { + Some(value) => { + observed_tags.insert(key.clone(), value.clone()); + } + None => { + observed_tags.remove(key); + } + } + } + assert_eq!( + observed_tags.get("unrelated").map(String::as_str), + Some("preserved"), + "the unrelated tag survived the patch" + ); + assert_eq!( + observed_tags.get("agent.actor.id").map(String::as_str), + Some("claude-code"), + "the external actor tag survived the patch untouched" + ); + assert!(!observed_tags.contains_key(LEGACY_AGENT_ACTOR_PATH_TAG)); + + let settled = reconcile( + std::slice::from_ref(&declared), + &[session(&observed_tags)], + "dev3", + ) + .unwrap(); + assert!( + settled.presentation.is_empty(), + "the second patch must be a no-op: {:?}", + settled.presentation + ); + } + + /// The supervisor edge is resolved before it is stored, in the ONE namespace the child's own + /// migration state says its `supervisor` value is written in. + /// + /// Migration adds a child's `id` and rewrites its references to their parents' IDs in the same + /// atomic transition, so an explicit-`id` child's `supervisor` is an ID and an unmigrated + /// child's is still a positional reference. Neither namespace falls back onto the other. + #[test] + fn a_supervisor_reference_resolves_only_in_the_childs_own_namespace() { + const PARENT_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320aaa"; + let mut parent = spec(vec![task("agent", TaskKind::Pty, Some(PARENT_ID))]); + parent.identity = "root".to_owned(); + parent.id = Some(AgentId::parse(PARENT_ID).unwrap()); + parent.address = Some(AgentAddress::parse("org.root").unwrap()); + + let target = |specs: &[AgentSpec]| { + let book = crate::spec::address_book(specs, "dev3").ok(); + resolve_supervisor(specs, book.as_ref(), &specs[1], "dev3") + }; + + // A MIGRATED child's reference is an exact ID, and only the ID namespace answers. + let mut migrated_child = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + migrated_child.supervisor = Some(PARENT_ID.to_owned()); + assert_eq!( + target(&[parent.clone(), migrated_child.clone()]), + SupervisorTarget::Resolved(PARENT_ID.to_owned()) + ); + + // The parent's ADDRESS is not an ID, so a migrated child naming it does not resolve: that + // declaration was not rewritten by migration and must be repaired, not guessed at. + migrated_child.supervisor = Some("org.root".to_owned()); + assert_eq!( + target(&[parent.clone(), migrated_child.clone()]), + SupervisorTarget::Unresolved("org.root".to_owned()) + ); + + // An UNMIGRATED child's reference is a legacy POSITIONAL reference: a bare `` on + // its own host, or a qualified `.`. It names a declaration slot, never a + // mutable route — which is what keeps a retired (non-routable) or cross-host parent + // reachable, and its qualified form is exactly that parent's frozen legacy ID. + let mut legacy_parent = spec(vec![task("agent", TaskKind::Pty, None)]); + legacy_parent.identity = "root".to_owned(); + let mut legacy_child = spec(vec![task("agent", TaskKind::Pty, None)]); + legacy_child.identity = "worker".to_owned(); + for reference in ["root", "dev3.root"] { + legacy_child.supervisor = Some(reference.to_owned()); + assert_eq!( + target(&[legacy_parent.clone(), legacy_child.clone()]), + SupervisorTarget::Resolved("dev3.root".to_owned()), + "unmigrated child reference {reference}" + ); + } + + // A migrated parent's `address` is a route, not a positional key: an unmigrated child + // naming it was never rewritten by migration and is reported for repair, not guessed at. + legacy_child.supervisor = Some("org.root".to_owned()); + assert_eq!( + target(&[parent.clone(), legacy_child.clone()]), + SupervisorTarget::Unresolved("org.root".to_owned()) + ); + + // A reference that names nothing is reported, never silently treated as "no supervisor". + legacy_child.supervisor = Some("gone".to_owned()); + assert_eq!( + target(&[parent.clone(), legacy_child.clone()]), + SupervisorTarget::Unresolved("gone".to_owned()) + ); + + legacy_child.supervisor = None; + assert_eq!( + target(&[parent, legacy_child]), + SupervisorTarget::Undeclared + ); + } + + /// Equal bytes in the two namespaces must never collide. An unmigrated child's `supervisor` + /// is a positional reference, so an unrelated subject whose explicit *ID* is those same bytes + /// cannot capture the edge from the declaration that reference actually names. + #[test] + fn an_unrelated_subject_whose_id_equals_the_reference_cannot_capture_the_edge() { + // The impostor: its immutable ID is literally `boss`, and it holds no such address. + let mut impostor = spec(vec![task("agent", TaskKind::Pty, Some("boss"))]); + impostor.identity = "impostor".to_owned(); + impostor.id = Some(AgentId::parse("boss").unwrap()); + impostor.address = Some(AgentAddress::parse("unrelated.impostor").unwrap()); + + // The real parent: unmigrated, so the bare reference `boss` qualifies to its frozen + // positional ID `dev3.boss`. + let mut real_parent = spec(vec![task("agent", TaskKind::Pty, None)]); + real_parent.identity = "boss".to_owned(); + + let mut child = spec(vec![task("agent", TaskKind::Pty, None)]); + child.identity = "worker".to_owned(); + child.supervisor = Some("boss".to_owned()); + + let specs = vec![impostor, real_parent, child]; + let book = crate::spec::address_book(&specs, "dev3").ok(); + assert_eq!( + resolve_supervisor(&specs, book.as_ref(), &specs[2], "dev3"), + SupervisorTarget::Resolved("dev3.boss".to_owned()), + "an unmigrated child's reference is positional; the subject whose explicit ID is \ + `boss` must not be able to steal the edge" + ); + } + + /// The plan carries that resolved target on the launch, so the park path never re-parses the + /// authored reference. + #[test] + fn a_launch_carries_the_resolved_supervisor_id() { + const PARENT_ID: &str = "0199b8f4-8d3a-7c21-9a44-6f85b7320aaa"; + let mut parent = spec(vec![task("agent", TaskKind::Pty, Some(PARENT_ID))]); + parent.identity = "root".to_owned(); + parent.id = Some(AgentId::parse(PARENT_ID).unwrap()); + parent.address = Some(AgentAddress::parse("org.root").unwrap()); + let mut child = migrated(vec![task("agent", TaskKind::Pty, Some(ID))]); + // The child is migrated, so migration already rewrote this reference to the parent's ID. + child.supervisor = Some(PARENT_ID.to_owned()); + let specs = vec![parent, child]; + + let plan = reconcile(&specs, &[], "dev3").unwrap(); + let launched = plan + .launch + .iter() + .find(|launch| launch.spec.identity == "worker") + .expect("the child launches"); + assert_eq!( + launched.supervisor, + SupervisorTarget::Resolved(PARENT_ID.to_owned()) + ); + + let selected = reconcile_selected(&specs, &[], "dev3", ID).unwrap(); + assert_eq!( + selected.launch[0].supervisor, + SupervisorTarget::Resolved(PARENT_ID.to_owned()) + ); + } +} diff --git a/src/request.rs b/src/request.rs index c1cfa53e..b364701a 100644 --- a/src/request.rs +++ b/src/request.rs @@ -13,7 +13,66 @@ use sha2::{Digest, Sha256}; use crate::message; +/// Typed-request schema versions. +/// +/// In a version-1 envelope every Agent endpoint (`to` on a request, `from` on a reply) means that +/// agent's BUS ADDRESS. In version 2 it means the immutable agent ID, with the publication-time +/// route kept beside it as a display-only snapshot. A service principal's `from`/`replyTo` is its +/// own canonical route at BOTH versions — a principal is not an Agent and has no agent ID. const REQUEST_VERSION: u32 = 1; +const REQUEST_VERSION_2: u32 = 2; + +/// The single switch for the version-2 typed-request writer, a member of the same DELTA-003 +/// activation cohort as [`crate::message::WRITE_MESSAGE_RECORD_VERSION_2`]. +/// +/// Reader-first held first: [`agent_endpoint_id`] and the version checks below accept both +/// versions, and that landed before this flipped. It is ON because `publish` resolves its +/// recipient to an immutable agent ID, and writing an ID into a schema whose `to` means a bus +/// address would leave a reader-first binary unable to validate, read, or reply to the request. +pub const WRITE_REQUEST_VERSION_2: bool = true; + +const REQUEST_WRITE_VERSION: u32 = if WRITE_REQUEST_VERSION_2 { + REQUEST_VERSION_2 +} else { + REQUEST_VERSION +}; + +/// Whether a typed-request reader understands this schema version. +fn supported_request_version(version: u32) -> bool { + version == REQUEST_VERSION || version == REQUEST_VERSION_2 +} + +/// The immutable agent ID one durable Agent endpoint denotes. +/// +/// A version-2 endpoint already IS an ID. A version-1 endpoint is a bus address whose bytes +/// migration froze as that subject's ID — unless migration reassigned them, in which case the +/// bytes denote either subject. `owner` names the agent that independently owns the row those +/// bytes were read from (the recipient of a request in its own inbox); pass `None` when nothing +/// proves ownership, and a colliding endpoint then refuses with `Ok(None)` rather than resolving +/// to whichever subject kept the bytes (`MESSAGE-R04`). +/// +/// `Err` is the distinct third answer: the durable collision record exists but this binary cannot +/// read it, so no legacy endpoint can be attributed at all. That must not collapse into +/// "no collisions recorded", which would retype contested bytes into the keeper. +fn agent_endpoint_id( + root: &Path, + version: u32, + endpoint: &str, + owner: Option<&str>, +) -> anyhow::Result> { + let collisions = crate::catalog_migrate::load_legacy_id_collisions(root)?; + Ok(message::DurableEndpoint { + version, + value: endpoint, + kind: None, + owns_row: owner.is_some(), + owner_agent_id: owner.unwrap_or(endpoint), + } + .attribute(&collisions) + .agent_id() + .map(str::to_owned)) +} + static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,7 +83,11 @@ pub struct ServicePrincipal { } impl ServicePrincipal { - pub fn bus_id(&self) -> String { + /// The principal's canonical bus route. + /// + /// A service principal is NOT an Agent: this is its own typed endpoint, it lives in a separate + /// namespace from agent IDs, and it is never usable as one (`MESSAGE-R11`). + pub fn bus_address(&self) -> String { format!("{}.{}", self.host, self.identity) } @@ -43,7 +106,12 @@ struct RequestEnvelope { version: u32, idempotency_key: String, from: String, + /// The recipient Agent. Version 2: its immutable agent ID. Version 1: its bus address. to: String, + /// Version 2 only: the recipient's bus address at publication time. Display only — a released + /// address is immediately reusable, so it is never a selector and never delivery authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + to_address: Option, reply_to: String, tags: BTreeMap, body: Value, @@ -55,7 +123,11 @@ struct ReplyEnvelope { version: u32, idempotency_key: String, request_filename: String, + /// The replying Agent. Version 2: its immutable agent ID. Version 1: its bus address. from: String, + /// Version 2 only: the replying agent's publication-time bus address. Display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + from_address: Option, tags: BTreeMap, body: Value, } @@ -132,7 +204,7 @@ pub fn discover_principals(root: &Path) -> anyhow::Result> declarations.sort(); let mut principals = Vec::new(); - let mut bus_ids = HashSet::new(); + let mut principal_addresses = HashSet::new(); for declaration in declarations { let text = fs::read_to_string(&declaration)?; let document = KdlDocument::parse(&text).map_err(|error| { @@ -179,8 +251,8 @@ pub fn discover_principals(root: &Path) -> anyhow::Result> host, path: directory.to_path_buf(), }; - if !bus_ids.insert(principal.bus_id()) { - anyhow::bail!("duplicate service principal `{}`", principal.bus_id()); + if !principal_addresses.insert(principal.bus_address()) { + anyhow::bail!("duplicate service principal `{}`", principal.bus_address()); } principals.push(principal); } @@ -195,7 +267,7 @@ pub fn resolve_principal( let principal = discover_principals(root)? .into_iter() .find(|principal| { - principal.bus_id() == identity + principal.bus_address() == identity || (principal.host == this_host && principal.identity == identity) }) .with_context(|| { @@ -204,60 +276,123 @@ pub fn resolve_principal( root.display() ) })?; + // The collision that matters is a human one: a principal route and an Agent's bus ADDRESS are + // both things a person types to reach someone, so those two namespaces must stay disjoint. + // Agent IDs are a separate namespace and equal bytes there never collide. if crate::discover(root) .specs .into_iter() - .any(|spec| spec.bus_id(this_host) == principal.bus_id()) + .any(|spec| spec.bus_address(this_host) == principal.bus_address()) { anyhow::bail!( - "service principal `{}` collides with an Agent Spec identity", - principal.bus_id() + "service principal `{}` collides with an Agent Spec address", + principal.bus_address() ); } Ok(principal) } +/// One Agent party to a typed request, as the durable record must carry it. +struct AgentParty { + /// The immutable agent ID: what a version-2 record persists. + id: String, + /// The current bus address: what a version-1 record persists, and what a version-2 record + /// keeps only as a display snapshot. + address: String, + path: PathBuf, +} + +impl AgentParty { + /// The value this schema version persists in an Agent endpoint field. + fn endpoint(&self) -> &str { + if WRITE_REQUEST_VERSION_2 { + &self.id + } else { + &self.address + } + } + + /// The publication-time snapshot, written only alongside a version-2 ID. + fn address_snapshot(&self) -> Option { + WRITE_REQUEST_VERSION_2.then(|| self.address.clone()) + } +} + +/// Resolve the Agent side of a typed request. +/// +/// The selector is typed because both forms genuinely occur: a CLI positional or `--agent` value +/// is an ordinary ADDRESS and must go through the address book, while `--id` and `$ST_AGENT` are +/// EXACT IDs. Forcing a positional through exact-ID lookup makes every subject that carries a +/// generated ID unreachable by the only name a person knows for it. fn resolve_agent( root: &Path, - identity: &str, + selector: &crate::AgentSelector, this_host: &str, -) -> anyhow::Result<(String, PathBuf)> { +) -> anyhow::Result { let discovered = crate::discover(root); - let spec = discovered + let id = crate::spec::address_book(&discovered.specs, this_host)? + .resolve(selector) + .map_err(|error| anyhow::anyhow!("{error} in catalog {}", root.display()))? + .id + .as_str() + .to_owned(); + // Back-mapping the resolved subject to its declaration is itself a uniqueness proof. Only + // `resolve_id` refuses `AmbiguousId`; `resolve_address` dedups its candidates BY agent ID, so + // an address naming one of two subjects that share an ID resolves cleanly to a single Subject + // and a first-match scan would then publish into whichever declaration discovery ordered + // first. Both selector kinds therefore prove it here. + let mut declarations = discovered .specs - .into_iter() - .find(|spec| { - spec.bus_id(this_host) == identity - || (spec.resolved_host(this_host) == this_host && spec.identity == identity) - }) - .with_context(|| format!("no agent '{identity}' found in catalog {}", root.display()))?; - let bus_id = spec.bus_id(this_host); + .iter() + .filter(|spec| spec.agent_id(this_host) == id); + let spec = declarations + .next() + .context("resolved subject has no declaration")?; + if let Some(duplicate) = declarations.next() { + anyhow::bail!( + "agent id '{id}' is declared by more than one subject ({}, {}); refusing to guess \ + which declaration this request belongs to", + spec.path.display(), + duplicate.path.display() + ); + } let path = spec .path .parent() .context("agent declaration has no parent")? .to_path_buf(); - Ok((bus_id, path)) + Ok(AgentParty { + id, + address: spec.bus_address(this_host), + path, + }) } +/// Publish a typed request from a service principal to one Agent. +/// +/// `recipient` is typed: the CLI positional is an ordinary address, `--id` is an exact ID. +/// `principal_identity` is the principal's own route — a service principal is not an Agent and +/// never enters the agent-ID namespace. pub fn publish( root: &Path, this_host: &str, principal_identity: &str, - recipient: &str, + recipient: &crate::AgentSelector, idempotency_key: &str, tags: BTreeMap, body: Value, ) -> anyhow::Result { require_key(idempotency_key)?; let principal = resolve_principal(root, principal_identity, this_host)?; - let (to, recipient_dir) = resolve_agent(root, recipient, this_host)?; - let from = principal.bus_id(); + let recipient = resolve_agent(root, recipient, this_host)?; + let from = principal.bus_address(); + let to = recipient.endpoint().to_owned(); let envelope = RequestEnvelope { - version: REQUEST_VERSION, + version: REQUEST_WRITE_VERSION, idempotency_key: idempotency_key.to_string(), from: from.clone(), to: to.clone(), + to_address: recipient.address_snapshot(), reply_to: from.clone(), tags, body, @@ -269,9 +404,11 @@ pub fn publish( idempotency_key, idempotency_key, &from, + // A principal has no agent ID, so the rendered `from:` route IS its canonical endpoint. + None, &to, &envelope, - &message::inbox_dir(&recipient_dir), + &message::inbox_dir(&recipient.path), Some(&format!("request {idempotency_key}")), None, &["st2-request".to_string()], @@ -279,37 +416,48 @@ pub fn publish( ) } +/// Reply to a typed request as the Agent it was addressed to. pub fn reply( root: &Path, this_host: &str, - agent_identity: &str, + agent: &crate::AgentSelector, request_filename: &str, tags: BTreeMap, body: Value, ) -> anyhow::Result { - let (from, agent_dir) = resolve_agent(root, agent_identity, this_host)?; - let request_message = read_inbox_or_archive(&agent_dir, request_filename)?; + let agent = resolve_agent(root, agent, this_host)?; + let request_message = read_inbox_or_archive(&agent.path, request_filename)?; let request = parse_request_message(&request_message)?; - if request.version != REQUEST_VERSION || request.to != from { - anyhow::bail!("request is not addressed to agent `{from}`"); - } + // The record was read from THIS agent's own boxes, which is what proves it owns the row and + // therefore what licenses attributing a colliding version-1 recipient endpoint to it. + anyhow::ensure!( + supported_request_version(request.version) + && agent_endpoint_id(root, request.version, &request.to, Some(&agent.id))?.as_deref() + == Some(agent.id.as_str()), + "request is not addressed to agent `{}`", + agent.id + ); let principal = resolve_principal(root, &request.reply_to, this_host)?; + let from = agent.endpoint().to_owned(); let envelope = ReplyEnvelope { - version: REQUEST_VERSION, + version: REQUEST_WRITE_VERSION, idempotency_key: request.idempotency_key.clone(), request_filename: request_filename.to_string(), from: from.clone(), + from_address: agent.address_snapshot(), tags, body, }; let envelope = serde_json::to_string(&envelope)?; let state_key = format!("{}\0{}", request.reply_to, request.idempotency_key); - let record_dir = agent_dir.join("resources/request-state/replies"); + let record_dir = agent.path.join("resources/request-state/replies"); publish_once( &record_dir, &state_key, &request.idempotency_key, - &from, + // The rendered `from:` line is the agent's ROUTE; `from-id:` carries the authority. + &agent.address, + WRITE_REQUEST_VERSION_2.then_some(agent.id.as_str()), &request.reply_to, &envelope, &principal.inbox(), @@ -323,15 +471,19 @@ pub fn reply( pub fn read( root: &Path, this_host: &str, - agent_identity: &str, + agent_selector: &crate::AgentSelector, request_filename: &str, ) -> anyhow::Result { - let (agent, agent_dir) = resolve_agent(root, agent_identity, this_host)?; - let message = read_inbox_or_archive(&agent_dir, request_filename)?; + let agent = resolve_agent(root, agent_selector, this_host)?; + let message = read_inbox_or_archive(&agent.path, request_filename)?; let request = parse_request_message(&message)?; - if request.version != REQUEST_VERSION || request.to != agent { - anyhow::bail!("request is not addressed to agent `{agent}`"); - } + anyhow::ensure!( + supported_request_version(request.version) + && agent_endpoint_id(root, request.version, &request.to, Some(&agent.id))?.as_deref() + == Some(agent.id.as_str()), + "request is not addressed to agent `{}`", + agent.id + ); resolve_principal(root, &request.reply_to, this_host)?; Ok(IncomingRequest { status: "request", @@ -359,18 +511,39 @@ pub fn status( format!("no published request for idempotency key `{idempotency_key}`") })?)?; + // The principal owns this outgoing record, so the Agent endpoint in it is NOT the row's own + // state owner. A colliding version-1 endpoint is therefore unattributed and matches nothing: + // pairing it with a reply would credit whichever subject kept the legacy bytes. + let Some(expected_agent) = agent_endpoint_id(root, record.version, &record.to, None)? else { + anyhow::bail!( + "request recipient `{}` is a legacy bus identity migration reassigned; \ + this record cannot prove which subject it was addressed to", + record.to + ); + }; + let mut replies = Vec::new(); for directory in [principal.inbox(), principal.archive()] { for candidate in message::list_dir(&directory)? { if candidate.in_reply_to.as_deref() != Some(record.filename.as_str()) { continue; } - if let Ok(reply) = serde_json::from_str::(candidate.body.trim()) - && reply.version == REQUEST_VERSION + let Ok(reply) = serde_json::from_str::(candidate.body.trim()) else { + continue; + }; + // A version-2 reply's `from` is an agent ID and the frontmatter route is only a + // display snapshot, so the frontmatter cross-check keys on `from-id` at that version. + let frontmatter_matches = if reply.version >= REQUEST_VERSION_2 { + candidate.from_id.as_deref() == Some(reply.from.as_str()) + } else { + candidate.from.as_deref() == Some(reply.from.as_str()) + }; + if supported_request_version(reply.version) && reply.idempotency_key == idempotency_key && reply.request_filename == record.filename - && reply.from == record.to - && candidate.from.as_deref() == Some(reply.from.as_str()) + && agent_endpoint_id(root, reply.version, &reply.from, None)?.as_deref() + == Some(expected_agent.as_str()) + && frontmatter_matches && candidate.tags.iter().any(|tag| tag == "st2-request-reply") { replies.push(reply); @@ -400,6 +573,8 @@ fn publish_once( state_key: &str, receipt_key: &str, from: &str, + // The sender's immutable agent ID, when the sender is an Agent and the writer is on version 2. + from_id: Option<&str>, to: &str, envelope: &str, inbox: &Path, @@ -410,8 +585,8 @@ fn publish_once( ) -> anyhow::Result { fs::create_dir_all(state_dir)?; let path = record_path(state_dir, state_key); - let candidate = PublicationRecord { - version: REQUEST_VERSION, + let mut candidate = PublicationRecord { + version: REQUEST_WRITE_VERSION, idempotency_key: state_key.to_string(), from: from.to_string(), to: to.to_string(), @@ -419,9 +594,9 @@ fn publish_once( envelope: envelope.to_string(), rendered_message: String::new(), }; - let mut candidate = candidate; - candidate.rendered_message = message::render_message( + candidate.rendered_message = message::render_agent_message( from, + from_id, subject, in_reply_to, message_tags, @@ -434,7 +609,7 @@ fn publish_once( } else { serde_json::from_slice(&fs::read(&path)?)? }; - if record.version != REQUEST_VERSION + if !supported_request_version(record.version) || record.idempotency_key != state_key || record.from != from || record.to != to @@ -504,3 +679,201 @@ fn read_inbox_or_archive(agent_dir: &Path, filename: &str) -> anyhow::Result tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let principal = root.join("principals/h/example-ci"); + fs::create_dir_all(&principal).unwrap(); + fs::write( + principal.join("principal.kdl"), + "principal \"example-ci\" host=\"h\"\n", + ) + .unwrap(); + // A MIGRATED agent: its immutable ID is a UUID and its route is a separate mutable address. + let agent = root.join("h/worker"); + fs::create_dir_all(&agent).unwrap(); + fs::write( + agent.join("agent.kdl"), + "agent \"worker\" {\n identity \"worker\"\n id \"worker-uuid\"\n address \"chat\"\n host \"h\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", + ) + .unwrap(); + temp + } + + /// A CLI positional is an ordinary ADDRESS. Forcing it through exact-ID lookup made every + /// subject carrying a generated ID unreachable by the only name a person knows for it. + #[test] + fn a_positional_address_resolves_a_uuid_id_agent_and_persists_the_id() { + let temp = catalog(); + let root = temp.path(); + + let by_address = resolve_agent(root, &AgentSelector::address("chat"), "h").unwrap(); + assert_eq!(by_address.id, "worker-uuid"); + assert_eq!(by_address.address, "h.chat"); + + let by_qualified = resolve_agent(root, &AgentSelector::address("h.chat"), "h").unwrap(); + assert_eq!(by_qualified.id, "worker-uuid"); + + let by_id = resolve_agent(root, &AgentSelector::id("worker-uuid"), "h").unwrap(); + assert_eq!(by_id.id, "worker-uuid"); + + // The two namespaces stay disjoint in both directions. + assert!(resolve_agent(root, &AgentSelector::id("chat"), "h").is_err()); + assert!(resolve_agent(root, &AgentSelector::address("worker-uuid"), "h").is_err()); + } + + /// Decision 1 for the request plane: a record that carries an immutable ID must DECLARE + /// version 2. Under version 1 `to` means the agent's bus address, so an ID stored there is + /// unreadable to a reader-first binary. + #[test] + fn publish_writes_a_version_2_envelope_carrying_the_id_and_an_address_snapshot() { + let temp = catalog(); + let root = temp.path(); + assert!(WRITE_REQUEST_VERSION_2, "the request writer cohort is on"); + + let receipt = publish( + root, + "h", + "h.example-ci", + &AgentSelector::address("chat"), + "key-1", + BTreeMap::new(), + serde_json::json!({ "run": 1 }), + ) + .unwrap(); + + let delivered = message::read_msg(&message::inbox_dir(&root.join("h/worker")), &receipt.filename) + .unwrap(); + let envelope: RequestEnvelope = serde_json::from_str(delivered.body.trim()).unwrap(); + assert_eq!(envelope.version, REQUEST_VERSION_2); + assert_eq!(envelope.to, "worker-uuid"); + assert_eq!(envelope.to_address.as_deref(), Some("h.chat")); + // A principal is not an Agent: both ends of its own route stay the route. + assert_eq!(envelope.from, "h.example-ci"); + assert_eq!(envelope.reply_to, "h.example-ci"); + assert_eq!(delivered.from.as_deref(), Some("h.example-ci")); + assert_eq!(delivered.from_id, None); + + // The agent it names can read and reply to it. + let incoming = read(root, "h", &AgentSelector::id("worker-uuid"), &receipt.filename).unwrap(); + assert_eq!(incoming.from, "h.example-ci"); + let reply_receipt = reply( + root, + "h", + &AgentSelector::address("chat"), + &receipt.filename, + BTreeMap::new(), + serde_json::json!({ "ok": true }), + ) + .unwrap(); + let replied = message::read_msg( + &message::inbox_dir(&root.join("principals/h/example-ci")), + &reply_receipt.filename, + ) + .unwrap(); + let reply_envelope: ReplyEnvelope = serde_json::from_str(replied.body.trim()).unwrap(); + assert_eq!(reply_envelope.version, REQUEST_VERSION_2); + assert_eq!(reply_envelope.from, "worker-uuid"); + assert_eq!(reply_envelope.from_address.as_deref(), Some("h.chat")); + // The rendered route is display; `from-id` is the authority the principal matches on. + assert_eq!(replied.from.as_deref(), Some("h.chat")); + assert_eq!(replied.from_id.as_deref(), Some("worker-uuid")); + + assert!(matches!( + status(root, "h", "h.example-ci", "key-1").unwrap(), + RequestStatus::Replied { from, .. } if from == "worker-uuid" + )); + } + + /// A version-1 endpoint whose legacy bytes migration reassigned denotes either subject. Where + /// nothing proves ownership it must refuse, not resolve to whichever subject kept the bytes. + #[test] + fn a_reassigned_version_1_endpoint_is_unattributed_without_proof_of_ownership() { + let temp = catalog(); + let root = temp.path(); + let control = crate::catalog_migrate::legacy_id_collisions_path(root); + fs::create_dir_all(control.parent().unwrap()).unwrap(); + fs::write( + &control, + serde_json::to_vec(&crate::catalog_migrate::LegacyIdCollisions { + schema: "st2.catalog-legacy-id-collisions.v1".to_owned(), + entries: vec![crate::catalog_migrate::LegacyIdCollision { + legacy_bus_identity: "h.worker".to_owned(), + keeper: crate::AgentId::parse("h.worker").unwrap(), + reassigned: vec![crate::AgentId::parse("worker-uuid").unwrap()], + }], + }) + .unwrap(), + ) + .unwrap(); + + // No proof of ownership: refuse rather than credit the keeper. + assert_eq!( + agent_endpoint_id(root, REQUEST_VERSION, "h.worker", None).unwrap(), + None + ); + // The recipient of a request found in its OWN boxes does own the row. + assert_eq!( + agent_endpoint_id(root, REQUEST_VERSION, "h.worker", Some("worker-uuid")) + .unwrap() + .as_deref(), + Some("worker-uuid") + ); + // Uncontested legacy bytes are that subject's frozen ID at version 1. + assert_eq!( + agent_endpoint_id(root, REQUEST_VERSION, "h.other", None) + .unwrap() + .as_deref(), + Some("h.other") + ); + // A version-2 endpoint is already an ID and is never reinterpreted. + assert_eq!( + agent_endpoint_id(root, REQUEST_VERSION_2, "h.worker", None) + .unwrap() + .as_deref(), + Some("h.worker") + ); + } + + /// A collision record that exists but cannot be read proves nothing about which legacy bytes + /// were contested, so attribution refuses instead of reading as "no collisions" and retyping + /// the bytes into their apparent owner. + #[test] + fn an_unreadable_collision_record_refuses_legacy_attribution() { + for body in [ + b"{ not json".to_vec(), + serde_json::to_vec(&serde_json::json!({ + "schema": "st2.catalog-legacy-id-collisions.v2", + "entries": [] + })) + .unwrap(), + ] { + let temp = catalog(); + let root = temp.path(); + let record = crate::catalog_migrate::legacy_id_collisions_path(root); + fs::create_dir_all(record.parent().unwrap()).unwrap(); + fs::write(&record, &body).unwrap(); + + let refusal = agent_endpoint_id(root, REQUEST_VERSION, "h.worker", Some("h.worker")) + .expect_err("an unreadable collision record must refuse"); + let rendered = format!("{refusal:#}"); + assert!( + rendered.contains("legacy-id-collision"), + "refusal must name the collision record: {rendered}" + ); + + // Even a version-2 endpoint, which never consults the record's contents, refuses: + // a catalog whose collision set is unreadable cannot answer attribution at all. + assert!( + agent_endpoint_id(root, REQUEST_VERSION_2, "h.worker", None).is_err(), + "an unreadable collision record must refuse at every version" + ); + } + } +} diff --git a/src/resource_profile_supervisor.rs b/src/resource_profile_supervisor.rs index 68feaddc..251bcceb 100644 --- a/src/resource_profile_supervisor.rs +++ b/src/resource_profile_supervisor.rs @@ -172,7 +172,9 @@ impl ResourceProfileSupervisor { /// Fence one agent before its canonical seat is replaced. Completion is synchronous. pub fn deactivate(&self, spec: &AgentSpec) { - let recipient = spec.bus_id(&self.this_host); + // The fence targets the durable observation, which is keyed by immutable agent ID: an + // address cutover must not re-key an active Resource observation. + let recipient = spec.agent_id(&self.this_host); let (reply_tx, reply_rx) = mpsc::sync_channel(1); if self .tx @@ -478,7 +480,7 @@ fn desired_bindings( warnings.push(format!( "Resource Profile '{}' binding {} resource '{}': {error}", scheme, - spec.bus_id(this_host), + spec.bus_address(this_host), resource.name() )); continue; @@ -489,7 +491,7 @@ fn desired_bindings( warnings.push(format!( "Resource Profile '{}' binding {} resource '{}': resolver returned no carrier", scheme, - spec.bus_id(this_host), + spec.bus_address(this_host), resource.name() )); continue; @@ -498,7 +500,7 @@ fn desired_bindings( warnings.push(format!( "Resource Profile '{}' binding {} resource '{}': {error}", scheme, - spec.bus_id(this_host), + spec.bus_address(this_host), resource.name() )); continue; @@ -510,13 +512,16 @@ fn desired_bindings( warnings.push(format!( "Resource Profile '{}' binding {} resource '{}': {error}", scheme, - spec.bus_id(this_host), + spec.bus_address(this_host), resource.name() )); continue; } }; - let recipient = spec.bus_id(this_host); + // The stable key is the agent ID, so a rename or an address cutover keeps one + // observation rather than starting a second beside it. The warnings above name the + // bus address instead: they are read by a person who has to go and reach the agent. + let recipient = spec.agent_id(this_host); let stable_key = format!("{recipient}\0{}", resource.name()); desired.insert( stable_key.clone(), diff --git a/src/resync.rs b/src/resync.rs index 2be9f9ff..4225f5e6 100644 --- a/src/resync.rs +++ b/src/resync.rs @@ -141,11 +141,14 @@ fn update_digest_field(digest: &mut Sha256, value: &[u8]) { digest.update(value); } -/// The watchable carriers of one agent, keyed by its declaration path with current routing IDs. +/// The watchable carriers of one agent, keyed by its declaration path and its immutable agent ID. +/// +/// Subscriptions, supersession keys, and seat ownership are all ownership, so they key on the ID: +/// an address change must not detach a live subscription or rewrite a pending occurrence. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentWatchSet { pub declaration_path: PathBuf, - pub bus_id: String, + pub agent_id: String, pub seat_id: Option, pub carriers: Vec, declaration_summary: Option, @@ -224,7 +227,7 @@ fn resolve_watch_set( Err(error) => { diagnostics.push(format!( "resync profile for {} resource '{}': {error}; binding is unwatchable", - spec.bus_id(this_host), + spec.bus_address(this_host), resource.name() )); continue; @@ -256,11 +259,11 @@ fn resolve_watch_set( ( AgentWatchSet { declaration_path, - bus_id: spec.bus_id(this_host), + agent_id: spec.agent_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)) + .unwrap_or_else(|| format!("{}.{}", spec.agent_id(this_host), task.name)) }), carriers, declaration_summary: Some(declaration_summary(spec)), @@ -315,7 +318,7 @@ fn append_chain_carriers( diagnostics.push(format!( "resync notify-chain for {}: supervisor chain is unwalkable ({error:?}); \ ancestor carriers are unwatchable", - spec.bus_id(this_host) + spec.bus_address(this_host) )); return; } @@ -329,7 +332,7 @@ fn append_chain_carriers( } let ancestor_declaration = lexical_clean(&ancestor.path); let ancestor_dir = ancestor_declaration.parent().unwrap_or(Path::new(".")); - let ancestor_bus_id = ancestor.bus_id(this_host); + let ancestor_agent_id = ancestor.agent_id(this_host); for resource in &ancestor.resources { if resource.inactive_reason().is_some() { continue; @@ -348,7 +351,7 @@ fn append_chain_carriers( carriers.push(WatchableCarrier { // Qualifying by owner keeps each ancestor's layer on its own supersession // key, so a burst on one ancestor cannot collapse another's event. - label: format!("{}@{ancestor_bus_id}", resource.name()), + label: format!("{}@{ancestor_agent_id}", resource.name()), path: resolution.path, class, containment_root: Some(resolution.containment_root), @@ -356,9 +359,10 @@ fn append_chain_carriers( } Ok(None) => {} Err(error) => diagnostics.push(format!( - "resync notify-chain for {}: ancestor {ancestor_bus_id} resource '{}': \ + "resync notify-chain for {}: ancestor {} resource '{}': \ {error}; that ancestor layer is unwatchable", - spec.bus_id(this_host), + spec.bus_address(this_host), + ancestor.bus_address(this_host), resource.name() )), } @@ -524,7 +528,7 @@ enum Msg { /// One handed-off publication finished. Outcomes return through the worker's own mailbox so /// the worker remains the only writer of carrier baselines and retry deadlines. Emitted { - bus_id: String, + agent_id: String, label: String, outcome: PublicationOutcome, }, @@ -708,7 +712,7 @@ impl ResyncSupervisor { 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(spec.agent_id(this_host), ack_tx)).is_ok()) { let _ = ack_rx.recv(); } @@ -898,7 +902,7 @@ impl PendingTransition { } struct Entry { - bus_id: String, + agent_id: String, seat_id: Option, label: String, class: CarrierClass, @@ -952,7 +956,7 @@ fn is_mutation(event: ¬ify::Event) -> bool { /// One publication handed to the emitter thread. struct EmitJob { - bus_id: String, + agent_id: String, label: String, transition: PendingTransition, } @@ -1008,13 +1012,13 @@ impl EmitQueue { /// Drop every queued publication for one recipient. A publication already taken by the /// emitter is left alone: it was in flight while the subscription was still active, and its /// outcome is discarded by the worker when the subscription is gone. - fn cancel(&self, bus_id: &str) { - self.lock().jobs.retain(|job| job.bus_id != bus_id); + fn cancel(&self, agent_id: &str) { + self.lock().jobs.retain(|job| job.agent_id != agent_id); } /// Drop every queued publication whose recipient is no longer an active subscription. fn retain_recipients(&self, active: &BTreeSet) { - self.lock().jobs.retain(|job| active.contains(&job.bus_id)); + self.lock().jobs.retain(|job| active.contains(&job.agent_id)); } /// Pop one queued publication without waiting. Only the synchronous test drive uses this; @@ -1030,7 +1034,7 @@ impl EmitQueue { self.lock() .jobs .iter() - .map(|job| job.bus_id.clone()) + .map(|job| job.agent_id.clone()) .collect() } @@ -1060,10 +1064,10 @@ impl EmitQueue { fn emitter_loop(root: PathBuf, this_host: String, queue: Arc, outcomes: Sender) { while let Some(job) = queue.next() { - let outcome = emit_resync(&root, &this_host, &job.bus_id, &job.transition); + let outcome = emit_resync(&root, &this_host, &job.agent_id, &job.transition); if outcomes .send(Msg::Emitted { - bus_id: job.bus_id, + agent_id: job.agent_id, label: job.label, outcome, }) @@ -1155,17 +1159,17 @@ fn worker_loop(root: PathBuf, this_host: String, rx: Receiver, forward: Sen worker.install_watch_set(set); let _ = ack.send(()); } - Ok(Msg::Deactivate(bus_id, ack)) => { - worker.deactivate_watch_set(&bus_id); + Ok(Msg::Deactivate(agent_id, ack)) => { + worker.deactivate_watch_set(&agent_id); let _ = ack.send(()); } Ok(Msg::Mutations(paths)) => worker.mark_mutated(paths), Ok(Msg::Rescan) => worker.rescan_all(), Ok(Msg::Emitted { - bus_id, + agent_id, label, outcome, - }) => worker.record_publication(&bus_id, &label, outcome), + }) => worker.record_publication(&agent_id, &label, outcome), Ok(Msg::Shutdown) => break, Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, @@ -1180,13 +1184,13 @@ fn worker_loop(root: PathBuf, this_host: String, rx: Receiver, forward: Sen fn take_retained_entry( previous: &mut BTreeMap>, - bus_id: &str, + agent_id: &str, label: &str, ) -> Option { for entries in previous.values_mut() { if let Some(index) = entries .iter() - .position(|entry| entry.bus_id == bus_id && entry.label == label) + .position(|entry| entry.agent_id == agent_id && entry.label == label) { return Some(entries.remove(index)); } @@ -1210,11 +1214,11 @@ fn rebuild_carriers( // across path buckets also lets a binding's re-resolved path/class/containment // metadata become current. A bus-id change intentionally seeds a new // recipient-scoped namespace. - let identity = (set.bus_id.clone(), carrier.label.clone()); + let identity = (set.agent_id.clone(), carrier.label.clone()); // Every recipient in a refresh set is running, so this is where a reservation parked // against a not-running recipient re-arms. let restored = parked_transitions.remove(&identity); - let retained = take_retained_entry(&mut previous, &set.bus_id, &carrier.label); + let retained = take_retained_entry(&mut previous, &set.agent_id, &carrier.label); let ( state, declaration_summary, @@ -1258,7 +1262,7 @@ fn rebuild_carriers( ); let restored_reservation = restored.is_some(); let entry = Entry { - bus_id: set.bus_id.clone(), + agent_id: set.agent_id.clone(), seat_id: set.seat_id.clone(), label: carrier.label.clone(), class: carrier.class, @@ -1306,7 +1310,7 @@ impl Worker { // scalar sequence floor per recipient/binding identity for this supervisor lifetime; // watches and carrier baselines remain exclusively in the active carrier map. for entry in previous.values().flatten() { - let identity = (entry.bus_id.clone(), entry.label.clone()); + let identity = (entry.agent_id.clone(), entry.label.clone()); self.subscription_sequences .entry(identity) .and_modify(|sequence| *sequence = (*sequence).max(entry.occurrence_sequence)) @@ -1317,7 +1321,7 @@ impl Worker { .flat_map(|(path, entries)| { entries.iter().map(|entry| { ( - (entry.bus_id.clone(), entry.label.clone()), + (entry.agent_id.clone(), entry.label.clone()), path.clone(), ) }) @@ -1335,7 +1339,7 @@ impl Worker { .flat_map(|(path, entries)| { entries.iter().filter_map(move |entry| { previous_paths - .get(&(entry.bus_id.clone(), entry.label.clone())) + .get(&(entry.agent_id.clone(), entry.label.clone())) .filter(|previous_path| *previous_path != path) .map(|_| path.clone()) }) @@ -1379,14 +1383,14 @@ impl Worker { .carriers .values() .flatten() - .map(|entry| entry.bus_id.clone()) + .map(|entry| entry.agent_id.clone()) .collect::>(); self.emit.retain_recipients(&active); self.finish_carrier_update(&previous_paths); } fn install_watch_set(&mut self, set: AgentWatchSet) { - let replaced_bus_id = set.bus_id.clone(); + let replaced_agent_id = set.agent_id.clone(); let previous = std::mem::take(&mut self.carriers); let previous_paths = self.prepare_carrier_update(&previous); let mut retained = BTreeMap::>::new(); @@ -1394,7 +1398,7 @@ impl Worker { for (path, entries) in previous { let (matching, other): (Vec<_>, Vec<_>) = entries .into_iter() - .partition(|entry| entry.bus_id == replaced_bus_id); + .partition(|entry| entry.agent_id == replaced_agent_id); if !matching.is_empty() { retained.insert(path.clone(), matching); } @@ -1419,9 +1423,9 @@ impl Worker { self.finish_carrier_update(&previous_paths); } - fn deactivate_watch_set(&mut self, bus_id: &str) { + fn deactivate_watch_set(&mut self, agent_id: &str) { // Nothing may start publishing to this recipient once the acknowledgement returns. - self.emit.cancel(bus_id); + self.emit.cancel(agent_id); let previous = std::mem::take(&mut self.carriers); let previous_paths = self.prepare_carrier_update(&previous); self.carriers = previous @@ -1429,7 +1433,7 @@ impl Worker { .filter_map(|(path, entries)| { let retained = entries .into_iter() - .filter(|entry| entry.bus_id != bus_id) + .filter(|entry| entry.agent_id != agent_id) .collect::>(); (!retained.is_empty()).then_some((path, retained)) }) @@ -1632,8 +1636,8 @@ impl Worker { #[cfg(test)] fn drain_publications(&mut self) { while let Some(job) = self.emit.take_queued() { - let published = emit_resync(&self.root, &self.this_host, &job.bus_id, &job.transition); - self.record_publication(&job.bus_id, &job.label, published); + let published = emit_resync(&self.root, &self.this_host, &job.agent_id, &job.transition); + self.record_publication(&job.agent_id, &job.label, published); } } @@ -1704,7 +1708,7 @@ impl Worker { } entry.in_flight = true; handoffs.push(EmitJob { - bus_id: entry.bus_id.clone(), + agent_id: entry.agent_id.clone(), label: entry.label.clone(), transition: pending.clone(), }); @@ -1762,7 +1766,7 @@ impl Worker { entry.occurrence_sequence = sequence; entry.in_flight = true; handoffs.push(EmitJob { - bus_id: entry.bus_id.clone(), + agent_id: entry.agent_id.clone(), label: entry.label.clone(), transition: transition.clone(), }); @@ -1799,13 +1803,13 @@ impl Worker { /// /// An outcome whose subscription is gone — deactivated or refreshed away while the /// publication was outstanding — matches nothing and is discarded. - fn record_publication(&mut self, bus_id: &str, label: &str, outcome: PublicationOutcome) { + fn record_publication(&mut self, agent_id: &str, label: &str, outcome: PublicationOutcome) { let mut touched = Vec::new(); let mut parked = Vec::new(); for (path, entries) in &mut self.carriers { for entry in entries .iter_mut() - .filter(|entry| entry.bus_id == bus_id && entry.label == label) + .filter(|entry| entry.agent_id == agent_id && entry.label == label) { entry.in_flight = false; match outcome { @@ -1823,7 +1827,7 @@ impl Worker { entry.dirty = false; if let Some(reservation) = entry.pending_transition.take() { parked.push(( - (entry.bus_id.clone(), entry.label.clone()), + (entry.agent_id.clone(), entry.label.clone()), reservation, )); } @@ -1849,7 +1853,7 @@ fn transition_identity(body: &str) -> String { fn emit_resync( root: &Path, this_host: &str, - bus_id: &str, + agent_id: &str, transition: &PendingTransition, ) -> PublicationOutcome { let subject = crate::resource_profile_supervisor::resource_change_subject( @@ -1861,7 +1865,7 @@ fn emit_resync( match crate::event::emit_builtin_resync( root, this_host, - bus_id, + agent_id, &transition.event_id, Some(&transition.binding), Some(subject.as_str()), @@ -1873,8 +1877,11 @@ fn emit_resync( let path = transition.path.display(); match crate::event::refusal_kind(&error) { Some(crate::event::RefusalKind::RecipientNotRunning) => { + // The refusal itself names the recipient by bus address, which is the route a + // person would use to go look. Repeating the immutable ID here would put an + // ownership key in front of a human for no reason. eprintln!( - "st2: resync for '{path}' is parked until '{bus_id}' is running again: {error:#}" + "st2: resync for '{path}' is parked until its recipient is running again: {error:#}" ); PublicationOutcome::Parked } @@ -2301,7 +2308,7 @@ mod tests { .unwrap(); let spec = discover(tmp.path()); let set = watch_set_for(&spec, "hetz", &Default::default()); - assert_eq!(set.bus_id, "hetz.worker"); + assert_eq!(set.agent_id, "hetz.worker"); let mut labels: Vec<&str> = set.carriers.iter().map(|c| c.label.as_str()).collect(); labels.sort(); assert_eq!(labels, vec!["declaration", "goal"]); @@ -2320,7 +2327,7 @@ mod tests { } #[test] - fn bus_id_uses_the_supervisor_host_not_the_os_hostname() { + fn agent_id_uses_the_supervisor_host_not_the_os_hostname() { let tmp = tempfile::tempdir().unwrap(); // A root-level declaration file supplies neither content nor path host: host stays // None, so the supervisor's logical alias must decide the recipient. @@ -2333,11 +2340,11 @@ mod tests { .unwrap(); let spec = discover(tmp.path()); assert_eq!( - watch_set_for(&spec, "alias", &Default::default()).bus_id, + watch_set_for(&spec, "alias", &Default::default()).agent_id, "alias.worker" ); assert_eq!( - watch_set_for(&spec, "other", &Default::default()).bus_id, + watch_set_for(&spec, "other", &Default::default()).agent_id, "other.worker" ); } @@ -2411,7 +2418,7 @@ mod tests { shared.clone(), vec![ Entry { - bus_id: "host.alpha".to_owned(), + agent_id: "host.alpha".to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -2425,7 +2432,7 @@ mod tests { dirty: true, }, Entry { - bus_id: "host.beta".to_owned(), + agent_id: "host.beta".to_owned(), seat_id: None, label: "spec".to_owned(), class: CarrierClass::Coalesced, @@ -2443,7 +2450,7 @@ mod tests { let sets = vec![ AgentWatchSet { declaration_path: PathBuf::from("/catalog/alpha/agent.kdl"), - bus_id: "host.alpha".to_owned(), + agent_id: "host.alpha".to_owned(), seat_id: None, carriers: vec![WatchableCarrier { label: "goal".to_owned(), @@ -2455,7 +2462,7 @@ mod tests { }, AgentWatchSet { declaration_path: PathBuf::from("/catalog/beta/agent.kdl"), - bus_id: "host.beta".to_owned(), + agent_id: "host.beta".to_owned(), seat_id: None, carriers: vec![WatchableCarrier { label: "spec".to_owned(), @@ -2470,19 +2477,19 @@ mod tests { let rebuilt = rebuild_carriers(previous, refresh_for(sets), &BTreeMap::new(), &mut BTreeMap::new()); let entries = rebuilt.get(&shared).expect("shared path remains watched"); assert_eq!(entries.len(), 2); - for (bus_id, digest) in [ + for (agent_id, digest) in [ ("host.alpha", "alpha-before"), ("host.beta", "beta-before"), ] { let entry = entries .iter() - .find(|entry| entry.bus_id == bus_id) + .find(|entry| entry.agent_id == agent_id) .expect("subscriber remains present"); assert_eq!( entry.state, Some(CarrierState::Present(digest.to_owned())) ); - assert!(entry.dirty, "pending mutation remains pending for {bus_id}"); + assert!(entry.dirty, "pending mutation remains pending for {agent_id}"); } } @@ -2514,7 +2521,7 @@ mod tests { let previous = BTreeMap::from([( old_path.clone(), vec![Entry { - bus_id: "alias.worker".to_owned(), + agent_id: "alias.worker".to_owned(), seat_id: Some("stale-seat".to_owned()), label: "goal".to_owned(), class: CarrierClass::Coalesced, @@ -2535,7 +2542,7 @@ mod tests { .iter() .find(|entry| entry.label == "goal") .expect("the goal subscription remains pending at its current path"); - assert_eq!(entry.bus_id, "alias.worker"); + assert_eq!(entry.agent_id, "alias.worker"); assert_eq!(entry.seat_id.as_deref(), Some("current-seat")); assert_eq!(entry.class, CarrierClass::Immediate); assert_eq!( @@ -2588,7 +2595,7 @@ mod tests { carriers: BTreeMap::from([( old_path.clone(), vec![Entry { - bus_id: "alias.worker".to_owned(), + agent_id: "alias.worker".to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -2669,7 +2676,7 @@ mod tests { carriers: BTreeMap::from([( old_path.clone(), vec![Entry { - bus_id: "host.worker".to_owned(), + agent_id: "host.worker".to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -2693,7 +2700,7 @@ mod tests { worker.apply_watch_sets(refresh_for(vec![AgentWatchSet { declaration_path: declaration, - bus_id: "host.worker".to_owned(), + agent_id: "host.worker".to_owned(), seat_id: None, carriers: vec![WatchableCarrier { label: "goal".to_owned(), @@ -2725,7 +2732,7 @@ mod tests { carriers: BTreeMap::from([( carrier.clone(), vec![Entry { - bus_id: "host.worker".to_owned(), + agent_id: "host.worker".to_owned(), seat_id: None, label: "spec".to_owned(), class: CarrierClass::Immediate, @@ -2749,7 +2756,7 @@ mod tests { let refresh = |class| { refresh_for(vec![AgentWatchSet { declaration_path: declaration.clone(), - bus_id: "host.worker".to_owned(), + agent_id: "host.worker".to_owned(), seat_id: None, carriers: vec![WatchableCarrier { label: "spec".to_owned(), @@ -2785,7 +2792,7 @@ mod tests { BTreeMap::from([( declaration.clone(), vec![Entry { - bus_id: "hetz.worker".to_owned(), + agent_id: "hetz.worker".to_owned(), seat_id: Some("custom-worker-seat".to_owned()), label: "declaration".to_owned(), class: CarrierClass::Immediate, @@ -2874,7 +2881,7 @@ mod tests { carriers: BTreeMap::from([( carrier.clone(), vec![Entry { - bus_id: "hetz.worker".to_owned(), + agent_id: "hetz.worker".to_owned(), seat_id: set.seat_id.clone(), label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -2942,7 +2949,7 @@ mod tests { carriers: BTreeMap::from([( carrier.clone(), vec![Entry { - bus_id: "host.missing".to_owned(), + agent_id: "host.missing".to_owned(), seat_id: None, label: "spec".to_owned(), class: CarrierClass::Coalesced, @@ -2967,7 +2974,7 @@ mod tests { let now = Instant::now(); worker.apply_watch_sets(refresh_for(vec![AgentWatchSet { declaration_path: PathBuf::from("/catalog/missing/agent.kdl"), - bus_id: "host.missing".to_owned(), + agent_id: "host.missing".to_owned(), seat_id: None, carriers: vec![WatchableCarrier { label: "spec".to_owned(), @@ -3160,7 +3167,7 @@ mod tests { let entries = [CarrierClass::Immediate, CarrierClass::Coalesced] .into_iter() .map(|class| Entry { - bus_id: "host.worker".to_owned(), + agent_id: "host.worker".to_owned(), seat_id: None, label: format!("{class:?}"), class, @@ -3587,7 +3594,7 @@ mod tests { assert!(event_field(&first_a_to_b, "occurrence").ends_with(":1")); assert!(event_field(&second_a_to_b, "occurrence").ends_with(":3")); - relocated.bus_id = "host.replacement".to_owned(); + relocated.agent_id = "host.replacement".to_owned(); worker.apply_watch_sets(refresh_for(vec![relocated])); assert_eq!( worker.carriers[&relocated_carrier] @@ -3608,8 +3615,8 @@ mod tests { crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); let entries = ["host.alpha", "host.beta"] .into_iter() - .map(|bus_id| Entry { - bus_id: bus_id.to_owned(), + .map(|agent_id| Entry { + agent_id: agent_id.to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -3682,7 +3689,7 @@ mod tests { carriers: BTreeMap::from([( carrier.clone(), vec![Entry { - bus_id: "host.missing".to_owned(), + agent_id: "host.missing".to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -3741,8 +3748,8 @@ mod tests { carrier.to_path_buf(), recipients .iter() - .map(|bus_id| Entry { - bus_id: (*bus_id).to_owned(), + .map(|agent_id| Entry { + agent_id: (*agent_id).to_owned(), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -3792,7 +3799,7 @@ mod tests { carriers: BTreeMap::from([( goal, vec![Entry { - bus_id: format!("host.{identity}"), + agent_id: format!("host.{identity}"), seat_id: None, label: "goal".to_owned(), class: CarrierClass::Immediate, @@ -4047,7 +4054,7 @@ mod tests { let undeclared = crate::event::emit( root.path(), "host", - "host.solo", + &crate::AgentSelector::address("host.solo"), "other", "eventid", None, diff --git a/src/run.rs b/src/run.rs index 81b67353..60cde508 100644 --- a/src/run.rs +++ b/src/run.rs @@ -594,12 +594,13 @@ impl PtyCli { Some(Some(None)) => { cmd.arg("--no-display-name"); } - // Secondary tasks retain the established task-specific presentation convention. - _ if target.pty_id == target.bus_id => { + // Secondary tasks retain the established task-specific presentation convention: the + // owner's human route, never its opaque ID. + _ if target.pty_id == target.agent_id => { cmd.arg("--no-display-name"); } _ => { - cmd.args(["--name", &target.bus_id]); + cmd.args(["--name", &target.bus_address]); } } cmd.arg("--cwd").arg(&cwd); @@ -1280,22 +1281,13 @@ pub fn exec_state_dir(host: &str) -> PathBuf { pub struct CrashLoop { /// The parked task's pty id. pub pty_id: String, - /// The owning agent's identity and (declared) host — resolved to a bus id when surfacing. - pub identity: String, - pub host: Option, - /// The agent's `supervisor` (from its spec), the crash-ding recipient. `None` → nobody to notify. - pub supervisor: Option, -} - -impl CrashLoop { - /// The parked agent's bus id (`.`), using `this_host` when the spec omits a host. - pub fn agent_bus_id(&self, this_host: &str) -> String { - format!( - "{}.{}", - self.host.as_deref().unwrap_or(this_host), - self.identity - ) - } + /// The immutable agent ID that owns the parked task. Park accounting is ownership, so this is + /// stable across an address change and needs no host qualification. + pub agent_id: String, + /// Where the crash-ding goes, already resolved from the declaration's `supervisor` reference + /// against the same catalog snapshot the plan came from. Storing the raw reference here is the + /// bug this type exists to prevent: the notifier resolves by exact ID and would drop the alert. + pub supervisor: crate::reconcile::SupervisorTarget, } /// Owned, human-readable summary of one reconcile+execute pass (no borrows of the plan/specs). @@ -1603,9 +1595,8 @@ fn execute_with_presentation_cursor( report.flapping.push(target.pty_id.clone()); report.crash_loops.push(CrashLoop { pty_id: target.pty_id.clone(), - identity: launch.spec.identity.clone(), - host: launch.spec.host.clone(), - supervisor: launch.spec.supervisor.clone(), + agent_id: target.agent_id.clone(), + supervisor: launch.supervisor.clone(), }); } if target.name == "agent" && !target.derived { @@ -1846,10 +1837,11 @@ 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::resolve_task_id( + &spec.agent_id(this_host), + &task.name, + task.id.as_deref(), + ); live_task_ids.contains(task_id.as_str()) }) }) @@ -2025,7 +2017,7 @@ fn reconcile_pass( } let eligible_specs = compiled_specs .iter() - .filter(|spec| !materialized.failed_agents.contains(&spec.bus_id(this_host))) + .filter(|spec| !materialized.failed_agents.contains(&spec.agent_id(this_host))) .cloned() .collect::>(); @@ -2947,7 +2939,7 @@ pub fn down_specs( } /// Shared teardown core: kill every live task session declared on this host. Task session ids are -/// derived identically to how reconcile spawns them (explicit `task.id`, else `.`), so +/// derived identically to how reconcile spawns them (explicit `task.id`, else `.`), so /// the catalog `down` and the spec `down_specs` tear down exactly what `up`/`up_*_specs` launched. fn teardown_specs( specs: &[agent_spec::spec::AgentSpec], @@ -2966,12 +2958,9 @@ fn teardown_specs( report.other_host.push(spec.identity.clone()); continue; } - let bus_id = spec.bus_id(this_host); + let agent_id = spec.agent_id(this_host); for task in &spec.tasks { - let id = task - .id - .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let id = crate::reconcile::resolve_task_id(&agent_id, &task.name, task.id.as_deref()); if live.contains(&id) { match runner.kill(&id) { Ok(()) => report.torn_down.push(id), @@ -3037,8 +3026,17 @@ fn wait_for_reconcile(rx: &Receiver<()>, interval: Duration, stop: &AtomicBool) } } -/// Install independent commit and declaration wake channels. Each channel diagnoses and retries -/// its own setup failure while the timer remains the correctness fallback for both. +/// Best-effort supervisor watch over BOTH reconciliation inputs: the catalog generation a +/// completed `st2` publication bumps, and the declaration space a human edits directly. Each +/// channel installs on its own backend instance, so exhausting inotify limits on a large +/// declaration space still leaves cooperative catalog commits waking the loop immediately, and a +/// catalog with no control directory yet still gets declaration wakeups. +/// +/// Installation never fails the supervisor and never blocks on a catalog writer: the watcher +/// diagnoses each channel's failure once and reconciliation continues on the timer alone rather +/// than silently losing immediate wakeups forever. `refresh` re-attempts a missing channel after +/// every pass, so a control directory created (or replaced by a transaction) later becomes +/// watched without a supervisor restart. fn best_effort_catalog_watcher( root: &Path, tx: Sender<()>, @@ -3064,8 +3062,9 @@ impl RecurringWarnings { } } -/// The supervisor loop: reconcile on a timer AND on folder changes until interrupted. The fs-watch is -/// best-effort; the `interval` timer is the always-on fallback. `on_report` is called once per pass +/// The supervisor loop: reconcile on a timer AND on catalog changes — a committed catalog +/// generation or a direct declaration edit — until interrupted. The fs-watch is best-effort; the +/// `interval` timer is the always-on fallback. `on_report` is called once per pass pub fn up_loop( root: &Path, this_host: &str, @@ -3222,19 +3221,42 @@ fn up_loop_until( /// be watching (the exact miss that let a 45-min outage run). Best-effort — a missing supervisor, /// an unresolvable supervisor, or a send failure is logged, never fatal. Dedup (once per park) is the /// caller's job. +/// +/// The recipient arrives already resolved to an immutable agent ID (see +/// [`crate::reconcile::SupervisorTarget`]). An authored `supervisor` is a human reference, so +/// resolving it here — against a catalog generation newer than the one that decided the park — +/// would be a second, disagreeing resolution; and passing the raw reference to the exact-ID +/// lookup below would silently drop the alert for every declaration that names its parent by +/// address rather than by ID. pub fn surface_crash_loop(catalog_root: &Path, this_host: &str, cl: &CrashLoop) { - let agent = cl.agent_bus_id(this_host); - let Some(supervisor) = cl.supervisor.as_deref() else { - tracing::warn!( - "st2: crash-loop '{}' ({agent}) has no supervisor to notify.", - cl.pty_id - ); - return; + let agent = cl.agent_id.as_str(); + let supervisor = match &cl.supervisor { + crate::reconcile::SupervisorTarget::Undeclared => { + tracing::warn!( + "st2: crash-loop '{}' ({agent}) has no supervisor to notify.", + cl.pty_id + ); + return; + } + // Loud, not silent: an unresolvable parent means a real park went unreported, and the + // operator has to repair the declaration before any future park is delivered either. + crate::reconcile::SupervisorTarget::Unresolved(reference) => { + tracing::error!( + "st2: crash-loop '{}' ({agent}) NOT reported: its declared supervisor '{reference}' \ + resolves to no single subject in this catalog. Repair the `supervisor` reference \ + (an exact agent ID, or an address unique on its host) — until then every park of \ + this agent goes unnotified.", + cl.pty_id + ); + return; + } + crate::reconcile::SupervisorTarget::Resolved(id) => id.as_str(), }; let Ok(Some(agent_dir)) = message::resolve_agent_dir(catalog_root, supervisor, this_host) else { - tracing::warn!( - "st2: crash-loop '{}': supervisor '{supervisor}' not found in the catalog to notify.", + tracing::error!( + "st2: crash-loop '{}' ({agent}) NOT reported: supervisor ID '{supervisor}' has no \ + agent directory in the catalog to notify.", cl.pty_id ); return; @@ -3397,7 +3419,8 @@ mod tests { TaskTarget { kind: TaskKind::Pty, pty_id: id.to_string(), - bus_id: "hetz.demo".to_string(), + agent_id: "hetz.demo".to_string(), + bus_address: "hetz.demo".to_string(), name: "agent".to_string(), derived: false, launch: TaskLaunch::Shell(cmd.to_string()), @@ -3535,6 +3558,8 @@ mod tests { #[test] fn selected_codex_gate_suppresses_launch_on_stale_hooks() { let spec = AgentSpec { + id: None, + address: None, identity: "codex".into(), name: None, description: None, @@ -3592,6 +3617,8 @@ mod tests { #[test] fn selected_identity_conflict_refuses_before_hook_verification_or_inventory() { let mut spec = AgentSpec { + id: None, + address: None, identity: "codex".into(), name: None, description: None, @@ -3726,6 +3753,63 @@ mod tests { ); } + /// The production installer must carry the catalog-GENERATION channel, not declarations alone. + /// A cooperative `st2` publication lands its declarations inside a staged tree and publishes + /// them by bumping `.st2/catalog-generation` — a control path the declaration walk deliberately + /// prunes. With only the declaration channel wired, a completed transaction waited for the + /// timer; the 60s interval here makes any second pass proof that the generation watch woke it. + #[cfg(target_os = "linux")] + #[test] + fn a_committed_catalog_generation_wakes_the_production_supervisor_watch() { + let catalog = tempfile::tempdir().unwrap(); + let control = catalog.path().join(crate::catalog_lock::CONTROL_DIR); + std::fs::create_dir(&control).unwrap(); + std::fs::write(control.join(crate::catalog_lock::GENERATION_FILE), "1\n").unwrap(); + let stop = AtomicBool::new(false); + let mut passes = 0usize; + let (started_tx, started_rx) = mpsc::sync_channel(1); + + std::thread::scope(|scope| { + let stop = &stop; + let control = control.clone(); + scope.spawn(move || { + started_rx.recv().unwrap(); + std::thread::sleep(Duration::from_millis(150)); + let staged = control.join("next-generation"); + std::fs::write(&staged, "2\n").unwrap(); + std::fs::rename(&staged, control.join(crate::catalog_lock::GENERATION_FILE)) + .unwrap(); + std::thread::sleep(Duration::from_millis(400)); + stop.store(true, Ordering::SeqCst); + }); + up_loop_until( + catalog.path(), + "test-host", + &SpawnCountingRunner::default(), + Duration::from_secs(60), + &stop, + best_effort_catalog_watcher, + |_| { + passes += 1; + let _ = started_tx.try_send(()); + }, + ) + .unwrap(); + }); + + assert!( + (2..=3).contains(&passes), + "a committed catalog generation must wake a further pass well inside the 60s timer, \ + without spinning: {passes} passes" + ); + } + + /// Prompt catalog convergence: publishing a declaration wakes the supervisor and launches it + /// on the very next pass, not on the 60s timer. + /// + /// The launched task ID is the agent ID. This declaration is compact and unmigrated, so its + /// agent ID is exactly the frozen `.` bytes and the canonical compact task ID + /// is that same value — which is why the expected ID is unchanged by DELTA-003. #[cfg(target_os = "linux")] #[test] fn supervisor_wakes_and_launches_a_new_direct_declaration() { @@ -3777,6 +3861,7 @@ mod tests { assert_eq!(runner.spawned.borrow().as_slice(), ["test-host.live"]); } + #[test] fn resident_loop_reloads_added_changed_removed_and_malformed_profiles() { let catalog = tempfile::tempdir().unwrap(); @@ -3990,6 +4075,7 @@ mod tests { spec, tasks: vec![target("hetz.demo.agent", "x")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }], ..ReconcilePlan::default() } @@ -4110,6 +4196,7 @@ mod tests { spec, tasks: vec![target("hetz.demo.agent", "x")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }], ..ReconcilePlan::default() } @@ -4149,6 +4236,8 @@ mod tests { fn spec_fixture() -> AgentSpec { AgentSpec { + id: None, + address: None, identity: "demo".into(), name: None, description: None, @@ -4178,6 +4267,7 @@ mod tests { spec: &legacy_spec, tasks: Vec::new(), live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }; let mut omp_argv = target("hetz.demo.agent", "unused"); omp_argv.launch = TaskLaunch::Argv(vec![ @@ -4216,6 +4306,7 @@ mod tests { spec: &typed_spec, tasks: Vec::new(), live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }; assert_eq!( driver_label(&typed_launch, &target("hetz.demo.agent", "claude")), @@ -4592,9 +4683,7 @@ mod tests { write_notify_chain_agent(catalog.path(), "worker", Some("hetz.lead"), false); let specs = crate::discover_strict(catalog.path()).specs; let task_id = |spec: &AgentSpec, task: &Task| { - task.id - .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id("hetz"), task.name)) + crate::reconcile::resolve_task_id(&spec.agent_id("hetz"), &task.name, task.id.as_deref()) }; let mut sessions = Vec::new(); for spec in &specs { @@ -4720,10 +4809,11 @@ mod tests { .filter(|spec| spec.desired_state.is_running()) .flat_map(|spec| { spec.tasks.iter().map(|task| { - let id = task - .id - .clone() - .unwrap_or_else(|| format!("{}.{}", spec.bus_id("hetz"), task.name)); + let id = crate::reconcile::resolve_task_id( + &spec.agent_id("hetz"), + &task.name, + task.id.as_deref(), + ); sess(&id, true) }) }) @@ -5064,6 +5154,7 @@ mod tests { spec: worker, tasks: vec![target("hetz.worker.agent", "agent"), later], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }], ..ReconcilePlan::default() }; @@ -5117,11 +5208,13 @@ mod tests { spec: first, tasks: vec![target("hetz.first.agent", "agent")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }, Launch { spec: second, tasks: vec![target("hetz.second.agent", "agent")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }, ], ..ReconcilePlan::default() @@ -5172,11 +5265,13 @@ mod tests { spec: first, tasks: vec![target("hetz.first.agent", "agent")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }, Launch { spec: second, tasks: vec![target("hetz.second.agent", "agent")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }, ], ..ReconcilePlan::default() @@ -5401,6 +5496,7 @@ mod tests { spec, tasks: vec![target("hetz.worker.agent", "agent"), derived], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }], ..ReconcilePlan::default() }; @@ -5557,6 +5653,7 @@ mod tests { spec: &spec, tasks: vec![target("hetz.demo.agent", "x")], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); let deferred = db.defer_flickers(&mut plan, t0 + Duration::from_secs(2)); assert!( @@ -5583,11 +5680,13 @@ mod tests { spec: &left, tasks: vec![left_agent], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); plan.launch.push(Launch { spec: &right, tasks: vec![right_agent], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); let expected = plan .launch @@ -5622,6 +5721,7 @@ mod tests { spec: &spec, tasks: vec![ding], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); let mut report = UpReport::default(); @@ -5651,11 +5751,13 @@ mod tests { spec: &codex, tasks: vec![codex_agent], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); plan.launch.push(Launch { spec: &claude, tasks: vec![claude_agent], live_derived: Vec::new(), + supervisor: crate::reconcile::SupervisorTarget::Undeclared, }); let mut report = UpReport::default(); @@ -5716,17 +5818,28 @@ mod tests { let cli = PtyCli::default(); let mut t = target("hetz.demo", "codex"); - t.bus_id = "hetz.demo".to_owned(); + t.agent_id = "hetz.demo".to_owned(); + t.bus_address = "hetz.demo".to_owned(); t.tags .insert("unrelated".to_owned(), "preserved".to_owned()); t.presentation = Some(PtyPresentation { pty_id: "hetz.demo".to_owned(), display_name: Some(Some("Build owner".to_owned())), tags: BTreeMap::from([ - ("agent.presentation.schema".to_owned(), Some("1".to_owned())), - ("agent.actor.path".to_owned(), Some("hetz.demo".to_owned())), ( - "agent.presentation.description".to_owned(), + crate::reconcile::AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), + Some(crate::reconcile::AGENT_PRESENTATION_SCHEMA.to_owned()), + ), + ( + crate::reconcile::AGENT_SUBJECT_ID_TAG.to_owned(), + Some("hetz.demo".to_owned()), + ), + ( + crate::reconcile::AGENT_SUBJECT_ADDRESS_TAG.to_owned(), + Some("hetz.demo".to_owned()), + ), + ( + crate::reconcile::AGENT_DESCRIPTION_TAG.to_owned(), Some(format!("${key}")), ), ]), @@ -5745,8 +5858,13 @@ mod tests { .map(|pair| pair[1].as_str()) .collect::>(); assert!(tags.contains("unrelated=preserved")); - assert!(tags.contains("agent.presentation.schema=1")); - assert!(tags.contains("agent.actor.path=hetz.demo")); + assert!(tags.contains("agent.presentation.schema=2")); + assert!(tags.contains("agent.subject.id=hetz.demo")); + assert!(tags.contains("agent.subject.address=hetz.demo")); + assert!( + !tags.iter().any(|tag| tag.starts_with("agent.actor.path")), + "schema 1's actor path is replaced, not carried alongside: {tags:?}" + ); assert!(tags.contains("agent.presentation.description=$ST2_TEST_PRESENTATION_LITERAL_71c")); } @@ -5770,8 +5888,11 @@ mod tests { pty_id: "stable.agent.id".to_owned(), display_name: Some(None), tags: BTreeMap::from([ - ("agent.presentation.schema".to_owned(), Some("1".to_owned())), - ("agent.presentation.description".to_owned(), None), + ( + crate::reconcile::AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), + Some(crate::reconcile::AGENT_PRESENTATION_SCHEMA.to_owned()), + ), + (crate::reconcile::AGENT_DESCRIPTION_TAG.to_owned(), None), ]), }; @@ -5785,7 +5906,7 @@ mod tests { serde_json::from_slice(&std::fs::read(executable.with_extension("stdin")).unwrap()) .unwrap(); assert_eq!(payload["displayName"], serde_json::Value::Null); - assert_eq!(payload["tags"]["agent.presentation.schema"], "1"); + assert_eq!(payload["tags"]["agent.presentation.schema"], "2"); assert_eq!( payload["tags"]["agent.presentation.description"], serde_json::Value::Null @@ -6326,7 +6447,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_id = t.pty_id.clone(); t.presentation = Some(PtyPresentation { pty_id: t.pty_id.clone(), display_name: Some(Some(t.pty_id.clone())), @@ -6836,7 +6957,7 @@ esac std::fs::write( &fake, r#"#!/bin/sh -printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-07-31T10:00:00.000Z","displayName":"Build owner","tags":{"agent.presentation.schema":"1","unrelated":"preserved"}},{"name":"h.exit","status":"exited","exitCode":0,"pid":42,"createdAt":"2026-07-31T09:00:00.000Z"},{"name":"h.gone","status":"vanished","pid":43,"createdAt":"2026-07-31T08:00:00.000Z"}]' +printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-07-31T10:00:00.000Z","displayName":"Build owner","tags":{"agent.presentation.schema":"2","agent.subject.id":"h.live","agent.subject.address":"h.build","unrelated":"preserved"}},{"name":"h.exit","status":"exited","exitCode":0,"pid":42,"createdAt":"2026-07-31T09:00:00.000Z"},{"name":"h.gone","status":"vanished","pid":43,"createdAt":"2026-07-31T08:00:00.000Z"}]' "#, ) .unwrap(); @@ -6870,7 +6991,18 @@ printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-0 .tags .get("agent.presentation.schema") .map(String::as_str), - Some("1") + Some("2") + ); + assert_eq!( + presentation.tags.get("agent.subject.id").map(String::as_str), + Some("h.live") + ); + assert_eq!( + presentation + .tags + .get("agent.subject.address") + .map(String::as_str), + Some("h.build") ); assert_eq!( presentation.tags.get("unrelated").map(String::as_str), @@ -6915,4 +7047,162 @@ printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-0 ObservedState::Indeterminate(_) )); } + + /// The canonical compact agent task maps `name` to native `displayName`; the secondary PTY + /// keeps the established convention of showing its owner's human route. + #[test] + fn only_the_canonical_task_takes_a_native_display_name_from_the_agent_name() { + let cli = PtyCli::default(); + + let mut canonical = target("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "codex"); + canonical.agent_id = canonical.pty_id.clone(); + canonical.bus_address = "dev3.fractal.keymap.verifier".to_owned(); + canonical.presentation = Some(PtyPresentation { + pty_id: canonical.pty_id.clone(), + display_name: Some(Some("Keymap verifier".to_owned())), + tags: BTreeMap::new(), + }); + let args = cli + .build_run_command(&canonical, Path::new("/cat/dev3/worker")) + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let name = args.iter().position(|arg| arg == "--name").unwrap(); + assert_eq!(args[name + 1], "Keymap verifier"); + + let mut secondary = target("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1.ding", "true"); + secondary.agent_id = "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".to_owned(); + secondary.bus_address = "dev3.fractal.keymap.verifier".to_owned(); + secondary.name = "ding".to_owned(); + let args = cli + .build_run_command(&secondary, Path::new("/cat/dev3/worker")) + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let name = args.iter().position(|arg| arg == "--name").unwrap(); + assert_eq!( + args[name + 1], "dev3.fractal.keymap.verifier", + "a secondary PTY displays the owner's route, not its opaque ID" + ); + } + + /// The spawn-time tag projection is the same snapshot the later metadata patch applies: + /// unrelated task tags survive, and an owned key whose value is absent is not written. + #[test] + fn spawn_tags_preserve_unrelated_keys_and_omit_absent_owned_values() { + let cli = PtyCli::default(); + let mut t = target("dev3.worker", "codex"); + t.agent_id = "dev3.worker".to_owned(); + t.bus_address = "dev3.worker".to_owned(); + t.tags.insert("role".to_owned(), "stale".to_owned()); + t.tags + .insert("unrelated".to_owned(), "preserved".to_owned()); + t.presentation = Some(PtyPresentation { + pty_id: t.pty_id.clone(), + display_name: Some(None), + tags: BTreeMap::from([ + ( + crate::reconcile::AGENT_PRESENTATION_SCHEMA_TAG.to_owned(), + Some(crate::reconcile::AGENT_PRESENTATION_SCHEMA.to_owned()), + ), + ( + crate::reconcile::AGENT_SUBJECT_ID_TAG.to_owned(), + Some("dev3.worker".to_owned()), + ), + // A non-routable subject has released its address. + (crate::reconcile::AGENT_SUBJECT_ADDRESS_TAG.to_owned(), None), + (crate::reconcile::AGENT_DESCRIPTION_TAG.to_owned(), None), + (crate::reconcile::COMPATIBILITY_ROLE_TAG.to_owned(), None), + ]), + }); + + let args = cli + .build_run_command(&t, Path::new("/cat/dev3/worker")) + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let tags = args + .windows(2) + .filter(|pair| pair[0] == "--tag") + .map(|pair| pair[1].as_str()) + .collect::>(); + + assert!(tags.contains("unrelated=preserved")); + assert!(tags.contains("agent.presentation.schema=2")); + assert!(tags.contains("agent.subject.id=dev3.worker")); + assert!( + !tags + .iter() + .any(|tag| tag.starts_with("agent.subject.address")), + "an absent owned value is removed, never written empty: {tags:?}" + ); + assert!( + !tags.iter().any(|tag| tag.starts_with("agent.presentation.description")), + "an absent description is removed: {tags:?}" + ); + assert!( + !tags.iter().any(|tag| tag.starts_with("role=")), + "a non-canonical PTY has the compatibility role cleared: {tags:?}" + ); + } + + /// A projection-only pass is not lifecycle work: it patches metadata and records nothing in + /// launch, teardown, garbage collection, or flapping accounting. + #[test] + fn a_presentation_only_pass_stays_out_of_lifecycle_accounting() { + struct PatchOnly { + patched: RefCell>, + } + impl Runner for PatchOnly { + fn list_sessions(&self) -> anyhow::Result> { + unreachable!("presentation execution does not list sessions") + } + fn spawn(&self, _: &TaskTarget, _: &Path) -> anyhow::Result<()> { + unreachable!("a presentation-only plan must not spawn") + } + fn kill(&self, _: &str) -> anyhow::Result<()> { + unreachable!("a presentation-only plan must not kill") + } + fn remove(&self, _: &str) -> anyhow::Result<()> { + unreachable!("a presentation-only plan must not remove") + } + fn patch_presentation(&self, presentation: &PtyPresentation) -> anyhow::Result<()> { + self.patched.borrow_mut().push(presentation.pty_id.clone()); + Ok(()) + } + } + + let plan = ReconcilePlan { + presentation: vec![PtyPresentation { + pty_id: "dev3.worker".to_owned(), + display_name: None, + tags: BTreeMap::from([( + crate::reconcile::AGENT_SUBJECT_ADDRESS_TAG.to_owned(), + Some("dev3.renamed".to_owned()), + )]), + }], + ..Default::default() + }; + let runner = PatchOnly { + patched: RefCell::new(Vec::new()), + }; + let mut report = UpReport::default(); + execute_with_presentation_cursor( + &plan, + &runner, + &mut FlappingCap::default(), + &mut PresentationPatchCursor::default(), + &mut report, + &mut |_| {}, + ); + + assert_eq!(runner.patched.borrow().as_slice(), ["dev3.worker"]); + assert!(report.launched.is_empty()); + assert!(report.restarted.is_empty()); + assert!(report.torn_down.is_empty()); + assert!(report.gc.is_empty()); + assert!(report.flapping.is_empty()); + assert!(report.crash_loops.is_empty()); + assert!(report.errors.is_empty()); + } } diff --git a/src/supervisor_chain.rs b/src/supervisor_chain.rs index c237d7ea..f9c157dc 100644 --- a/src/supervisor_chain.rs +++ b/src/supervisor_chain.rs @@ -3,10 +3,17 @@ //! DING walks it to render a relationship marker; resync walks it to reach the carriers an agent's //! ancestors declare. Both need the same cycle guard and the same depth bound, so the traversal //! lives here once rather than being reimplemented per consumer. +//! +//! The walk is keyed by immutable agent ID throughout. A declared `supervisor` value is resolved +//! once, at the edge, and only its resolved ID travels: nothing downstream ever re-parses the +//! free-form reference, so an address cutover cannot re-key a chain or a cycle guard. use std::collections::HashSet; +use crate::AddressBook; +use crate::AgentSelector; use crate::AgentSpec; +use crate::spec::address_book; /// A declaration graph deeper than this is a declaration fault, not a chain worth walking. The /// bound is what keeps a malformed catalog from turning a walk into a hang; validation rejecting @@ -20,21 +27,108 @@ pub enum SupervisorChainError { DepthLimit, } -/// Resolve one declared supervisor reference to its spec. A reference matches either a fully -/// qualified bus id or a bare identity on the local host. -pub fn resolve_spec<'a>( +/// One declared `supervisor` value, in the single namespace it is written in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SupervisorEdge { + /// A migrated child names its parent by immutable agent ID, exactly as authored. + Id(String), + /// An unmigrated child names its parent by legacy POSITIONAL key `.` — the + /// declaration slot, which is neither a mutable address nor an explicit ID. + Positional(String), +} + +/// The namespace a declaration's `supervisor` value is written in, decided by the CHILD's +/// migration state. +/// +/// Catalog migration rewrites every `supervisor` reference to its parent's migrated ID in the same +/// atomic transition that adds the child's own `id`, so the child's declaration decides the +/// namespace without guessing: +/// +/// - the child carries an explicit `id` → the value is the parent's immutable agent ID; +/// - the child is unmigrated → the value is a legacy positional reference: a bare `` on +/// the child's own host, or a qualified `.` on any admitted host. +/// +/// This is the one place that decision is made, and the three namespaces stay disjoint. A legacy +/// edge resolves against declaration slots only: matching it against explicit IDs would let a +/// subject that merely claims those bytes as its ID capture the edge, and matching it against +/// mutable addresses would lose a retired (non-routable) or renamed parent. +pub fn supervisor_edge( + specs: &[AgentSpec], + spec: &AgentSpec, + local_host: &str, +) -> Option { + let reference = spec.supervisor.as_deref()?; + if spec.id.is_some() { + return Some(SupervisorEdge::Id(reference.to_owned())); + } + let child_host = spec.resolved_host(local_host); + // A dotted prefix is a host only when this snapshot actually admits that logical host; + // anything else is a bare identity on the child's own host, dots included. + let qualified = match reference.split_once('.') { + Some((prefix, _)) + if specs + .iter() + .any(|candidate| candidate.resolved_host(local_host) == prefix) => + { + reference.to_owned() + } + _ => format!("{child_host}.{reference}"), + }; + Some(SupervisorEdge::Positional(qualified)) +} + +/// Resolve one typed edge to exactly one declaration in this snapshot. +/// +/// More than one match is a broken catalog rather than a choice to make, so it refuses. +pub fn resolve_edge<'a>( specs: &'a [AgentSpec], - identity: &str, + edge: &SupervisorEdge, local_host: &str, ) -> Option<&'a AgentSpec> { - let mut matches = specs.iter().filter(|spec| { - spec.bus_id(local_host) == identity - || (spec.resolved_host(local_host) == local_host && spec.identity == identity) + let mut matches = specs.iter().filter(|spec| match edge { + SupervisorEdge::Id(id) => spec.agent_id(local_host) == *id, + SupervisorEdge::Positional(key) => spec.legacy_bus_identity(local_host) == *key, }); let first = matches.next()?; matches.next().is_none().then_some(first) } +/// Resolve one declaration's supervisor edge to the parent's spec, in the child's own namespace. +pub fn resolve_supervisor_spec<'a>( + specs: &'a [AgentSpec], + child: &AgentSpec, + local_host: &str, +) -> Option<&'a AgentSpec> { + resolve_edge(specs, &supervisor_edge(specs, child, local_host)?, local_host) +} + +/// Resolve one already-typed selector to exactly one spec in this snapshot. +pub fn resolve_selector<'a>( + specs: &'a [AgentSpec], + selector: &AgentSelector, + local_host: &str, +) -> Option<&'a AgentSpec> { + let book = address_book(specs, local_host).ok()?; + resolve_in(specs, &book, selector, local_host) +} + +/// Resolve against a caller-held book so one walk and its uniqueness proof describe one snapshot. +fn resolve_in<'a>( + specs: &'a [AgentSpec], + book: &AddressBook, + selector: &AgentSelector, + pinned_host: &str, +) -> Option<&'a AgentSpec> { + let id = book.resolve(selector).ok()?.id.as_str().to_owned(); + // The book answers with one subject even for a catalog that declares an ID twice; the walk + // stays fail-closed on that catalog by refusing an edge it cannot attribute to one spec. + let mut matches = specs + .iter() + .filter(|spec| spec.agent_id(pinned_host) == id); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + /// Whether a declaration occupies its host's root slot in the org chart: no supervisor, and not /// retired. Retirement — either spelling; the folded `AgentDesiredState` normalizes legacy /// `retired #true` and `desired-state "retired"` — removes a declaration from the org chart, so a @@ -53,31 +147,38 @@ pub fn chain<'a>( let mut chain = Vec::new(); let mut visited = HashSet::new(); let mut current = start; + // One admission proof for the whole walk: a catalog this snapshot cannot project into one + // address book cannot attribute an edge either, so the walk fails rather than silently + // skipping over an inadmissible declaration. + address_book(specs, this_host).map_err(|_| SupervisorChainError::MissingSupervisor)?; for _ in 0..SUPERVISOR_CHAIN_LIMIT { - if !visited.insert(current.bus_id(this_host)) { + // The guard keys on the immutable ID: two declarations reachable under different addresses + // are the same node in this graph exactly when they are the same subject. + if !visited.insert(current.agent_id(this_host)) { return Err(SupervisorChainError::Cycle); } chain.push(current); - let Some(supervisor) = current.supervisor.as_deref() else { + let Some(edge) = supervisor_edge(specs, current, this_host) else { return Ok(chain); }; - current = resolve_spec(specs, supervisor, current.resolved_host(this_host)) - .ok_or(SupervisorChainError::MissingSupervisor)?; + current = + resolve_edge(specs, &edge, this_host).ok_or(SupervisorChainError::MissingSupervisor)?; } Err(SupervisorChainError::DepthLimit) } -/// The bus ids of [`chain`], `start` first. What DING's relationship marker keys on. -pub fn chain_bus_ids( +/// The immutable agent IDs of [`chain`], `start` first. What DING's relationship marker keys on: +/// a marker must not change when either party's address does. +pub fn chain_agent_ids( specs: &[AgentSpec], start: &AgentSpec, this_host: &str, ) -> Result, SupervisorChainError> { Ok(chain(specs, start, this_host)? .into_iter() - .map(|spec| spec.bus_id(this_host)) + .map(|spec| spec.agent_id(this_host)) .collect()) } @@ -91,3 +192,148 @@ pub fn ancestors<'a>( chain.remove(0); Ok(chain) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + /// One declaration. `id`/`address` are written only when supplied, so a legacy subject is + /// expressed exactly as an unmigrated catalog spells it. + fn declare( + root: &Path, + host: &str, + identity: &str, + id: Option<&str>, + address: Option<&str>, + supervisor: Option<&str>, + ) { + let directory = root.join(host).join(identity); + std::fs::create_dir_all(&directory).unwrap(); + let id = id.map(|id| format!(" id {id:?}\n")).unwrap_or_default(); + let address = address + .map(|address| format!(" address {address:?}\n")) + .unwrap_or_default(); + let supervisor = supervisor + .map(|value| format!(" supervisor {value:?}\n")) + .unwrap_or_default(); + std::fs::write( + directory.join("agent.kdl"), + format!( + "agent {identity:?} {{\n identity {identity:?}\n{id}{address} host {host:?}\n{supervisor} type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" + ), + ) + .unwrap(); + } + + fn specs(root: &Path) -> Vec { + let discovered = crate::discover(root); + assert!(discovered.errors.is_empty(), "{:?}", discovered.errors); + discovered.specs + } + + fn parent_of(specs: &[AgentSpec], identity: &str, host: &str) -> String { + let child = specs + .iter() + .find(|spec| spec.identity == identity) + .expect("child declared"); + resolve_supervisor_spec(specs, child, host) + .map(|parent| parent.identity.clone()) + .unwrap_or_else(|| "".to_owned()) + } + + fn edge_for(specs: &[AgentSpec], identity: &str, host: &str) -> Option { + let child = specs + .iter() + .find(|spec| spec.identity == identity) + .expect("child declared"); + supervisor_edge(specs, child, host) + } + + /// A migrated child's `supervisor` is its parent's immutable ID, so a bystander whose mutable + /// ADDRESS is byte-equal to that ID must not capture the edge. + #[test] + fn a_migrated_child_resolves_its_supervisor_only_in_the_id_namespace() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + declare(root, "h", "parent", Some("boss-id"), Some("boss"), None); + // The bystander's ADDRESS is exactly the parent's ID. + declare(root, "h", "decoy", Some("decoy-id"), Some("boss-id"), None); + declare( + root, + "h", + "child", + Some("child-id"), + Some("child"), + Some("boss-id"), + ); + let specs = specs(root); + + assert_eq!( + parent_of(&specs, "child", "h"), + "parent", + "a migrated child's supervisor ID must not be answered by a byte-equal address" + ); + assert_eq!( + edge_for(&specs, "child", "h"), + Some(SupervisorEdge::Id("boss-id".to_owned())) + ); + } + + /// An unmigrated child's `supervisor` is a legacy POSITIONAL reference, so a migrated + /// bystander whose explicit ID is byte-equal to the bare reference must not capture the edge, + /// a bare reference stays on the child's own host, and a qualified one reaches another host. + #[test] + fn an_unmigrated_child_resolves_its_supervisor_only_as_a_positional_reference() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + declare(root, "h", "boss", None, None, None); + // The bystander's immutable ID is exactly the bare reference the child authored. + declare(root, "h", "decoy", Some("boss"), Some("decoy"), None); + // A same-identity subject on another host must not answer a bare local reference. + declare(root, "other", "boss", None, None, None); + declare(root, "h", "child", None, None, Some("boss")); + // A qualified legacy reference reaches the declaration it names on any admitted host. + declare(root, "h", "remote-child", None, None, Some("other.boss")); + let specs = specs(root); + + assert_eq!( + edge_for(&specs, "child", "h"), + Some(SupervisorEdge::Positional("h.boss".to_owned())), + "a bare legacy reference is qualified with the child's own host, never routed" + ); + let parent = resolve_supervisor_spec( + &specs, + specs + .iter() + .find(|spec| spec.identity == "child") + .expect("child declared"), + "h", + ) + .expect("local boss resolves"); + assert_eq!(parent.identity, "boss"); + assert_eq!( + parent.agent_id("h"), + "h.boss", + "the legacy parent keeps its frozen positional ID, not the bystander's" + ); + assert_eq!(parent_of(&specs, "remote-child", "h"), "boss"); + assert_eq!( + edge_for(&specs, "remote-child", "h"), + Some(SupervisorEdge::Positional("other.boss".to_owned())) + ); + assert_eq!( + chain_agent_ids( + &specs, + specs + .iter() + .find(|spec| spec.identity == "child") + .expect("child declared"), + "h" + ) + .unwrap(), + vec!["h.child".to_owned(), "h.boss".to_owned()], + "the walk itself must use the child's own namespace at every hop" + ); + } +} diff --git a/src/task_inventory.rs b/src/task_inventory.rs index f45bab3f..d7a48721 100644 --- a/src/task_inventory.rs +++ b/src/task_inventory.rs @@ -349,7 +349,12 @@ impl TaskInventory { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct TaskRow { + /// The catalog-global immutable agent ID that owns this task. agent: String, + /// The owning subject's current qualified human route, or `null` for a proved non-routable + /// retired subject. A released address is an answer, not missing coverage: the row is still + /// complete, still keyed by its ID, and still reachable by it. + bus_address: Option, task: String, runtime_id: String, kind: &'static str, @@ -397,6 +402,7 @@ struct RuntimeJson { #[derive(Debug)] struct DesiredTask { agent: String, + bus_address: Option, task: String, runtime_id: String, kind: TaskKind, @@ -446,7 +452,11 @@ pub fn inventory( if spec.resolved_host(host) != host { continue; } - let bus_id = spec.bus_id(host); + // Ownership, task ids, and the inventory key are the agent ID; the route is projected + // beside it and is absent exactly when the subject is non-routable. + let agent_id = spec.agent_id(host); + let bus_address = + (!spec.desired_state.is_retired()).then(|| spec.bus_address(host)); 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. @@ -456,13 +466,14 @@ pub fn inventory( let runtime_id = task .id .clone() - .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + .unwrap_or_else(|| format!("{agent_id}.{}", task.name)); runtime_owners .entry(runtime_id.clone()) .or_default() - .push(format!("{bus_id}/{}", task.name)); + .push(format!("{agent_id}/{}", task.name)); desired.push(DesiredTask { - agent: bus_id.clone(), + agent: agent_id.clone(), + bus_address: bus_address.clone(), task: task.name.clone(), runtime_id, kind: task.kind, @@ -645,6 +656,7 @@ pub fn inventory( }; TaskRow { agent: task.agent, + bus_address: task.bus_address, task: task.task, runtime_id: task.runtime_id, kind: match task.kind { @@ -835,6 +847,7 @@ mod tests { value["tasks"][0], serde_json::json!({ "agent": "h.worker", + "busAddress": "h.worker", "task": "agent", "runtimeId": "h.worker", "kind": "pty", @@ -869,6 +882,73 @@ mod tests { ); } + /// R23: a proved non-routable retired subject reports a null bus address and stays keyed by + /// its immutable ID — and that null is an *answer*, so the versioned envelope remains + /// complete. Reporting the released address, or degrading coverage to express its absence, + /// are the two plausible bugs this pins. + #[test] + fn a_retired_subject_reports_a_null_bus_address_without_making_coverage_incomplete() { + let tmp = tempfile::tempdir().unwrap(); + write_agent( + tmp.path(), + "h", + "gone", + r#"desired-state "retired" reason="Replaced by worker"; pty "agent" { argv "agent-bin" }"#, + ); + let value = json( + tmp.path(), + "h", + ObservationBatch { + complete: true, + observations: vec![], + errors: vec![], + }, + ); + assert_eq!(value["errors"], Value::Array(vec![])); + assert_eq!( + value["complete"], true, + "a released address is an answer, not missing evidence: {value:#}" + ); + let row = &value["tasks"][0]; + assert_eq!(row["agent"], "h.gone", "the ID survives retirement"); + assert_eq!(row["runtimeId"], "h.gone.agent"); + assert!( + row["busAddress"].is_null(), + "a retired subject releases its address: {row:#}" + ); + assert_eq!(row["retired"], true); + assert_eq!(row["runtime"]["state"], "absent"); + } + + /// An explicit `address` moves the route without moving anything the runtime owns: the row + /// key, the runtime id, and the durable task identity all stay on the agent ID. + #[test] + fn an_explicit_address_changes_only_the_projected_route() { + let tmp = tempfile::tempdir().unwrap(); + write_agent( + tmp.path(), + "h", + "worker", + r#"id "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"; address "fleet.builder"; pty "agent" { argv "agent-bin" }"#, + ); + let value = json( + tmp.path(), + "h", + ObservationBatch { + complete: true, + observations: vec![], + errors: vec![], + }, + ); + let row = &value["tasks"][0]; + assert_eq!(row["agent"], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"); + assert_eq!(row["busAddress"], "h.fleet.builder"); + assert_eq!( + row["runtimeId"], "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1.agent", + "task ids derive from the ID, never from the route: {row:#}" + ); + } + /// The whole operational complaint in #204: a parked task reported `desiredState: running`, /// nothing running, and `error: null`, so an operator saw a task that should be up, was not up, /// and had nothing visibly wrong with it. The only record of the fault was a supervisor journal diff --git a/src/validate.rs b/src/validate.rs index 2b8a43f1..a07f234b 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -234,11 +234,8 @@ pub(crate) fn validate_discovered( } } - // 4. Resolved pass: cross-spec + field checks over each agent. - let mut seen: HashMap = HashMap::new(); - // Placeholder host for bus-id collision: catalogs carry explicit host, and an empty host still - // makes two unset-host same-identity specs collide (which is the real bug). - let collision_host = ""; + // 4. Resolved pass: cross-spec + field checks over each agent. Catalog-wide uniqueness is + // proved once after this loop, from one address book over the complete prospective catalog. for s in &d.specs { let rp = rel(root, &s.path); @@ -259,21 +256,6 @@ pub(crate) fn validate_discovered( .map_err(|error| format!("{error:#}")) }); - // Duplicate bus id — the runner cannot run two agents under one .. - let bid = s.bus_id(collision_host); - if let Some(prev) = seen.insert(bid.clone(), s.path.clone()) { - issues.push(Issue::error( - "dup-id", - rp.clone(), - ag.clone(), - format!( - "duplicate agent id '{}' (also declared in {})", - bid, - rel(root, &prev) - ), - )); - } - if let Some(Err(error)) = &compiled { let code = if s.driver.is_some() && s.delivery.is_some() { "driver-deliver-conflict" @@ -397,12 +379,14 @@ pub(crate) fn validate_discovered( && runs_on_selected_host { let pty_root = crate::run::effective_pty_root(root); - let bus_id = compiled.bus_id(host); + // Task ids are ownership, so they derive from the agent ID, never from the route. + let agent_id = compiled.agent_id(host); for task in &compiled.tasks { if task.kind != agent_spec::spec::TaskKind::Pty { continue; } - let id = crate::reconcile::resolve_task_id(&bus_id, &task.name, task.id.as_deref()); + let id = + crate::reconcile::resolve_task_id(&agent_id, &task.name, task.id.as_deref()); if let Some((socket, over)) = crate::run::session_socket_overage(&pty_root, &id) { issues.push(Issue::error( "socket-path-too-long", @@ -444,21 +428,21 @@ pub(crate) fn validate_discovered( "supervisor-missing", format!( "supervisor chain from '{}' references a missing or ambiguous parent", - s.bus_id(this_host.unwrap_or_default()) + s.agent_id(this_host.unwrap_or_default()) ), ), crate::supervisor_chain::SupervisorChainError::Cycle => ( "supervisor-cycle", format!( "supervisor chain from '{}' contains a cycle", - s.bus_id(this_host.unwrap_or_default()) + s.agent_id(this_host.unwrap_or_default()) ), ), crate::supervisor_chain::SupervisorChainError::DepthLimit => ( "supervisor-depth", format!( "supervisor chain from '{}' exceeds the maximum depth of {}", - s.bus_id(this_host.unwrap_or_default()), + s.agent_id(this_host.unwrap_or_default()), crate::supervisor_chain::SUPERVISOR_CHAIN_LIMIT ), ), @@ -479,8 +463,8 @@ pub(crate) fn validate_discovered( ag.clone(), format!( "supervisor chain from '{}' terminates at retired root '{}'; active agents must descend from a counted root", - s.bus_id(this_host.unwrap_or_default()), - chain.last().expect("chain contains at least its start").bus_id(this_host.unwrap_or_default()), + s.agent_id(this_host.unwrap_or_default()), + chain.last().expect("chain contains at least its start").agent_id(this_host.unwrap_or_default()), ), )); } @@ -525,6 +509,110 @@ pub(crate) fn validate_discovered( } } + // Catalog-wide uniqueness, proved once from one address book over the complete prospective + // catalog rather than from a second hand-rolled scan. Two namespaces, two admission rules: + // agent IDs are catalog-global, effective addresses are unique per logical host among + // routable subjects only, and equal bytes across the two namespaces are not a collision. The + // empty default host keeps two host-less same-identity declarations colliding, which is the + // real bug this catches. + // + // "Catalog-global" includes the structural archive (R24): an archived subject keeps its ID, so + // a live declaration reusing those bytes is a duplicate even though nothing in live discovery + // can see it. Archival released its address, so it enters the book non-routable and occupies no + // host's address namespace. The archive read is fail-closed by construction — an entry it + // cannot explain is an error, never an empty archive — and this proof refuses with it rather + // than passing a catalog whose ID namespace it could not read. + let prospective = crate::spec::address_book(&d.specs, "").and_then(|live| { + let mut subjects = live.subjects().to_vec(); + subjects.extend(crate::catalog_archive::archived_subjects(root)?); + Ok(crate::AddressBook::new(subjects)) + }); + match prospective { + Ok(book) => { + let mut by_id: HashMap> = HashMap::new(); + for (index, spec) in d.specs.iter().enumerate() { + by_id.entry(spec.agent_id("")).or_default().push(index); + } + let declarants = |ids: &[String]| { + let mut indexes = ids + .iter() + .flat_map(|id| by_id.get(id).map(Vec::as_slice).unwrap_or_default()) + .copied() + .collect::>(); + indexes.sort_unstable(); + indexes + }; + for conflict in book.conflicts() { + let (code, ids, holders, message): (_, Vec, _, _) = match &conflict { + agent_spec::UniquenessConflict::DuplicateId { id, count } => ( + "dup-id", + vec![id.clone()], + *count, + format!("duplicate agent id '{id}'"), + ), + agent_spec::UniquenessConflict::DuplicateAddress { host, address, ids } => ( + "dup-address", + ids.clone(), + ids.len(), + format!( + "effective address '{address}' on host '{}' is claimed by {}", + if host.is_empty() { "" } else { host }, + ids.join(", ") + ), + ), + }; + let indexes = declarants(&ids); + // Attributed to the last declaration involved, with the earlier ones named — the + // author fixes the declaration that arrived into an occupied namespace. + let Some((&last, earlier)) = indexes.split_last() else { + // No live declarant at all: every holder is in the structural archive. The + // catalog's global ID namespace is still provably broken — `catalog apply` + // and `publish` refuse it — so it is reported against the catalog root + // rather than silently dropped for want of a declaration path to blame. + issues.push(Issue::error( + code, + ".".to_string(), + None, + format!( + "{message} (held only by {holders} subject(s) in the structural \ + archive; no live declaration claims it)" + ), + )); + continue; + }; + let spec = &d.specs[last]; + let others = earlier + .iter() + .map(|&index| rel(root, &d.specs[index].path)) + .collect::>(); + // An archived holder has no live declaration path, so it is named as what it is + // rather than being silently dropped from the explanation. + let mut message = message; + if !others.is_empty() { + message = format!("{message} (also declared in {})", others.join(", ")); + } + if holders > indexes.len() { + message = format!( + "{message} (also held by {} subject(s) in the structural archive)", + holders - indexes.len() + ); + } + issues.push(Issue::error( + code, + rel(root, &spec.path), + Some(spec.identity.clone()), + message, + )); + } + } + Err(error) => issues.push(Issue::error( + "uniqueness-unprovable", + ".".to_string(), + None, + format!("catalog-global identity uniqueness cannot be proved: {error:#}"), + )), + } + 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/templates/bus.st2.md b/templates/bus.st2.md index 8b287eb2..a2968a51 100644 --- a/templates/bus.st2.md +++ b/templates/bus.st2.md @@ -7,7 +7,7 @@ see below). ## Boot ritual (on cold start or /clear) -1. `st2 status $ST_AGENT --set available` — set your status so peers see you as active. +1. `st2 status --id "$ST_AGENT" --set available` — set your status so peers see you as active. 2. Drain your inbox backlog: `st2 message ls` to enumerate filenames, then for each: `st2 message read `, `st2 message reply -m ""` if a response is warranted, and `st2 message archive ` to clear. Don't leave inbox items unaddressed. @@ -27,9 +27,10 @@ pixels and does not suppress notifications merely because you are `busy`: - Set `dnd` only as an explicit operator/agent hold. Fresh `dnd` is the only status that defers DING. The sidecar does not refresh `dnd`, so an abandoned hold ages to `unknown` after 15 minutes. -Use `st2 status "$ST_AGENT" --set busy` before work and -`st2 status "$ST_AGENT" --set available` when yielding. The live DING sidecar refreshes non-DND -presence without changing its value. +Use `st2 status --id "$ST_AGENT" --set busy` before work and +`st2 status --id "$ST_AGENT" --set available` when yielding. `$ST_AGENT` is your immutable agent +ID, so pass it through `--id`: the positional slot is an ordinary bus address and your address can +change under you. The live DING sidecar refreshes non-DND presence without changing its value. ## Resume safety — do NOT double-act (important for hosted/respawned agents) @@ -89,7 +90,7 @@ Bus ops: Peer discovery + state: - `st2 agents [--status STATE] [--json [--enrich]]` -- `st2 status [] [--set ]` +- `st2 status [
] [--id ] [--set ]` *(defaults to you; `--id` is the exact-ID form `$ST_AGENT` uses)* Working state (lossless-restart): - `st2 context read [] [--decisions | --full]` @@ -111,5 +112,6 @@ Agent declarations: `st2 agent publish`; `st2 validate`; `st2 up --materialize-o Catalog selection on every catalog-aware command: `--catalog ` → `$CATALOG` → `${XDG_STATE_HOME:-$HOME/.local/state}/st2/default/catalog`. Bus ops retain `--root` as an explicit -flat-bus/catalog override. Other shared bus flags: `--as ` (default `$ST_AGENT`), `--host`. +flat-bus/catalog override. Other shared bus flags: `--as
` (the acting identity; defaults +to the exact immutable ID in `$ST_AGENT`), `--host`. Every command supports `--help`. diff --git a/tests/agent_address.rs b/tests/agent_address.rs new file mode 100644 index 00000000..5b795935 --- /dev/null +++ b/tests/agent_address.rs @@ -0,0 +1,493 @@ +//! Address authoring: the mutable host-local address, cut over atomically, never the immutable ID. +//! +//! Every case here runs against a real temporary catalog and proves its claim by rediscovering that +//! catalog and resolving through the same address book ordinary references use. Nothing is modelled. + +use std::fs; +use std::path::Path; + +use st2::agent_author::{ + AuthorOutcome, DesiredStateValue, refuse_agent_id_change, set_address, set_desired_state, +}; +use st2::{AddressBook, AgentAddress, AgentSelector, ResolveError, spec::address_book}; + +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 KDL declaration with an unrelated comment and an unrelated `meta` value, so every +/// byte-preservation claim has something to preserve. +fn declaration(identity: &str, host: &str, extra: &str) -> String { + format!( + "// unrelated comment\nagent {identity:?} {{\n host {host:?}\n meta {{ managed-by \"catalog\"; owner \"platform\" }}\n{extra} command \"sleep 300\"\n}}\n" + ) +} + +fn book(root: &Path, this_host: &str) -> AddressBook { + let found = st2::discover(root); + assert!(found.errors.is_empty(), "{:?}", found.errors); + address_book(&found.specs, this_host).unwrap() +} + +fn resolved_id(root: &Path, this_host: &str, reference: &str) -> Result { + book(root, this_host) + .resolve_address(reference, None) + .map(|subject| subject.id.as_str().to_owned()) +} + +fn address(value: &str) -> AgentAddress { + AgentAddress::parse(value).unwrap() +} + +#[test] +fn first_explicit_address_cuts_over_immediately_and_releases_the_identity_fallback() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let source = declaration("worker", "h", ""); + write(root, "h/worker/agent.kdl", &source); + + assert_eq!( + resolved_id(root, "h", "worker").unwrap(), + "h.worker", + "the positional identity is the effective address before any explicit one" + ); + + let receipt = set_address(root, "h.worker", "h", None, Some(&address("build.owner"))).unwrap(); + assert_eq!(receipt.result, AuthorOutcome::Changed); + assert_eq!(receipt.id, "h.worker"); + assert_eq!(receipt.address, "build.owner"); + assert_eq!(receipt.bus_address.as_deref(), Some("h.build.owner")); + assert!(receipt.explicit); + + assert_eq!(resolved_id(root, "h", "build.owner").unwrap(), "h.worker"); + assert_eq!(resolved_id(root, "h", "h.build.owner").unwrap(), "h.worker"); + assert!( + matches!( + resolved_id(root, "h", "worker"), + Err(ResolveError::UnknownAddress { .. }) + ), + "the prior effective address stops resolving the moment the new generation is visible" + ); + + let subject = book(root, "h").resolve_id("h.worker").unwrap().clone(); + assert_eq!( + subject.id.as_str(), + "h.worker", + "an address cutover never moves the immutable id" + ); + assert_eq!( + fs::read_to_string(root.join("h/worker/agent.kdl")) + .unwrap() + .replace(" address \"build.owner\"\n", ""), + source, + "only the address span changed" + ); +} + +#[test] +fn a_later_address_change_releases_the_previous_one() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/worker/agent.kdl", &declaration("worker", "h", "")); + + set_address(root, "h.worker", "h", None, Some(&address("first"))).unwrap(); + let receipt = set_address(root, "h.worker", "h", None, Some(&address("second"))).unwrap(); + assert_eq!(receipt.result, AuthorOutcome::Changed); + assert_eq!(receipt.address, "second"); + + assert_eq!(resolved_id(root, "h", "second").unwrap(), "h.worker"); + assert!(matches!( + resolved_id(root, "h", "first"), + Err(ResolveError::UnknownAddress { .. }) + )); + assert_eq!( + set_address(root, "h.worker", "h", None, Some(&address("second"))) + .unwrap() + .result, + AuthorOutcome::Unchanged, + ); +} + +#[test] +fn clear_restores_the_identity_fallback() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let source = declaration("worker", "h", ""); + write(root, "h/worker/agent.kdl", &source); + + set_address(root, "h.worker", "h", None, Some(&address("build.owner"))).unwrap(); + let receipt = set_address(root, "h.worker", "h", None, None).unwrap(); + + assert_eq!(receipt.result, AuthorOutcome::Changed); + assert_eq!(receipt.address, "worker"); + assert_eq!(receipt.bus_address.as_deref(), Some("h.worker")); + assert!(!receipt.explicit); + assert_eq!(resolved_id(root, "h", "worker").unwrap(), "h.worker"); + assert!(matches!( + resolved_id(root, "h", "build.owner"), + Err(ResolveError::UnknownAddress { .. }) + )); + assert_eq!( + fs::read_to_string(root.join("h/worker/agent.kdl")).unwrap(), + source, + "clearing restores the original bytes exactly" + ); +} + +#[test] +fn clear_is_refused_when_the_restored_fallback_is_already_claimed() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/worker/agent.kdl", &declaration("worker", "h", "")); + write(root, "h/other/agent.kdl", &declaration("other", "h", "")); + + set_address(root, "h.worker", "h", None, Some(&address("build.owner"))).unwrap(); + // `other` claims the address `worker` just released, so the fallback is no longer free. + set_address(root, "h.other", "h", None, Some(&address("worker"))).unwrap(); + let before = fs::read_to_string(root.join("h/worker/agent.kdl")).unwrap(); + + let error = set_address(root, "h.worker", "h", None, None).unwrap_err(); + assert_eq!(error.code(), "address-fallback-conflict"); + assert_eq!( + fs::read_to_string(root.join("h/worker/agent.kdl")).unwrap(), + before, + "a refused clear writes nothing" + ); + assert_eq!(resolved_id(root, "h", "worker").unwrap(), "h.other"); +} + +#[test] +fn a_host_local_collision_is_refused_against_an_explicit_address_and_an_identity_fallback() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/worker/agent.kdl", &declaration("worker", "h", "")); + write(root, "h/helper/agent.kdl", &declaration("helper", "h", "")); + write(root, "h/router/agent.kdl", &declaration("router", "h", "")); + set_address(root, "h.router", "h", None, Some(&address("edge"))).unwrap(); + + let fallback = set_address(root, "h.worker", "h", None, Some(&address("helper"))).unwrap_err(); + assert_eq!(fallback.code(), "address-conflict"); + assert!( + fallback.to_string().contains("h.helper"), + "the refusal names the subject already holding the address: {fallback}" + ); + + let explicit = set_address(root, "h.worker", "h", None, Some(&address("edge"))).unwrap_err(); + assert_eq!(explicit.code(), "address-conflict"); + + assert_eq!(resolved_id(root, "h", "helper").unwrap(), "h.helper"); + assert_eq!(resolved_id(root, "h", "edge").unwrap(), "h.router"); + assert_eq!(resolved_id(root, "h", "worker").unwrap(), "h.worker"); +} + +#[test] +fn the_same_address_is_admitted_on_two_different_hosts() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h1/worker/agent.kdl", &declaration("worker", "h1", "")); + write(root, "h2/worker/agent.kdl", &declaration("worker", "h2", "")); + + set_address(root, "h1.worker", "h1", None, Some(&address("edge"))).unwrap(); + set_address(root, "h2.worker", "h1", None, Some(&address("edge"))).unwrap(); + + let book = book(root, "h1"); + assert_eq!( + book.resolve_address("edge", Some("h1")).unwrap().id.as_str(), + "h1.worker" + ); + assert_eq!( + book.resolve_address("h2.edge", None).unwrap().id.as_str(), + "h2.worker" + ); + assert!( + matches!( + book.resolve_address("edge", None), + Err(ResolveError::AmbiguousAddress { .. }) + ), + "uniqueness is per logical host; an unpinned bare reference across hosts stays ambiguous" + ); +} + +#[test] +fn an_id_and_an_address_with_equal_bytes_are_separate_namespaces() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/alpha/agent.kdl", + &declaration("alpha", "h", " id \"ops.router\"\n"), + ); + write(root, "h/beta/agent.kdl", &declaration("beta", "h", "")); + + let receipt = set_address(root, "h.beta", "h", None, Some(&address("ops.router"))).unwrap(); + assert_eq!(receipt.result, AuthorOutcome::Changed); + + let book = book(root, "h"); + assert_eq!( + book.resolve_id("ops.router").unwrap().id.as_str(), + "ops.router", + "the id namespace still answers with alpha" + ); + assert_eq!( + book.resolve_id("ops.router").unwrap().effective_address, + "alpha" + ); + assert_eq!( + book.resolve_address("ops.router", None).unwrap().id.as_str(), + "h.beta", + "the address namespace answers with beta" + ); + assert_eq!( + book.resolve_id("h.beta").unwrap().effective_address, + "ops.router", + "beta stays reachable by its own id and reports its new address" + ); +} + +#[test] +fn an_exact_id_lookup_never_falls_through_to_address_lookup() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/worker/agent.kdl", &declaration("worker", "h", "")); + + let error = set_address(root, "worker", "h", None, Some(&address("edge"))).unwrap_err(); + assert_eq!( + error.code(), + "target-not-found", + "`worker` is a live address, never an id: {error}" + ); + assert_eq!(resolved_id(root, "h", "worker").unwrap(), "h.worker"); +} + +#[test] +fn unsupported_formats_and_nix_owned_declarations_fail_closed() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/toml/agent.toml", + "identity = \"toml\"\nhost = \"h\"\ncommand = \"sleep 300\"\n", + ); + write( + root, + "h/json/agent.json", + "{\"identity\":\"json\",\"host\":\"h\",\"command\":\"sleep 300\"}\n", + ); + write( + root, + "h/nix/agent.kdl", + "agent \"nix\" {\n host \"h\"\n meta { managed-by \"nix\" }\n command \"sleep 300\"\n}\n", + ); + + for id in ["h.toml", "h.json"] { + let error = set_address(root, id, "h", None, Some(&address("edge"))).unwrap_err(); + assert_eq!(error.code(), "unsupported-declaration-format", "{id}"); + } + let error = set_address(root, "h.nix", "h", None, Some(&address("edge"))).unwrap_err(); + assert_eq!(error.code(), "nix-managed-declaration"); +} + +#[test] +fn direct_id_authoring_is_refused_and_address_authoring_never_moves_an_id() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/worker/agent.kdl", + &declaration("worker", "h", " id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\"\n"), + ); + + let refusal = refuse_agent_id_change("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "h.worker") + .unwrap_err(); + assert_eq!(refusal.code(), "immutable-agent-id"); + assert!( + refusal.to_string().contains("retire"), + "the refusal names the supported replacement path: {refusal}" + ); + + set_address( + root, + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", + "h", + None, + Some(&address("build.owner")), + ) + .unwrap(); + let source = fs::read_to_string(root.join("h/worker/agent.kdl")).unwrap(); + assert!(source.contains("id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\"")); + assert_eq!( + book(root, "h") + .resolve_address("build.owner", None) + .unwrap() + .id + .as_str(), + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", + "the subject kept its id across the cutover" + ); +} + +#[test] +fn unrelated_bytes_and_unknown_declaration_shapes_survive_a_cutover() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let source = concat!( + "// leading comment kept verbatim\n", + "agent \"worker\" {\n", + " host \"h\"\n", + " meta { managed-by \"catalog\"; owner \"platform\"; ticket \"DELTA-003\" }\n", + " name \"Build owner\" // trailing note\n", + " command \"sleep 300\"\n", + "}\n", + ); + write(root, "h/worker/agent.kdl", source); + + set_address(root, "h.worker", "h", None, Some(&address("build.owner"))).unwrap(); + let after = fs::read_to_string(root.join("h/worker/agent.kdl")).unwrap(); + + assert_eq!( + after.replace(" address \"build.owner\"\n", ""), + source, + "exactly one inserted line differs" + ); + assert!(after.contains("ticket \"DELTA-003\"")); + assert!(after.contains("// trailing note")); +} + +#[test] +fn id_keyed_authority_admits_self_and_a_descendant_and_refuses_a_non_descendant() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/root/agent.kdl", &declaration("root", "h", "")); + write( + root, + "h/child/agent.kdl", + &declaration("child", "h", " supervisor \"root\"\n"), + ); + write( + root, + "h/grandchild/agent.kdl", + &declaration("grandchild", "h", " supervisor \"h.child\"\n"), + ); + write(root, "h/stranger/agent.kdl", &declaration("stranger", "h", "")); + + // Both edges are unmigrated legacy positional references, so the grandparent reaches the + // grandchild through the declaration slots that migration will rewrite to immutable IDs. + set_address( + root, + "h.grandchild", + "h", + Some(&AgentSelector::id("h.root")), + Some(&address("grandchild.edge")), + ) + .unwrap(); + set_address( + root, + "h.child", + "h", + Some(&AgentSelector::id("h.child")), + Some(&address("child.edge")), + ) + .unwrap(); + + // An address cutover does not change a legacy declaration slot. The grandparent therefore + // remains authorized until migration rewrites the edge to the same immutable parent ID. + set_address( + root, + "h.grandchild", + "h", + Some(&AgentSelector::id("h.root")), + Some(&address("grandchild.edge2")), + ) + .unwrap(); + + let error = set_address( + root, + "h.stranger", + "h", + Some(&AgentSelector::id("h.child")), + Some(&address("stranger.edge")), + ) + .unwrap_err(); + assert_eq!(error.code(), "address-not-authorized"); + assert_eq!(resolved_id(root, "h", "stranger").unwrap(), "h.stranger"); + + let by_address = set_address( + root, + "h.child", + "h", + Some(&AgentSelector::id("h.child.edge")), + Some(&address("child.edge2")), + ) + .unwrap_err(); + assert_eq!( + by_address.code(), + "address-not-authorized", + "an actor is an id; its own bus address never authorizes it" + ); +} + +#[test] +fn leaving_retirement_validates_effective_address_uniqueness() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/worker/agent.kdl", + &declaration( + "worker", + "h", + " desired-state \"retired\" reason=\"replaced\"\n", + ), + ); + write(root, "h/other/agent.kdl", &declaration("other", "h", "")); + + // A retired subject is non-routable and releases its address, so `other` may claim it. + set_address(root, "h.other", "h", None, Some(&address("worker"))).unwrap(); + + let error = set_desired_state( + root, + "h.worker", + "h", + None, + DesiredStateValue::Running, + None, + ) + .unwrap_err(); + assert_eq!(error.code(), "address-conflict"); + assert!( + st2::discover(root) + .specs + .iter() + .any(|spec| spec.identity == "worker" && spec.desired_state.is_retired()), + "a refused transition leaves the declaration retired" + ); + + // Giving the retired subject its own address makes the same transition admissible. + let retired_cutover = + set_address(root, "h.worker", "h", None, Some(&address("worker.next"))).unwrap(); + assert_eq!( + retired_cutover.bus_address, None, + "a retired subject is non-routable and holds no bus address, only an id" + ); + assert_eq!(retired_cutover.address, "worker.next"); + assert!( + matches!( + resolved_id(root, "h", "worker.next"), + Err(ResolveError::UnknownAddress { .. }) + ), + "and it does not occupy the address namespace while retired" + ); + let receipt = set_desired_state( + root, + "h.worker", + "h", + None, + DesiredStateValue::Running, + None, + ) + .unwrap(); + assert_eq!(receipt.result, AuthorOutcome::Changed); + assert_eq!(resolved_id(root, "h", "worker.next").unwrap(), "h.worker"); +} diff --git a/tests/agent_desired_state.rs b/tests/agent_desired_state.rs index d54e9251..0626e35f 100644 --- a/tests/agent_desired_state.rs +++ b/tests/agent_desired_state.rs @@ -19,6 +19,7 @@ fn author(root: &Path, state: &str, reason: Option<&str>) -> Output { root.to_str().unwrap(), "agent", "desired-state", + "--id", "h.worker", state, "--host", @@ -38,6 +39,7 @@ fn author_as(root: &Path, actor: &str) -> Output { root.to_str().unwrap(), "agent", "desired-state", + "--id", "h.worker", "suspended", "--reason", diff --git a/tests/agent_presentation.rs b/tests/agent_presentation.rs index 09068398..7422e5a6 100644 --- a/tests/agent_presentation.rs +++ b/tests/agent_presentation.rs @@ -63,7 +63,7 @@ fn cli_sets_replaces_and_clears_fields_without_changing_identity_or_other_bytes( let output = run( root, command, - &["h.worker", value, "--host", "h", "--json"], + &["--id", "h.worker", value, "--host", "h", "--json"], None, ); assert!( @@ -110,7 +110,7 @@ fn cli_sets_replaces_and_clears_fields_without_changing_identity_or_other_bytes( let repeat = run( root, "rename", - &["worker", "Build owner", "--host", "h", "--json"], + &["--id", "h.worker", "Build owner", "--host", "h", "--json"], None, ); assert!(repeat.status.success()); @@ -123,7 +123,7 @@ fn cli_sets_replaces_and_clears_fields_without_changing_identity_or_other_bytes( let clear = run( root, command, - &["h.worker", "--clear", "--host", "h", "--json"], + &["--id", "h.worker", "--clear", "--host", "h", "--json"], None, ); assert!(clear.status.success()); @@ -161,7 +161,7 @@ fn cli_authors_positional_identity_without_an_existing_child_block() { let output = run( root, "rename", - &["h.worker", "Owner", "--host", "h", "--json"], + &["--id", "h.worker", "Owner", "--host", "h", "--json"], None, ); assert!( @@ -188,7 +188,7 @@ fn cli_clears_a_presentation_field_from_compact_kdl() { let output = run( root, "rename", - &["h.worker", "--clear", "--host", "h", "--json"], + &["--id", "h.worker", "--clear", "--host", "h", "--json"], None, ); assert!( @@ -221,6 +221,7 @@ fn cli_preserves_declaration_mode_under_a_restrictive_umask() { "--catalog", root.to_str().unwrap(), "rename", + "--id", "h.worker", "Owner", "--host", @@ -256,7 +257,7 @@ fn cli_rejects_unicode_line_and_paragraph_separators_for_both_fields() { let output = run( root, command, - &["h.worker", &value, "--host", "h", "--json"], + &["--id", "h.worker", &value, "--host", "h", "--json"], None, ); assert!( @@ -324,7 +325,7 @@ fn cli_enforces_agent_authority_and_nix_and_format_refusals() { let output = run( root, command, - &[target, "refused", "--host", "h", "--json"], + &["--id", target, "refused", "--host", "h", "--json"], actor, ); assert!(!output.status.success()); @@ -336,7 +337,7 @@ fn cli_enforces_agent_authority_and_nix_and_format_refusals() { let allowed = run( root, "describe", - &["h.child", "Owned by root", "--host", "h", "--json"], + &["--id", "h.child", "Owned by root", "--host", "h", "--json"], Some("h.root"), ); assert!( @@ -373,6 +374,7 @@ fn concurrent_cli_writers_serialize_without_losing_either_field() { "--catalog", root.to_str().unwrap(), command, + "--id", "h.worker", value, "--host", @@ -438,6 +440,7 @@ fn presentation_and_publication_contend_on_the_same_persistent_catalog_lock() { "--catalog", root.to_str().unwrap(), "rename", + "--id", "h.worker", "Build owner", "--host", @@ -525,7 +528,7 @@ fn catalog_lock_refuses_a_symlinked_control_directory() { let output = run( &root, "rename", - &["h.worker", "Owner", "--host", "h", "--json"], + &["--id", "h.worker", "Owner", "--host", "h", "--json"], None, ); assert!(!output.status.success()); @@ -545,6 +548,7 @@ fn presentation_crash_stages_only_in_the_control_plane() { "--catalog", root.to_str().unwrap(), "rename", + "--id", "h.worker", "Build owner", "--host", @@ -579,7 +583,7 @@ fn presentation_crash_stages_only_in_the_control_plane() { let retry = run( &root, "rename", - &["h.worker", "Build owner", "--host", "h", "--json"], + &["--id", "h.worker", "Build owner", "--host", "h", "--json"], None, ); assert!( @@ -607,6 +611,7 @@ fn presentation_post_commit_generation_failure_is_fenced_and_recovered() { "--catalog", root.to_str().unwrap(), "rename", + "--id", "h.worker", "Build owner", "--host", @@ -633,7 +638,7 @@ fn presentation_post_commit_generation_failure_is_fenced_and_recovered() { let recovered = run( &root, "rename", - &["h.worker", "Build owner", "--host", "h", "--json"], + &["--id", "h.worker", "Build owner", "--host", "h", "--json"], None, ); assert!( @@ -664,6 +669,7 @@ fn control_directory_swap_cannot_redirect_presentation_staging() { "--catalog", root.to_str().unwrap(), "rename", + "--id", "h.worker", "Build owner", "--host", @@ -718,7 +724,7 @@ fn catalog_lock_refuses_a_symlinked_lock_file() { let output = run( &root, "describe", - &["h.worker", "Owner", "--host", "h", "--json"], + &["--id", "h.worker", "Owner", "--host", "h", "--json"], None, ); assert!(!output.status.success()); @@ -726,3 +732,71 @@ fn catalog_lock_refuses_a_symlinked_lock_file() { assert_eq!(receipt["code"], "catalog-lock-failed"); assert_eq!(fs::read_to_string(outside).unwrap(), "unchanged"); } + +/// Presentation is selected by immutable agent ID and by nothing else. +/// +/// The subject's mutable address is deliberately moved first: a selector that used to work as a +/// positional identity, and the new address that now routes to the same subject, must both refuse, +/// while the unchanged ID keeps working. Decision 0015 rejects a precedence-based resolver, so +/// there is no order in which an address may satisfy an identity-authoring command. +#[test] +fn presentation_selection_is_immutable_id_only_and_survives_an_address_cutover() { + use st2::agent_author::{PresentationField, set_address, set_presentation}; + + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/worker/agent.kdl", &declaration("worker", None, "catalog")); + + set_presentation( + root, + "h.worker", + "h", + None, + PresentationField::Name, + Some("Build owner"), + ) + .unwrap(); + + set_address( + root, + "h.worker", + "h", + None, + Some(&st2::AgentAddress::parse("build.owner").unwrap()), + ) + .unwrap(); + + let after_cutover = set_presentation( + root, + "h.worker", + "h", + None, + PresentationField::Description, + Some("Own build delivery"), + ) + .unwrap(); + assert_eq!(after_cutover.identity, "h.worker"); + + for reference in ["build.owner", "h.build.owner", "worker"] { + let error = set_presentation( + root, + reference, + "h", + None, + PresentationField::Name, + Some("no"), + ) + .unwrap_err(); + assert_eq!( + error.code(), + "target-not-found", + "{reference:?} is an address, never an id" + ); + } + + let found = st2::discover(root); + let spec = &found.specs[0]; + assert_eq!(spec.name.as_deref(), Some("Build owner")); + assert_eq!(spec.description.as_deref(), Some("Own build delivery")); + assert_eq!(spec.agent_id("h"), "h.worker"); +} diff --git a/tests/agent_publish.rs b/tests/agent_publish.rs index 9f78dd0e..3db6b08b 100644 --- a/tests/agent_publish.rs +++ b/tests/agent_publish.rs @@ -14,8 +14,14 @@ fn st2() -> Command { Command::new(env!("CARGO_BIN_EXE_st2")) } +/// The minted immutable ID every `worker` fixture publishes under. Creation requires a generated +/// canonical UUIDv7: a brand-new subject's ID is never derived from its placement. +const WORKER_ID: &str = "01930000-0000-7000-8000-000000000001"; + fn valid_spec(retired: bool) -> String { - format!("agent \"worker\" {{\n host \"host\"\n retired #{retired}\n argv \"true\"\n}}\n") + format!( + "agent \"worker\" {{\n host \"host\"\n id \"{WORKER_ID}\"\n retired #{retired}\n argv \"true\"\n}}\n" + ) } fn publish(catalog: &Path, spec: &Path, expectation: &[&str]) -> Output { @@ -95,7 +101,10 @@ fn spec_create_is_typed_and_idempotent() { assert_eq!(first["policyProfile"], "st2.core+catalog.v1"); assert_agent_spec_revision(&first["agentSpecRevision"]); assert_eq!(first["status"], "published"); - assert_eq!(first["busId"], "host.worker"); + // The receipt names the immutable ID that owns the subject and, separately, the route a human + // uses to reach it. The ID is the minted UUIDv7; the route is still the identity fallback. + assert_eq!(first["agentId"], WORKER_ID); + assert_eq!(first["busAddress"], "host.worker"); assert_eq!(first["inputSha256"], sha256(valid_spec(false).as_bytes())); assert_eq!(first["afterSha256"], sha256(valid_spec(false).as_bytes())); assert_eq!( @@ -233,7 +242,7 @@ fn cas_rejects_stale_writers_and_preserves_resources() { let workspace = temp.path().join("workspace"); fs::create_dir(&workspace).unwrap(); let replacement = format!( - "agent \"worker\" {{\n host \"host\"\n retired #true\n workspace \"{}\"\n argv \"true\"\n render {{\n copy \"assets/PERSONA.md\" \"PERSONA.md\"\n }}\n}}\n", + "agent \"worker\" {{\n host \"host\"\n id \"{WORKER_ID}\"\n retired #true\n workspace \"{}\"\n argv \"true\"\n render {{\n copy \"assets/PERSONA.md\" \"PERSONA.md\"\n }}\n}}\n", workspace.display() ); let candidate = temp.path().join("retired.kdl"); @@ -308,7 +317,7 @@ fn full_catalog_admission_rejects_same_host_render_ownership_conflicts() { fs::write( &candidate, format!( - "agent \"worker\" {{\n host \"host\"\n workspace \"{}\"\n argv \"true\"\n render {{ file \"shared\" \"two\" }}\n}}\n", + "agent \"worker\" {{\n host \"host\"\n id \"{WORKER_ID}\"\n workspace \"{}\"\n argv \"true\"\n render {{ file \"shared\" \"two\" }}\n}}\n", workspace.display() ), ) @@ -1115,12 +1124,12 @@ fn concurrent_publishers_serialize_and_only_one_wins_the_cas() { let two = temp.path().join("two.kdl"); fs::write( &one, - "agent \"worker\" {\n host \"host\"\n retired #true\n argv \"true\"\n}\n", + format!("agent \"worker\" {{\n host \"host\"\n id \"{WORKER_ID}\"\n retired #true\n argv \"true\"\n}}\n"), ) .unwrap(); fs::write( &two, - "agent \"worker\" {\n host \"host\"\n role \"other\"\n retired #true\n argv \"true\"\n}\n", + format!("agent \"worker\" {{\n host \"host\"\n id \"{WORKER_ID}\"\n role \"other\"\n retired #true\n argv \"true\"\n}}\n"), ) .unwrap(); let expected = sha256(old.as_bytes()); @@ -1352,3 +1361,157 @@ fn wait_for_path(path: &Path) { thread::sleep(Duration::from_millis(10)); } } + +/// A published declaration is address-authorable, and the cutover leaves publication alone. +/// +/// Publication owns the transactional bytes; address authoring edits exactly one field inside them. +/// The subject keeps the agent ID publication admitted it under, so nothing anchored to that ID — +/// task IDs, state paths, supervisor edges — moves. +#[test] +fn a_published_declaration_is_address_authorable_by_its_immutable_id() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + fs::create_dir(&catalog).unwrap(); + let spec = temp.path().join("candidate.kdl"); + fs::write(&spec, valid_spec(false)).unwrap(); + + let published = publish(&catalog, &spec, &["--expect-absent"]); + assert!( + published.status.success(), + "stderr: {}", + String::from_utf8_lossy(&published.stderr) + ); + let before = fs::read_to_string(target(&catalog)).unwrap(); + + let receipt = st2::agent_author::set_address( + &catalog, + WORKER_ID, + "host", + None, + Some(&st2::AgentAddress::parse("build.owner").unwrap()), + ) + .unwrap(); + assert_eq!(receipt.id, WORKER_ID); + assert_eq!(receipt.address, "build.owner"); + assert_eq!(receipt.bus_address.as_deref(), Some("host.build.owner")); + + let after = fs::read_to_string(target(&catalog)).unwrap(); + assert_eq!( + after.replace(" address \"build.owner\"\n", ""), + before, + "publication bytes survive the cutover except the authored field" + ); + + let found = st2::discover(&catalog); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let book = st2::spec::address_book(&found.specs, "host").unwrap(); + assert_eq!( + book.resolve_address("build.owner", None).unwrap().id.as_str(), + WORKER_ID, + ); + assert_eq!( + book.resolve_id(WORKER_ID).unwrap().effective_address, + "build.owner", + ); +} + +/// A byte-level expectation cannot see identity, so publication compares IDs itself. +/// +/// `--expect-sha256` proves only that the incumbent bytes are the ones the caller read. Without an +/// explicit comparison, a matching update was free to re-key the subject and strand every ID-keyed +/// durable surface. Both directions of divergence refuse. +#[test] +fn an_update_cannot_change_the_immutable_agent_id() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + fs::create_dir(&catalog).unwrap(); + let spec = temp.path().join("candidate.kdl"); + fs::write(&spec, valid_spec(false)).unwrap(); + assert!( + publish(&catalog, &spec, &["--expect-absent"]) + .status + .success() + ); + let incumbent = fs::read_to_string(target(&catalog)).unwrap(); + let expected = sha256(incumbent.as_bytes()); + + // A different explicit ID. + let rekey = temp.path().join("rekey.kdl"); + fs::write( + &rekey, + incumbent.replace(WORKER_ID, "01930000-0000-7000-8000-0000000000ff"), + ) + .unwrap(); + let refused = publish(&catalog, &rekey, &["--expect-sha256", &expected]); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr).into_owned(); + assert!(stderr.contains("immutable-agent-id"), "stderr: {stderr}"); + assert_eq!(fs::read_to_string(target(&catalog)).unwrap(), incumbent); + + // Dropping `id` entirely: the implicit frozen legacy value is a different ID. + let dropped = temp.path().join("dropped.kdl"); + fs::write( + &dropped, + incumbent.replace(&format!(" id \"{WORKER_ID}\"\n"), ""), + ) + .unwrap(); + let refused = publish(&catalog, &dropped, &["--expect-sha256", &expected]); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr).into_owned(); + assert!(stderr.contains("immutable-agent-id"), "stderr: {stderr}"); + assert!(stderr.contains("host.worker"), "stderr: {stderr}"); + assert_eq!(fs::read_to_string(target(&catalog)).unwrap(), incumbent); +} + +/// Creating a subject mints a generated ID; it never derives one from placement. +/// +/// The frozen-legacy fallback exists to read and update a subject that predates migration. Left +/// open on the creation path it would let publication keep minting placement-shaped IDs forever, +/// which is the overloading decision 0015 retires. +#[test] +fn creating_a_subject_requires_a_generated_id() { + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + fs::create_dir(&catalog).unwrap(); + + let legacy = temp.path().join("legacy.kdl"); + fs::write( + &legacy, + "agent \"worker\" {\n host \"host\"\n argv \"true\"\n}\n", + ) + .unwrap(); + let refused = publish(&catalog, &legacy, &["--expect-absent"]); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr).into_owned(); + assert!( + stderr.contains("creation-requires-generated-id"), + "stderr: {stderr}" + ); + assert!(!target(&catalog).exists()); + + // A placement-shaped explicit ID is not a minted one either. + let placement_shaped = temp.path().join("placement.kdl"); + fs::write( + &placement_shaped, + "agent \"worker\" {\n host \"host\"\n id \"host.worker\"\n argv \"true\"\n}\n", + ) + .unwrap(); + let refused = publish(&catalog, &placement_shaped, &["--expect-absent"]); + assert!(!refused.status.success()); + assert!( + String::from_utf8_lossy(&refused.stderr).contains("creation-requires-generated-id"), + "stderr: {}", + String::from_utf8_lossy(&refused.stderr) + ); + assert!(!target(&catalog).exists()); + + // The minted form is admitted. + let minted = temp.path().join("minted.kdl"); + fs::write(&minted, valid_spec(false)).unwrap(); + let accepted = publish(&catalog, &minted, &["--expect-absent"]); + assert!( + accepted.status.success(), + "stderr: {}", + String::from_utf8_lossy(&accepted.stderr) + ); +} diff --git a/tests/bus_template.rs b/tests/bus_template.rs new file mode 100644 index 00000000..05e03daf --- /dev/null +++ b/tests/bus_template.rs @@ -0,0 +1,120 @@ +//! The shipped bus instructions are an executable contract, not prose: every agent that boots +//! against `templates/bus.st2.md` runs these exact command lines with `$ST_AGENT` in its +//! environment. `$ST_AGENT` carries the immutable agent ID, so a selector that reaches the address +//! parser is wrong twice over — it is the mutable route, and a renamed agent's boot ritual would +//! start failing. These tests read the template itself, so the instruction and the CLI cannot drift. + +use std::fs; +use std::path::Path; +use std::process::Command; + +const AGENT_ID: &str = "h.worker"; + +fn template() -> String { + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("templates/bus.st2.md")) + .expect("read the shipped bus template") +} + +/// Every backticked snippet in the template that invokes `st2 status` with `$ST_AGENT`. +fn status_snippets(source: &str) -> Vec { + source + .split('`') + .enumerate() + // Odd spans are inside a backtick pair; even spans are surrounding prose. A snippet may + // wrap across a hard line break, so collapse the newline the way a reader would. + .filter(|(index, _)| index % 2 == 1) + .map(|(_, span)| span.split_whitespace().collect::>().join(" ")) + .filter(|span| span.starts_with("st2 status") && span.contains("$ST_AGENT")) + .collect() +} + +#[test] +fn the_shipped_boot_ritual_selects_the_agent_by_immutable_id() { + let snippets = status_snippets(&template()); + assert!( + snippets.len() >= 3, + "the template must keep its boot-ritual and status-discipline commands: {snippets:?}" + ); + for snippet in &snippets { + assert!( + snippet.contains("--id \"$ST_AGENT\""), + "a shipped status command must pass $ST_AGENT through the exact-ID form, never the \ + positional address slot: {snippet}" + ); + } +} + +/// Run the template's own command lines against a real catalog with the same environment st2 +/// injects into a task, and prove each one actually sets the presence it claims. +#[test] +fn the_shipped_status_commands_run_as_written_inside_a_task_environment() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path(); + let declaration = catalog.join("h/worker/agent.kdl"); + fs::create_dir_all(declaration.parent().unwrap()).unwrap(); + fs::write( + &declaration, + "agent \"worker\" {\n identity \"worker\"\n host \"h\"\n command \"true\"\n}\n", + ) + .unwrap(); + + let bin = Path::new(env!("CARGO_BIN_EXE_st2")); + let path = format!( + "{}:{}", + bin.parent().unwrap().display(), + std::env::var("PATH").unwrap_or_default() + ); + + let snippets = status_snippets(&template()); + assert!(!snippets.is_empty()); + for snippet in &snippets { + let out = Command::new("sh") + .arg("-c") + .arg(snippet) + .env("PATH", &path) + .env("CATALOG", catalog) + .env("ST_AGENT", AGENT_ID) + .output() + .expect("run the shipped bus command"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "shipped bus command failed: {snippet}\nstdout: {stdout}\nstderr: {stderr}" + ); + // `--set ` echoes the state it wrote; that is what a booting agent sees. + let state = snippet + .split("--set ") + .nth(1) + .expect("a shipped status command sets a state") + .split_whitespace() + .next() + .unwrap(); + assert!( + stdout.contains(state), + "`{snippet}` must report the state it set ({state}): {stdout}" + ); + } + + let read = Command::new(bin) + .args(["status", "--id", AGENT_ID, "--root"]) + .arg(catalog) + .args(["--host", "h"]) + .output() + .unwrap(); + assert!(read.status.success(), "{read:?}"); + let observed = String::from_utf8_lossy(&read.stdout); + let last = snippets + .last() + .unwrap() + .split("--set ") + .nth(1) + .unwrap() + .split_whitespace() + .next() + .unwrap(); + assert!( + observed.contains(last), + "the last shipped write must be the persisted presence ({last}): {observed}" + ); +} diff --git a/tests/catalog_graph.rs b/tests/catalog_graph.rs index cc52eca4..296cb48a 100644 --- a/tests/catalog_graph.rs +++ b/tests/catalog_graph.rs @@ -162,6 +162,76 @@ fn graph_exposes_admitted_topology_and_delivery_readiness_facts() { ); } +#[test] +fn graph_edges_are_keyed_by_migrated_agent_id_and_carry_the_route_beside_them() { + // R24/R35: a migrated declaration's topology edges are its ID, not its route. `legacy` names + // its parent with an unmigrated positional reference; the edge published for it must still be + // the parent's migrated ID, because the reference is resolved once and only the ID travels. + let catalog = tempfile::tempdir().unwrap(); + let root = catalog.path(); + write( + root, + "agents/h/boss/agent.kdl", + r#"agent "boss" { host "h"; id "boss-id"; command "true" }"#, + ); + write( + root, + "agents/h/worker/agent.kdl", + r#"agent "worker" { + host "h" + id "worker-id" + address "fleet.builder" + supervisor "boss-id" + command "true" +}"#, + ); + write( + root, + "agents/h/legacy/agent.kdl", + r#"agent "legacy" { host "h"; supervisor "boss"; command "true" }"#, + ); + write( + root, + "agents/h/gone/agent.kdl", + r#"agent "gone" { host "h"; id "gone-id"; desired-state "retired" reason="Replaced by worker"; command "true" }"#, + ); + + let output = st2(root, &["catalog", "graph", "--host", "h", "--json"], None); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let graph = json(&output); + assert_eq!(graph["complete"], true, "{graph:#}"); + let rows = graph["agents"].as_array().unwrap(); + + let worker = rows.iter().find(|row| row["id"] == "worker-id").unwrap(); + assert_eq!(worker["identity"], "worker", "the positional key is preserved"); + assert_eq!(worker["address"], "fleet.builder"); + assert_eq!(worker["busAddress"], "h.fleet.builder"); + assert_eq!(worker["parentId"], "boss-id"); + assert_eq!(worker["rootId"], "boss-id"); + assert_eq!(worker["depth"], 1); + assert_eq!(worker["ancestorIds"], serde_json::json!(["boss-id"])); + + let legacy = rows.iter().find(|row| row["id"] == "h.legacy").unwrap(); + assert_eq!( + legacy["parentId"], "boss-id", + "a legacy positional reference resolves to the parent's migrated ID: {legacy:#}" + ); + assert_eq!(legacy["rootId"], "boss-id"); + assert_eq!(legacy["address"], "legacy"); + assert_eq!(legacy["busAddress"], "h.legacy"); + + let gone = rows.iter().find(|row| row["id"] == "gone-id").unwrap(); + assert_eq!(gone["address"], "gone", "the declaration still declares it"); + assert!( + gone["busAddress"].is_null(), + "a retired subject keeps its ID and releases its route: {gone:#}" + ); +} + #[test] fn graph_ignores_retired_roots_and_folds_legacy_retirement_into_declarations() { // #402 regression fixture: one active root plus root-shaped retired declarations — legacy diff --git a/tests/catalog_migrate_ids.rs b/tests/catalog_migrate_ids.rs new file mode 100755 index 00000000..a5f9a159 --- /dev/null +++ b/tests/catalog_migrate_ids.rs @@ -0,0 +1,792 @@ +#![cfg(unix)] +//! Additive legacy explicit-ID migration: freeze every live and archived legacy subject's existing +//! bus identity as its immutable agent ID, without moving one byte of runtime or declaration state. + +use std::fs; +use std::path::{Path, PathBuf}; + +use st2::catalog_archive::{self, UnarchiveRequest}; +use st2::catalog_migrate::{ + self, LegacyEndpoint, MigrateRefusal, MigrateRequest, MigrateStatus, Plane, +}; + +const HOST: &str = "h"; + +fn write(root: &Path, relative: &str, body: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); +} + +fn catalog(temporary: &tempfile::TempDir) -> PathBuf { + let catalog = temporary.path().join("catalog"); + fs::create_dir_all(&catalog).unwrap(); + catalog +} + +/// A legacy declaration: no `id`, so its implicit ID is exactly `.`. +fn legacy_agent(root: &Path, identity: &str, extra: &str) { + write( + root, + &format!("agents/{HOST}/{identity}/agent.kdl"), + &format!( + "agent \"{identity}\" {{\n host \"{HOST}\"\n name \"Worker {identity}\"\n{extra} command \"true\"\n}}\n" + ), + ); +} + +fn declaration(root: &Path, identity: &str) -> String { + fs::read_to_string(root.join(format!("agents/{HOST}/{identity}/agent.kdl"))).unwrap() +} + +fn archived_declaration(root: &Path, identity: &str) -> String { + fs::read_to_string( + root.join(format!(".st2/archive/{HOST}/{identity}/agent.kdl")), + ) + .unwrap() +} + +fn tombstone(root: &Path, identity: &str) -> serde_json::Value { + serde_json::from_slice( + &fs::read(root.join(format!(".st2/archive/{HOST}/{identity}.tombstone.json"))).unwrap(), + ) + .unwrap() +} + +fn migrate(root: &Path) -> anyhow::Result { + catalog_migrate::migrate(MigrateRequest { + catalog: root.to_path_buf(), + host: HOST.to_owned(), + dry_run: false, + }) +} + +fn refusal_code(error: &anyhow::Error) -> &'static str { + error + .downcast_ref::() + .unwrap_or_else(|| panic!("not a classified migration refusal: {error:#}")) + .code +} + +/// Structurally archive one identity by performing exactly the two steps the archive transaction's +/// `move_out` performs: rename the identity directory under the archive root, then write its +/// tombstone beside it. +/// +/// The transaction around those steps — eligibility, the exclusive lock, the generation commit — +/// is proven end to end in `tests/catalog_archive.rs`. Reproducing the on-disk result here keeps +/// the migration suite independent of the CLI binary and of a `pty list` shim, so it exercises +/// exactly the archived state migration has to read. +fn archive(root: &Path, identity: &str) { + write( + root, + &format!("agents/{HOST}/{identity}/agent.kdl"), + &format!( + "agent \"{identity}\" {{\n host \"{HOST}\"\n desired-state \"retired\" reason=\"done\"\n command \"true\"\n}}\n" + ), + ); + let host_root = root.join(format!(".st2/archive/{HOST}")); + fs::create_dir_all(&host_root).unwrap(); + fs::rename( + root.join(format!("agents/{HOST}/{identity}")), + host_root.join(identity), + ) + .unwrap(); + let tombstone = serde_json::json!({ + "schema": "st2.catalog-archive-tombstone.v1", + "id": format!("{HOST}.{identity}"), + "host": HOST, + "identity": identity, + "archivedAt": 1_750_000_000_000_u64, + "reason": "done", + "archiveRoot": format!(".st2/archive/{HOST}/{identity}"), + }); + fs::write( + host_root.join(format!("{identity}.tombstone.json")), + format!("{}\n", serde_json::to_string_pretty(&tombstone).unwrap()), + ) + .unwrap(); + assert!(host_root.join(identity).join("agent.kdl").exists()); +} + +#[test] +fn live_legacy_subjects_freeze_their_bus_identity_without_moving_state() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "alpha", ""); + legacy_agent(&root, "beta", ""); + write( + &root, + &format!("agents/{HOST}/alpha/resources/goal.md"), + "# goal\n", + ); + let before = declaration(&root, "alpha"); + let state = fs::read(root.join(format!("agents/{HOST}/alpha/resources/goal.md"))).unwrap(); + + let result = migrate(&root).unwrap(); + + assert_eq!(result.status, MigrateStatus::Migrated); + assert_eq!(result.migrated.len(), 2, "{result:#?}"); + let alpha = result + .migrated + .iter() + .find(|entry| entry.identity == "alpha") + .unwrap(); + assert_eq!(alpha.id.as_str(), "h.alpha"); + assert_eq!(alpha.plane, Plane::Live); + assert!(!alpha.generated); + + let after = declaration(&root, "alpha"); + assert!(after.contains("id \"h.alpha\""), "{after}"); + // Every pre-existing byte survives: the migration adds one line and touches nothing else. + let added: Vec<&str> = after + .lines() + .filter(|line| !before.lines().any(|original| original == *line)) + .collect(); + assert_eq!(added, vec![" id \"h.alpha\""], "{after}"); + // Declaration-anchored state does not move. + assert_eq!( + fs::read(root.join(format!("agents/{HOST}/alpha/resources/goal.md"))).unwrap(), + state + ); + + // The ownership selector is byte-identical before and after, which is why no runtime, + // task ID, or socket path moves. + let found = st2::discover_strict(&root); + let spec = found + .specs + .iter() + .find(|spec| spec.identity == "alpha") + .unwrap(); + assert_eq!(spec.agent_id(HOST), "h.alpha"); + assert_eq!(spec.id.as_ref().unwrap().as_str(), "h.alpha"); +} + +#[test] +fn an_archived_subject_freezes_the_same_bytes_when_they_are_unique() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "alpha", ""); + archive(&root, "gone"); + + let result = migrate(&root).unwrap(); + + let archived = result + .migrated + .iter() + .find(|entry| entry.identity == "gone") + .unwrap(); + assert_eq!(archived.plane, Plane::Archived); + assert_eq!(archived.id.as_str(), "h.gone"); + assert!(!archived.generated); + assert!(result.collisions.is_empty()); + let text = archived_declaration(&root, "gone"); + assert!(text.contains("id \"h.gone\""), "{text}"); + // A non-colliding archived subject keeps its tombstone untouched. + assert_eq!(tombstone(&root, "gone")["id"], "h.gone"); +} + +#[test] +fn an_archived_collision_receives_a_generated_id_in_its_declaration_and_tombstone() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "worker"); + // The seat came back under the same name after archival: both subjects want `h.worker`. + legacy_agent(&root, "worker", ""); + + let result = migrate(&root).unwrap(); + + let live = result + .migrated + .iter() + .find(|entry| entry.plane == Plane::Live) + .unwrap(); + assert_eq!(live.id.as_str(), "h.worker", "the live subject keeps its bytes"); + assert!(!live.generated); + let archived = result + .migrated + .iter() + .find(|entry| entry.plane == Plane::Archived) + .unwrap(); + assert!(archived.generated); + let generated = archived.id.as_str().to_owned(); + // UUIDv7: canonical hyphenated form with version nibble 7. + assert_eq!(generated.len(), 36, "{generated}"); + assert_eq!(generated.as_bytes()[14], b'7', "{generated}"); + assert_ne!(generated, "h.worker"); + + let text = archived_declaration(&root, "worker"); + assert!(text.contains(&format!("id \"{generated}\"")), "{text}"); + assert_eq!(tombstone(&root, "worker")["id"], generated); + assert!(declaration(&root, "worker").contains("id \"h.worker\"")); +} + +#[test] +fn collision_metadata_is_recorded_durably_and_readable() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "worker"); + legacy_agent(&root, "worker", ""); + + let result = migrate(&root).unwrap(); + + assert_eq!(result.collisions.len(), 1, "{result:#?}"); + let collision = &result.collisions[0]; + assert_eq!(collision.legacy_bus_identity, "h.worker"); + assert_eq!(collision.keeper.as_str(), "h.worker"); + let generated = collision.reassigned[0].as_str().to_owned(); + + let record = catalog_migrate::load_legacy_id_collisions(&root).unwrap(); + assert_eq!(record.schema, "st2.catalog-legacy-id-collisions.v1"); + match record.attribution("h.worker") { + LegacyEndpoint::Collision { keeper, reassigned } => { + assert_eq!(keeper.as_str(), "h.worker"); + assert_eq!( + reassigned.iter().map(|id| id.as_str()).collect::>(), + vec![generated.as_str()] + ); + } + other => panic!("expected a recorded collision, got {other:?}"), + } + // Bytes nobody contested need no record: they are the frozen ID by construction. + assert_eq!(record.attribution("h.other"), LegacyEndpoint::Frozen); + // A pre-migration deployment reads every endpoint as frozen. + let empty = catalog_migrate::load_legacy_id_collisions(temporary.path()).unwrap(); + assert_eq!(empty.attribution("h.worker"), LegacyEndpoint::Frozen); +} + +/// Absent means "pre-migration", and only absent. A record that exists but cannot be read proves +/// nothing about which legacy bytes were contested, and reading it as empty would retype every +/// contested endpoint into whichever subject kept the bytes. +#[test] +fn an_unreadable_collision_record_refuses_instead_of_reading_as_empty() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + let path = catalog_migrate::legacy_id_collisions_path(&root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + + // A dangling symlink: the entry demonstrably exists, but `fs::read` reports NotFound for it, + // so deciding absence from the read would let an alias forge "no collisions were recorded". + std::os::unix::fs::symlink(root.join("nowhere.json"), &path).unwrap(); + let refusal = format!( + "{:#}", + catalog_migrate::load_legacy_id_collisions(&root) + .expect_err("a dangling collision-record symlink must refuse") + ); + assert!( + refusal.contains("not a regular file"), + "the refusal must name the aliased entry: {refusal}" + ); + // A symlink to a valid record is refused for the same reason: the bytes are not the ones the + // control plane wrote and can be swapped out from under a reader. + std::fs::write( + root.join("nowhere.json"), + br#"{"schema":"st2.catalog-legacy-id-collisions.v1","entries":[]}"#, + ) + .unwrap(); + assert!( + catalog_migrate::load_legacy_id_collisions(&root).is_err(), + "an aliased collision record must refuse even when its target parses" + ); + std::fs::remove_file(&path).unwrap(); + + // Corrupt body. + std::fs::write(&path, b"{ this is not json").unwrap(); + let refusal = format!( + "{:#}", + catalog_migrate::load_legacy_id_collisions(&root) + .expect_err("a corrupt collision record must refuse") + ); + assert!( + refusal.contains("legacy-id-collision record"), + "the refusal must name the record: {refusal}" + ); + + // A schema string this binary does not understand. + std::fs::write( + &path, + br#"{"schema":"st2.catalog-legacy-id-collisions.v2","entries":[]}"#, + ) + .unwrap(); + let refusal = format!( + "{:#}", + catalog_migrate::load_legacy_id_collisions(&root) + .expect_err("a foreign-schema collision record must refuse") + ); + assert!( + refusal.contains("unsupported legacy-id-collision schema"), + "the refusal must name the unsupported schema: {refusal}" + ); + + // Migration itself refuses rather than rewriting the record it could not read. + legacy_agent(&root, "worker", ""); + let migrate_refusal = format!("{:#}", migrate(&root).expect_err("migrate must refuse")); + assert!( + migrate_refusal.contains("legacy-id-collision"), + "migrate must surface the unreadable record: {migrate_refusal}" + ); +} + +#[test] +fn supervisor_references_are_rewritten_to_the_parents_migrated_id() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + // A parent that only exists in the structural archive: resolution uses the *combined* + // pre-migration live-and-archived index, not just what discovery can see. + archive(&root, "ghostboss"); + legacy_agent(&root, "boss", ""); + // A bare address, resolved on the child's own host. + legacy_agent(&root, "child", " supervisor \"boss\"\n"); + // Already the parent's pre-migration ID bytes: nothing to rewrite. + legacy_agent(&root, "cousin", &format!(" supervisor \"{HOST}.boss\"\n")); + legacy_agent(&root, "orphaned", " supervisor \"ghostboss\"\n"); + + let result = migrate(&root).unwrap(); + + let mut rewritten = result + .supervisors + .iter() + .map(|rewrite| (rewrite.identity.as_str(), rewrite.to.as_str())) + .collect::>(); + rewritten.sort(); + assert_eq!( + rewritten, + vec![("child", "h.boss"), ("orphaned", "h.ghostboss")], + "{result:#?}" + ); + assert!(declaration(&root, "child").contains("supervisor \"h.boss\"")); + assert!(declaration(&root, "orphaned").contains("supervisor \"h.ghostboss\"")); + // An already ID-keyed reference is left byte-identical rather than rewritten to itself. + assert!(declaration(&root, "cousin").contains("supervisor \"h.boss\"")); + + // The edge now points at the parent's immutable ID. + let found = st2::discover_strict(&root); + let child = found + .specs + .iter() + .find(|spec| spec.identity == "child") + .unwrap(); + assert_eq!(child.supervisor.as_deref(), Some("h.boss")); +} + +#[test] +fn a_missing_supervisor_reference_refuses_before_any_write() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "orphan", " supervisor \"ghost\"\n"); + let before = declaration(&root, "orphan"); + + let error = migrate(&root).unwrap_err(); + + assert_eq!(refusal_code(&error), "legacy-supervisor-unresolved"); + assert_eq!(declaration(&root, "orphan"), before, "nothing may be written"); + assert!(!catalog_migrate::legacy_id_collisions_path(&root).exists()); +} + +#[test] +fn an_ambiguous_supervisor_reference_refuses_before_any_write() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + // Archived `h.boss` and live `h.boss` are two distinct pre-migration subjects, so a reference + // to `boss` cannot be attributed to one ID-keyed parent. + archive(&root, "boss"); + legacy_agent(&root, "boss", ""); + legacy_agent(&root, "child", " supervisor \"boss\"\n"); + + + let error = migrate(&root).unwrap_err(); + + assert_eq!(refusal_code(&error), "legacy-supervisor-unresolved"); + assert!(declaration(&root, "child").contains("supervisor \"boss\"")); + assert!(!declaration(&root, "child").contains("id \"")); +} + +#[test] +fn agent_ids_are_unique_across_the_live_catalog_and_the_structural_archive() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "twin"); + migrate(&root).unwrap(); + // A new live declaration that explicitly claims the archived subject's frozen ID. + write( + &root, + "agents/h/fresh/agent.kdl", + "agent \"fresh\" {\n host \"h\"\n id \"h.twin\"\n command \"true\"\n}\n", + ); + + let error = migrate(&root).unwrap_err(); + + assert_eq!(refusal_code(&error), "identity-not-unique"); + assert!( + format!("{error:#}").contains("h.twin"), + "the conflict names the duplicated ID: {error:#}" + ); +} + +#[test] +fn host_local_address_uniqueness_catches_an_identity_fallback_collision() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "worker", ""); + // An explicit address that collides with another declaration's identity fallback. + write( + &root, + "agents/h/renamed/agent.kdl", + "agent \"renamed\" {\n host \"h\"\n address \"worker\"\n command \"true\"\n}\n", + ); + + let error = migrate(&root).unwrap_err(); + + assert_eq!(refusal_code(&error), "identity-not-unique"); + assert!( + format!("{error:#}").contains("worker"), + "the conflict names the contested address: {error:#}" + ); + assert!(!declaration(&root, "worker").contains("id \"")); +} + +#[test] +fn a_second_migration_pass_is_a_proven_no_op() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "worker"); + legacy_agent(&root, "worker", ""); + legacy_agent(&root, "boss", ""); + legacy_agent(&root, "child", " supervisor \"boss\"\n"); + let first = migrate(&root).unwrap(); + assert_eq!(first.status, MigrateStatus::Migrated); + let live = declaration(&root, "worker"); + let archived = archived_declaration(&root, "worker"); + let child = declaration(&root, "child"); + let record = fs::read(catalog_migrate::legacy_id_collisions_path(&root)).unwrap(); + + let second = migrate(&root).unwrap(); + + assert_eq!(second.status, MigrateStatus::Unchanged); + assert!(second.migrated.is_empty(), "{second:#?}"); + assert!(second.supervisors.is_empty(), "{second:#?}"); + assert!(second.collisions.is_empty(), "{second:#?}"); + assert_eq!(declaration(&root, "worker"), live); + assert_eq!(archived_declaration(&root, "worker"), archived); + assert_eq!(declaration(&root, "child"), child); + assert_eq!( + fs::read(catalog_migrate::legacy_id_collisions_path(&root)).unwrap(), + record + ); +} + +#[test] +fn an_archived_subject_releases_its_address_but_keeps_its_id() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "seat"); + migrate(&root).unwrap(); + + let subjects = catalog_archive::archived_subjects(&root).unwrap(); + assert_eq!(subjects.len(), 1); + assert_eq!(subjects[0].id.as_str(), "h.seat"); + assert!(!subjects[0].routable, "a retired subject is non-routable"); + assert_eq!(subjects[0].bus_address(), None, "it released its address"); + + // Because the address is released, a new live subject may take those exact route bytes; + // the archived subject's ID stays reserved. + write( + &root, + "agents/h/successor/agent.kdl", + "agent \"successor\" {\n host \"h\"\n address \"seat\"\n command \"true\"\n}\n", + ); + let result = migrate(&root).unwrap(); + assert_eq!(result.status, MigrateStatus::Migrated); + assert_eq!(result.migrated.len(), 1); + assert_eq!(result.migrated[0].id.as_str(), "h.successor"); + assert_eq!( + catalog_archive::archived_subjects(&root).unwrap()[0] + .id + .as_str(), + "h.seat" + ); +} + +#[test] +fn unarchive_preserves_and_validates_the_migrated_id() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "alpha", ""); + archive(&root, "back"); + migrate(&root).unwrap(); + + let receipt = catalog_archive::unarchive(UnarchiveRequest { + catalog: root.clone(), + host: HOST.to_owned(), + identity: "back".to_owned(), + }) + .unwrap(); + + assert_eq!(receipt.id, "h.back", "the subject keeps its frozen ID"); + assert!(declaration(&root, "back").contains("id \"h.back\"")); + assert!(!root.join(".st2/archive/h/back").exists()); +} + +#[test] +fn unarchive_refuses_an_id_the_live_catalog_already_claims() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "twin"); + migrate(&root).unwrap(); + // The archived declaration's frozen ID, claimed by a live subject under another name. + write( + &root, + "agents/h/other/agent.kdl", + "agent \"other\" {\n host \"h\"\n id \"h.twin\"\n command \"true\"\n}\n", + ); + + let error = catalog_archive::unarchive(UnarchiveRequest { + catalog: root.clone(), + host: HOST.to_owned(), + identity: "twin".to_owned(), + }) + .unwrap_err(); + + let rendered = format!("{error:#}"); + assert!(rendered.contains("h.twin"), "{rendered}"); + assert!( + root.join(".st2/archive/h/twin/agent.kdl").exists(), + "the refusal happens before the move" + ); +} + +#[test] +fn unarchive_refuses_an_unmigrated_archived_declaration_in_a_migrated_catalog() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "stale"); + legacy_agent(&root, "alpha", ""); + // Migrate only the live plane, leaving the archived declaration unmigrated, exactly the state + // an operator reaches by archiving after activation without repairing the tombstoned spec. + migrate(&root).unwrap(); + let archived_path = root.join(".st2/archive/h/stale/agent.kdl"); + let text = fs::read_to_string(&archived_path).unwrap(); + fs::write(&archived_path, text.replace(" id \"h.stale\"\n", "")).unwrap(); + + let error = catalog_archive::unarchive(UnarchiveRequest { + catalog: root.clone(), + host: HOST.to_owned(), + identity: "stale".to_owned(), + }) + .unwrap_err(); + + let rendered = format!("{error:#}"); + assert!( + rendered.contains("no explicit `id`"), + "the refusal explains the missing migration: {rendered}" + ); + assert!(archived_path.exists(), "the refusal happens before the move"); +} + +#[test] +fn a_nix_owned_declaration_refuses_instead_of_being_rewritten() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + write( + &root, + "agents/h/managed/agent.kdl", + "agent \"managed\" {\n host \"h\"\n meta {\n managed-by \"nix\"\n }\n command \"true\"\n}\n", + ); + let before = declaration(&root, "managed"); + + let error = migrate(&root).unwrap_err(); + + assert_eq!(refusal_code(&error), "nix-owned-declaration"); + assert_eq!(declaration(&root, "managed"), before); +} + +#[test] +fn a_dry_run_proves_the_plan_without_writing() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "alpha", ""); + let before = declaration(&root, "alpha"); + + let result = catalog_migrate::migrate(MigrateRequest { + catalog: root.clone(), + host: HOST.to_owned(), + dry_run: true, + }) + .unwrap(); + + assert_eq!(result.status, MigrateStatus::Migrated); + assert!(result.dry_run); + assert_eq!(result.migrated[0].id.as_str(), "h.alpha"); + assert_eq!(declaration(&root, "alpha"), before); +} + +/// The declaration projection models `id` and `address`, so a whole-catalog comparison sees the +/// migration as a semantic field change rather than as opaque byte drift. +#[test] +fn the_declaration_projection_models_id_and_address_as_comparable_fields() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + legacy_agent(&root, "alpha", ""); + let prepared = temporary.path().join("prepared"); + fs::create_dir_all(prepared.join(format!("agents/{HOST}/alpha"))).unwrap(); + fs::write( + prepared.join(format!("agents/{HOST}/alpha/agent.kdl")), + declaration(&root, "alpha") + .replace(" host \"h\"\n", " host \"h\"\n id \"h.alpha\"\n address \"build.owner\"\n"), + ) + .unwrap(); + + let snapshot = st2::catalog_transaction::snapshot(st2::catalog_transaction::SnapshotRequest { + catalog: root.clone(), + output: temporary.path().join("snapshot.json"), + raw_preimage: false, + }) + .unwrap(); + let diff = st2::catalog_transaction::diff(st2::catalog_transaction::DiffRequest { + catalog: root.clone(), + prepared, + expect_sha256: snapshot.root_sha256, + }) + .unwrap(); + + let agent = diff + .agents + .iter() + .find(|delta| delta.identity == "alpha") + .unwrap_or_else(|| panic!("no semantic delta for alpha: {diff:#?}")); + let changed = agent + .fields + .iter() + .map(|field| field.address.as_str()) + .collect::>(); + assert!( + changed.contains(&"/agents/h/alpha/id"), + "the added explicit ID is a modelled field: {changed:?}" + ); + assert!( + changed.contains(&"/agents/h/alpha/address"), + "the address cutover is a modelled field: {changed:?}" + ); +} + +/// A pass interrupted between an archived declaration write and its tombstone write converges. +/// +/// The declaration already carries its generated ID, so "does this declaration have an id" reports +/// nothing to do while the tombstone still names the bytes the subject lost. Repairs are scheduled +/// from the observed disagreement, so the rerun closes it and rebuilds the collision metadata. +#[test] +fn an_interrupted_pass_repairs_a_tombstone_that_disagrees_with_its_declaration() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "worker"); + legacy_agent(&root, "worker", ""); + let first = migrate(&root).unwrap(); + let generated = first.collisions[0].reassigned[0].as_str().to_owned(); + let archived_bytes = archived_declaration(&root, "worker"); + + // Rewind to the exact intermediate state: declaration written, tombstone and record not. + let tombstone_path = root.join(format!(".st2/archive/{HOST}/worker.tombstone.json")); + let mut stale: serde_json::Value = + serde_json::from_slice(&fs::read(&tombstone_path).unwrap()).unwrap(); + stale["id"] = serde_json::Value::String("h.worker".to_owned()); + fs::write( + &tombstone_path, + format!("{}\n", serde_json::to_string_pretty(&stale).unwrap()), + ) + .unwrap(); + fs::remove_file(catalog_migrate::legacy_id_collisions_path(&root)).unwrap(); + + let repair = migrate(&root).unwrap(); + + assert_eq!(repair.status, MigrateStatus::Migrated, "{repair:#?}"); + assert_eq!(repair.tombstones_repaired, 1, "{repair:#?}"); + assert!( + repair.migrated.is_empty(), + "no declaration needed a new ID: {repair:#?}" + ); + assert_eq!(tombstone(&root, "worker")["id"], generated); + assert_eq!( + archived_declaration(&root, "worker"), + archived_bytes, + "the declaration was already correct and must not be rewritten" + ); + // The metadata that keeps a tolerant reader from retyping `h.worker` is durable again. + let record = catalog_migrate::load_legacy_id_collisions(&root).unwrap(); + match record.attribution("h.worker") { + LegacyEndpoint::Collision { keeper, reassigned } => { + assert_eq!(keeper.as_str(), "h.worker"); + assert_eq!(reassigned[0].as_str(), generated); + } + other => panic!("the rebuilt record must explain the collision, got {other:?}"), + } + + // And it stays a proven no-op from there. + let settled = migrate(&root).unwrap(); + assert_eq!(settled.status, MigrateStatus::Unchanged, "{settled:#?}"); + assert_eq!(settled.tombstones_repaired, 0); +} + +/// An aliased archive entry is uncertainty, not absence. +/// +/// Every consumer of the archived reader feeds a catalog-global uniqueness proof, so an entry that +/// could be hiding an occupied agent ID must refuse rather than be skipped. +#[test] +fn the_archived_reader_refuses_an_aliased_entry() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "seat"); + legacy_agent(&root, "alpha", ""); + std::os::unix::fs::symlink( + root.join(format!(".st2/archive/{HOST}/seat")), + root.join(format!(".st2/archive/{HOST}/alias")), + ) + .unwrap(); + + let error = catalog_archive::archived_subjects(&root).unwrap_err(); + let rendered = format!("{error:#}"); + assert!( + rendered.contains("alias") && rendered.contains("refusing to prove archived identity"), + "{rendered}" + ); + // The transaction that would claim an ID inherits the refusal instead of a short archive. + let error = migrate(&root).unwrap_err(); + assert!( + format!("{error:#}").contains("refusing to prove archived identity"), + "{error:#}" + ); + assert!(!declaration(&root, "alpha").contains("id \"")); +} + +/// Two archived subjects claiming one agent ID are both reported, never silently collapsed. +#[test] +fn the_archive_observation_reports_duplicate_archived_ids() { + let temporary = tempfile::tempdir().unwrap(); + let root = catalog(&temporary); + archive(&root, "one"); + archive(&root, "two"); + // Force the pathological state the ID-keyed map used to hide: one ID, two placements. + for identity in ["one", "two"] { + let path = root.join(format!(".st2/archive/{HOST}/{identity}.tombstone.json")); + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + value["id"] = serde_json::Value::String("h.shared".to_owned()); + fs::write( + &path, + format!("{}\n", serde_json::to_string_pretty(&value).unwrap()), + ) + .unwrap(); + } + + let observation = catalog_archive::observe(&root).unwrap(); + + assert_eq!( + observation.archived.len(), + 2, + "both archived identities stay visible: {observation:#?}" + ); + assert!( + observation.issues.iter().any(|issue| { + issue.message.contains("h.shared") && issue.message.contains("2 archived identities") + }), + "the duplicate is its own diagnostic: {observation:#?}" + ); +} diff --git a/tests/driver_expansion.rs b/tests/driver_expansion.rs index ef1fbf54..193f8fb0 100644 --- a/tests/driver_expansion.rs +++ b/tests/driver_expansion.rs @@ -116,7 +116,7 @@ fn pi_deliver_wraps_the_authored_launch_without_rendering_anything() { catalog.to_string_lossy().into_owned(), "driver".into(), "pi-session".into(), - "--identity".into(), + "--id".into(), "h.worker".into(), "--runtime-id".into(), "h.worker".into(), @@ -253,7 +253,7 @@ fn claude_driver_uses_the_packaged_channel_without_project_mcp_state() { catalog_string.as_str(), "driver", "claude-session", - "--identity", + "--id", "h.worker", "--runtime-id", "h.worker", @@ -371,7 +371,7 @@ fn legacy_claude_shell_launch_keeps_its_source_under_the_session_wrapper() { [ "driver", "claude-session", - "--identity", + "--id", "h.worker", "--runtime-id", "h.worker", diff --git a/tests/event_e2e.rs b/tests/event_e2e.rs index 54d29da3..d2998933 100644 --- a/tests/event_e2e.rs +++ b/tests/event_e2e.rs @@ -28,7 +28,7 @@ fn emit(root: &Path, id: &str, key: Option<&str>, supersede: bool) -> event::Eve event::emit( root, "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", id, key, @@ -66,7 +66,7 @@ fn public_emit_requires_a_declared_stream_even_for_reserved_resync() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "resync", "forged-resync", Some("goal"), @@ -95,7 +95,7 @@ fn completed_event_replay_rejects_changed_supersession_intent() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "stable-intent", Some("pr-1"), @@ -120,7 +120,7 @@ fn durable_successor_failure_retains_replayable_pending_receipt() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "durable-successor", None, @@ -144,7 +144,7 @@ fn durable_successor_failure_retains_replayable_pending_receipt() { let replay = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "durable-successor", None, @@ -204,7 +204,7 @@ fn conflicting_reuse_and_undeclared_or_suspended_ingress_fail_closed() { let conflict = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "same", None, @@ -221,7 +221,7 @@ fn conflicting_reuse_and_undeclared_or_suspended_ingress_fail_closed() { let undeclared = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "other", "1", None, @@ -244,7 +244,7 @@ fn conflicting_reuse_and_undeclared_or_suspended_ingress_fail_closed() { let suspended = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "2", None, @@ -279,7 +279,7 @@ fn ambiguous_recipient_matching_a_bus_id_and_local_identity_fails_closed() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "ambiguous", None, @@ -313,7 +313,7 @@ fn exact_remote_bus_id_cannot_bypass_the_owner_host_lock_domain() { let error = event::emit( catalog.path(), "hetz", - "berlin.worker", + &st2::AgentSelector::address("berlin.worker"), "gh-ci", "remote-attempt", None, @@ -433,7 +433,7 @@ fn unobservable_declaration_entry_blocks_event_recipient_resolution() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "strict-discovery", None, @@ -463,7 +463,7 @@ fn symlinked_stream_state_ancestor_cannot_escape_the_agent_capability() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "escape-state", None, @@ -501,7 +501,7 @@ fn predictable_stream_state_temporary_symlink_is_never_followed() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "temp-symlink", None, @@ -543,7 +543,7 @@ fn stream_state_symlink_and_fifo_fail_without_following_or_blocking() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", entry, None, @@ -571,7 +571,7 @@ fn symlinked_inbox_cannot_escape_the_agent_capability() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "escape-inbox", None, @@ -668,7 +668,7 @@ fn initial_supersession_authenticates_predecessor_immediately_before_archive() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "initial-passed", Some("pr-1"), @@ -713,7 +713,7 @@ fn predecessor_replacement_after_validation_cannot_forge_archive_receipt() { event::emit( &root, "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "race-passed", Some("pr-1"), @@ -763,7 +763,13 @@ fn catalog_authoring_and_emit_linearize_without_deadlock() { let root = catalog.path().to_path_buf(); let (done_tx, done_rx) = mpsc::channel(); let author = std::thread::spawn(move || { - let result = st2::agent_author::remove_stream(&root, "hetz.worker", "hetz", None, "gh-ci"); + let result = st2::agent_author::remove_stream( + &root, + &st2::AgentSelector::address("hetz.worker"), + "hetz", + None, + "gh-ci", + ); done_tx.send(result).unwrap(); }); assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err()); @@ -780,7 +786,7 @@ fn catalog_authoring_and_emit_linearize_without_deadlock() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "after-remove", None, @@ -829,7 +835,7 @@ fn desired_state_authoring_and_emit_linearize_without_deadlock() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "after-suspend", None, @@ -853,7 +859,7 @@ fn invalid_archive_receipt_blocks_supersession_before_successor_publication() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "pr1-pass", Some("pr-1"), @@ -899,7 +905,7 @@ fn a_different_event_reconciles_both_pending_crash_windows() { let before_materialization = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "reserved-a", None, @@ -923,7 +929,7 @@ fn a_different_event_reconciles_both_pending_crash_windows() { let after_materialization = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "materialized-a", Some("pr-1"), @@ -950,7 +956,7 @@ fn a_different_event_reconciles_both_pending_crash_windows() { let replay = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "materialized-a", Some("pr-1"), @@ -980,7 +986,7 @@ fn pending_reconciliation_rejects_corrupt_and_forged_reserved_files() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "reserved", Some("pr-1"), @@ -1010,7 +1016,7 @@ fn pending_reconciliation_rejects_corrupt_and_forged_reserved_files() { let corrupt = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "next", None, @@ -1030,7 +1036,7 @@ fn pending_reconciliation_rejects_corrupt_and_forged_reserved_files() { let forged_identity = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "next", None, @@ -1066,7 +1072,7 @@ fn pending_reconciliation_rejects_a_fifo_without_blocking() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "reserved-fifo", None, @@ -1091,7 +1097,7 @@ fn pending_reconciliation_rejects_a_fifo_without_blocking() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "next", None, @@ -1117,7 +1123,7 @@ fn a_different_event_completes_pending_successor_compaction() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "passed", Some("pr-1"), @@ -1163,7 +1169,7 @@ fn pending_supersession_authenticates_its_predecessor_before_archive() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "passed", Some("pr-1"), @@ -1179,7 +1185,7 @@ fn pending_supersession_authenticates_its_predecessor_before_archive() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "unrelated", None, @@ -1214,7 +1220,7 @@ fn pending_supersession_accepts_an_authenticated_archive_only_predecessor() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "passed", Some("pr-1"), @@ -1250,7 +1256,7 @@ fn pending_supersession_recovery_unlinks_a_durably_archived_predecessor() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "passed", Some("pr-1"), @@ -1285,7 +1291,7 @@ fn archived_pending_event_still_authenticates_a_same_name_inbox_entry() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "reserved", Some("pr-1"), @@ -1308,7 +1314,7 @@ fn archived_pending_event_still_authenticates_a_same_name_inbox_entry() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "next", None, @@ -1335,7 +1341,7 @@ fn pending_supersession_fails_closed_when_predecessor_has_no_receipt() { let _ = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "passed", Some("pr-1"), @@ -1351,7 +1357,7 @@ fn pending_supersession_fails_closed_when_predecessor_has_no_receipt() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "unrelated", None, @@ -1425,7 +1431,7 @@ fn crash_replay_honors_an_archive_receipt_and_never_restores_the_inbox_copy() { let receipt = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "archived", None, @@ -1446,7 +1452,7 @@ fn subject_frontmatter_injection_is_refused_before_any_write() { let error = event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "gh-ci", "safe-id", None, @@ -1477,6 +1483,51 @@ fn stream_state_is_bounded_and_forgets_only_beyond_its_honest_horizon() { assert_ne!(replay.filename, first.filename); } +#[test] +fn a_duplicate_agent_id_refuses_stream_resolution_instead_of_picking_a_declaration() { + // The address book dedups its candidates BY agent ID, so `hetz.worker` resolves cleanly to a + // single Subject even though two declarations share the ID. The back-mapping to a declaration + // is what refuses; first-match would publish into — and host-check — the wrong declaration. + let catalog = tempfile::tempdir().unwrap(); + declare_agent(catalog.path(), "\"running\"", " stream \"gh-ci\" {}\n"); + let twin = catalog.path().join("agents/hetz/twin"); + fs::create_dir_all(&twin).unwrap(); + fs::write( + twin.join("agent.kdl"), + "agent \"twin\" {\n host \"hetz\"\n desired-state \"running\"\n command \"agent\"\n stream \"gh-ci\" {}\n}\n", + ) + .unwrap(); + // Both declarations carry the same explicit id, so the catalog's global id namespace is + // broken while each address still names exactly one subject. + for declaration in ["agents/hetz/worker/agent.kdl", "agents/hetz/twin/agent.kdl"] { + let path = catalog.path().join(declaration); + let body = fs::read_to_string(&path).unwrap(); + fs::write( + &path, + body.replace(" host \"hetz\"\n", " host \"hetz\"\n id \"shared-id\"\n"), + ) + .unwrap(); + } + + let refusal = event::emit( + catalog.path(), + "hetz", + &st2::AgentSelector::address("hetz.worker"), + "gh-ci", + "run-1", + None, + None, + "{}", + false, + ) + .expect_err("a duplicate-id catalog must refuse"); + let rendered = format!("{refusal:#}"); + assert!( + rendered.contains("declared by more than one subject"), + "the refusal must name the broken id namespace: {rendered}" + ); +} + #[test] fn event_emit_cli_cannot_claim_the_supervisor_resync_stream() { let catalog = tempfile::tempdir().unwrap(); diff --git a/tests/exec_backend.rs b/tests/exec_backend.rs index dbbb20db..54920ccd 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_id: "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/fixtures/driver/claude.out.kdl b/tests/fixtures/driver/claude.out.kdl index 793f0bf0..e2ba6e2a 100644 --- a/tests/fixtures/driver/claude.out.kdl +++ b/tests/fixtures/driver/claude.out.kdl @@ -1,4 +1,4 @@ render { json-upsert ".claude/settings.local.json" "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"hooks\": {\n \"PermissionRequest\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PermissionRequest\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PostCompact\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PostCompact\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PostToolUse\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PostToolUse\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PreCompact\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-pre-compact.sh\\\"\",\n \"type\": \"command\"\n },\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PreCompact\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"PreToolUse\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" PreToolUse\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"async\": true,\n \"asyncRewake\": true,\n \"command\": \"\\\"$ST_HOOKS/claude-session-start.sh\\\"\",\n \"type\": \"command\"\n },\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" SessionStart\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" Stop\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"StopFailure\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-stop-failure.sh\\\"\",\n \"type\": \"command\"\n },\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" StopFailure\",\n \"type\": \"command\"\n }\n ]\n }\n ],\n \"UserPromptSubmit\": [\n {\n \"hooks\": [\n {\n \"command\": \"\\\"$ST_HOOKS/claude-observe.sh\\\" UserPromptSubmit\",\n \"type\": \"command\"\n }\n ]\n }\n ]\n },\n \"statusLine\": {\n \"command\": \"\\\"$ST_HOOKS/claude-statusline.sh\\\"\",\n \"padding\": 0,\n \"refreshInterval\": 5,\n \"type\": \"command\"\n }\n}" arrays=union } -argv st2 --catalog $CATALOG driver claude-session --identity Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh --channels plugin:st2-channel@st2 --permission-mode bypassPermissions --model override "Start the assigned work." +argv st2 --catalog $CATALOG driver claude-session --id Silber.fabric --runtime-id Silber.fabric -- claude --model opus --effort xhigh --channels plugin:st2-channel@st2 --permission-mode bypassPermissions --model override "Start the assigned work." diff --git a/tests/fixtures/driver/codex.out.kdl b/tests/fixtures/driver/codex.out.kdl index bc48068a..560970d1 100644 --- a/tests/fixtures/driver/codex.out.kdl +++ b/tests/fixtures/driver/codex.out.kdl @@ -1 +1 @@ -argv st2 --catalog $CATALOG driver codex --identity host.worker --runtime-id host.worker -- codex --model gpt-5.6-sol -c "model_reasoning_effort=xhigh" --dangerously-bypass-approvals-and-sandbox --model override "Start the assigned work." +argv st2 --catalog $CATALOG driver codex --id host.worker --runtime-id host.worker -- codex --model gpt-5.6-sol -c "model_reasoning_effort=xhigh" --dangerously-bypass-approvals-and-sandbox --model override "Start the assigned work." diff --git a/tests/fixtures/driver/omp.out.kdl b/tests/fixtures/driver/omp.out.kdl index 4d420df4..1ab7fa76 100644 --- a/tests/fixtures/driver/omp.out.kdl +++ b/tests/fixtures/driver/omp.out.kdl @@ -1 +1 @@ -argv st2 --catalog $CATALOG driver omp-session --identity host.worker --runtime-id host.worker -- omp --model "opencode-go/gpt-5.6-luna" --thinking high --tools read,bash,edit,write "Start the assigned work." +argv st2 --catalog $CATALOG driver omp-session --id host.worker --runtime-id host.worker -- omp --model "opencode-go/gpt-5.6-luna" --thinking high --tools read,bash,edit,write "Start the assigned work." diff --git a/tests/fixtures/driver/pi.out.kdl b/tests/fixtures/driver/pi.out.kdl index 022780a8..85cc8673 100644 --- a/tests/fixtures/driver/pi.out.kdl +++ b/tests/fixtures/driver/pi.out.kdl @@ -1 +1 @@ -argv st2 --catalog $CATALOG driver pi-session --identity host.worker --runtime-id host.worker -- pi -a --model "anthropic/claude-opus-5" --thinking high --tools read,bash,edit,write "Start the assigned work." +argv st2 --catalog $CATALOG driver pi-session --id host.worker --runtime-id host.worker -- pi -a --model "anthropic/claude-opus-5" --thinking high --tools read,bash,edit,write "Start the assigned work." diff --git a/tests/message.rs b/tests/message.rs index 44dc8009..50bb969a 100644 --- a/tests/message.rs +++ b/tests/message.rs @@ -6,9 +6,10 @@ use std::fs; use std::path::Path; +use st2::AgentSelector; use st2::message::{ archive_dir, archive_msg, collect_thread, inbox_dir, list_dir, read_msg, reply_subject, - resolve_agent_dir, send_to_inbox, + resolve_agent_dir, select_agent_dir, send_to_inbox, send_to_resolved_inbox, }; fn write(root: &Path, rel: &str, contents: &str) { @@ -31,10 +32,27 @@ fn agent_kdl(identity: &str, host: &str) -> String { ) } +/// A migrated declaration: an explicit catalog-global immutable `id` and a mutable `address`. +fn migrated_agent_kdl(identity: &str, host: &str, id: &str, address: &str) -> String { + format!( + r#"agent "{identity}" {{ + identity "{identity}" + id "{id}" + address "{address}" + host "{host}" + type "service" + pty "agent" {{ + command "exec claude boot" + }} +}} +"# + ) +} + /// 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] -fn send_by_bus_id_lands_in_recipient_inbox() { +fn send_by_agent_id_lands_in_recipient_inbox() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write( @@ -48,15 +66,18 @@ fn send_by_bus_id_lands_in_recipient_inbox() { &agent_kdl("cos-claude", "hetz"), ); - // Resolve the recipient's agent folder by its bus id, then by bare identity — both must match. - let dir_by_bus = resolve_agent_dir(root, "hetz.st2-claude", "hetz") + // Resolve by the subject's exact immutable ID. For an unmigrated declaration that ID is + // exactly its `.` bytes, which is why migration moves no state. + let dir_by_id = resolve_agent_dir(root, "hetz.st2-claude", "hetz") .unwrap() - .expect("resolve by bus id"); - let dir_by_ident = resolve_agent_dir(root, "st2-claude", "hetz") + .expect("resolve by immutable id"); + // The same subject as an ordinary human reference, through the address algorithm. + let dir_by_address = select_agent_dir(root, &AgentSelector::address("st2-claude"), "hetz") .unwrap() - .expect("resolve by identity"); - assert_eq!(dir_by_bus, dir_by_ident); - assert_eq!(dir_by_bus, root.join("hetz/st2-claude")); + .expect("resolve by address"); + assert_eq!(dir_by_id, dir_by_address); + assert_eq!(dir_by_id, root.join("hetz/st2-claude")); + let dir_by_bus = dir_by_id; // Send lands under the recipient's resources/inbox — and nowhere near the other agent. let inbox = inbox_dir(&dir_by_bus); @@ -292,3 +313,165 @@ fn a_concurrent_reader_never_observes_a_half_written_message() { "reader observed a canonical message before it was complete" ); } + +/// The ordinary reference algorithm, end to end: a bare address, a host-qualified bus address, and +/// a reference that names two distinct subjects, which must fail closed rather than pick one. +#[test] +fn an_ordinary_reference_resolves_by_address_and_fails_closed_when_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "hetz/worker/agent.kdl", &agent_kdl("worker", "hetz")); + write(root, "dev4/worker/agent.kdl", &agent_kdl("worker", "dev4")); + write(root, "hetz/solo/agent.kdl", &agent_kdl("solo", "hetz")); + + // A bare address unique across the catalog resolves. + assert_eq!( + select_agent_dir(root, &AgentSelector::address("solo"), "hetz") + .unwrap() + .expect("a unique bare address resolves"), + root.join("hetz/solo") + ); + + // A host-qualified reference picks exactly that host's subject, including a remote one. + assert_eq!( + select_agent_dir(root, &AgentSelector::address("dev4.worker"), "hetz") + .unwrap() + .expect("a qualified bus address resolves"), + root.join("dev4/worker") + ); + + // Pinning the caller's host disambiguates the same bare address. + assert_eq!( + select_agent_dir(root, &AgentSelector::address_on_host("worker", "hetz"), "hetz") + .unwrap() + .expect("a host-pinned address resolves"), + root.join("hetz/worker") + ); + + // Unpinned, `worker` names two distinct subjects. Silently picking one would deliver a + // message to the wrong agent, so this is an error and not an absent subject. + let error = select_agent_dir(root, &AgentSelector::address("worker"), "hetz") + .expect_err("an ambiguous reference must fail closed"); + let rendered = format!("{error}"); + assert!(rendered.contains("dev4.worker"), "{rendered}"); + assert!(rendered.contains("hetz.worker"), "{rendered}"); +} + +/// ID and address are separate typed namespaces. An exact-ID selection must never fall through to +/// address lookup, and an ordinary reference must never reach the ID namespace. +#[test] +fn an_exact_id_selection_never_falls_through_to_address_lookup() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "hetz/worker/agent.kdl", + &migrated_agent_kdl("worker", "hetz", "worker-uuid", "chat"), + ); + + assert_eq!( + resolve_agent_dir(root, "worker-uuid", "hetz") + .unwrap() + .expect("the exact immutable ID resolves"), + root.join("hetz/worker") + ); + // The subject's current route is `hetz.chat`; asking for it as an ID must not find it. + assert!(resolve_agent_dir(root, "chat", "hetz").unwrap().is_none()); + assert!( + resolve_agent_dir(root, "hetz.chat", "hetz") + .unwrap() + .is_none() + ); + // And the ID is not a route: an address lookup must not reach the ID namespace. + assert!( + select_agent_dir(root, &AgentSelector::address("worker-uuid"), "hetz") + .unwrap() + .is_none() + ); + assert_eq!( + select_agent_dir(root, &AgentSelector::address("chat"), "hetz") + .unwrap() + .expect("the address resolves as an address"), + root.join("hetz/worker") + ); +} + +/// An address change is an immediate atomic cutover of the ROUTE only. Durable state does not +/// move: the inbox, the archive, and every reply that targets the persisted endpoint still land +/// in the same boxes, and the old route stops resolving with no alias or redirect left behind. +#[test] +fn an_address_change_preserves_inbox_archive_and_reply_targeting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let worker = root.join("hetz/worker"); + write( + root, + "hetz/sender/agent.kdl", + &migrated_agent_kdl("sender", "hetz", "sender-uuid", "sender"), + ); + write( + root, + "hetz/worker/agent.kdl", + &migrated_agent_kdl("worker", "hetz", "worker-uuid", "chat"), + ); + + // Send by the recipient's current route. + let first = send_to_resolved_inbox( + root, "chat", "hetz", "sender-uuid", Some("before"), None, &[], "one", None, None, + ) + .unwrap(); + assert!(inbox_dir(&worker).join(&first).exists()); + archive_msg(&inbox_dir(&worker), &archive_dir(&worker), &first).unwrap(); + + // Cut the address over. Nothing else about the declaration changes. + write( + root, + "hetz/worker/agent.kdl", + &migrated_agent_kdl("worker", "hetz", "worker-uuid", "renamed"), + ); + + // Durable state stayed exactly where it was — the archive receipt is still the subject's. + assert_eq!( + resolve_agent_dir(root, "worker-uuid", "hetz") + .unwrap() + .expect("the ID still resolves after the cutover"), + worker + ); + let archived = list_dir(&archive_dir(&worker)).unwrap(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].filename, first); + + // The released address is gone immediately: no alias, no redirect, no history. + assert!( + select_agent_dir(root, &AgentSelector::address("chat"), "hetz") + .unwrap() + .is_none() + ); + + // A reply targeting the persisted canonical endpoint still lands in the same inbox, and so + // does a send by the new route. + let reply = send_to_inbox( + &inbox_dir(&worker), + "hetz.sender", + Some("re: before"), + Some(&first), + &[], + "two", + ) + .unwrap(); + let second = send_to_resolved_inbox( + root, "renamed", "hetz", "sender-uuid", Some("after"), None, &[], "three", None, None, + ) + .unwrap(); + + let inbox = list_dir(&inbox_dir(&worker)).unwrap(); + let mut names = inbox.iter().map(|m| m.filename.clone()).collect::>(); + names.sort(); + let mut expected = vec![reply, second]; + expected.sort(); + assert_eq!(names, expected); + assert_eq!( + read_msg(&inbox_dir(&worker), &expected[0]).unwrap().ts_ms > 0, + true + ); +} diff --git a/tests/message_cli.rs b/tests/message_cli.rs index 545d7cda..1a722fbf 100644 --- a/tests/message_cli.rs +++ b/tests/message_cli.rs @@ -79,6 +79,120 @@ fn sent(root: &Path, identity: &str, extra: &[&str]) -> std::process::Output { .unwrap() } +/// Declare a migrated subject: an explicit immutable `id` plus a mutable `address`. +fn write_migrated_agent(root: &Path, identity: &str, id: &str, address: &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 id \"{id}\"\n address \"{address}\"\n host \"h\"\n type \"service\"\n pty \"agent\" {{ command \"x\" }}\n}}\n" + ), + ) + .unwrap(); +} + +fn reply(root: &Path, as_reference: &str, filename: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "reply", filename, "--root"]) + .arg(root) + .args(["--host", "h", "--as", as_reference, "-m", "answer"]) + .output() + .unwrap() +} + +fn inbox_filenames(root: &Path, identity: &str) -> Vec { + let inbox = root.join("h").join(identity).join("resources/inbox"); + let Ok(entries) = fs::read_dir(&inbox) else { + return Vec::new(); + }; + let mut names = entries + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .filter(|name| name.ends_with(".md")) + .collect::>(); + names.sort(); + names +} + +/// A reply is addressed by the sender's immutable `from-id`, never by the mutable `from` route. +/// Here the original sender's address is released and immediately taken by a different subject: +/// routing the reply by `from` would deliver a private answer to that impostor. +#[test] +fn reply_routes_by_the_senders_immutable_id_after_its_address_is_reused() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_migrated_agent(root, "writer", "writer-id", "helper"); + write_migrated_agent(root, "reader", "reader-id", "reader"); + + let sent = send_message(root, "helper", "reader", "question", &[]); + assert!( + sent.status.success(), + "{}", + String::from_utf8_lossy(&sent.stderr) + ); + let original = String::from_utf8(sent.stdout).unwrap().trim().to_owned(); + + // The row records the authoritative sender ID alongside the display-only route. + let read = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "read", &original, "--root"]) + .arg(root) + .args(["--host", "h", "--as", "reader", "--json"]) + .output() + .unwrap(); + assert!( + read.status.success(), + "{}", + String::from_utf8_lossy(&read.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&read.stdout).unwrap(); + assert_eq!(value["fromId"], "writer-id"); + assert_eq!(value["from"], "h.helper"); + + // The writer renames, and an unrelated subject takes the released address. + write_migrated_agent(root, "writer", "writer-id", "writer"); + write_migrated_agent(root, "impostor", "impostor-id", "helper"); + + let replied = reply(root, "reader", &original); + assert!( + replied.status.success(), + "{}", + String::from_utf8_lossy(&replied.stderr) + ); + let delivered = String::from_utf8(replied.stdout).unwrap().trim().to_owned(); + + assert_eq!( + inbox_filenames(root, "writer"), + vec![delivered], + "the reply must reach the subject that actually wrote the message" + ); + assert!( + inbox_filenames(root, "impostor").is_empty(), + "the subject that merely took the released address must receive nothing" + ); +} + +/// A row with no authoritative sender is refused outright: there is no route to fall back to, +/// because those bytes may already name a different subject. +#[test] +fn reply_refuses_a_row_with_no_authoritative_sender() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_migrated_agent(root, "reader", "reader-id", "reader"); + let inbox = root.join("h/reader/resources/inbox"); + fs::create_dir_all(&inbox).unwrap(); + let filename = "1785000000000-aaaaaa.md"; + fs::write(inbox.join(filename), "---\nsubject: anonymous\n---\nbody\n").unwrap(); + + let refused = reply(root, "reader", filename); + + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("no authoritative sender to reply to"), + "{stderr}" + ); +} + #[test] fn send_rejects_empty_explicit_and_stdin_bodies_without_persisting_a_message() { for body_arg in [Some(""), None] { @@ -468,7 +582,12 @@ fn sent_ledger_fails_closed_when_head_nodes_or_rows_are_lost_substituted_or_inva "payload-filename", "corrupt-row", "unreadable-row", - "row-version", + // Records are written at version 2. An unknown future version, a downgrade that leaves + // version-2 endpoint fields behind, and a version-2 row missing its endpoint kind are all + // undecidable endpoint shapes, so each must fail closed rather than be tolerated. + "row-version-unsupported", + "row-version-downgrade", + "row-version-missing-kind", ] { let tmp = prepare(); let messages = tmp.path().join("h/sender/resources/sent/messages"); @@ -501,10 +620,18 @@ fn sent_ledger_fails_closed_when_head_nodes_or_rows_are_lost_substituted_or_inva fs::remove_file(&row).unwrap(); fs::create_dir(&row).unwrap(); } - "row-version" => { + "row-version-unsupported" | "row-version-downgrade" | "row-version-missing-kind" => { let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&row).unwrap()).unwrap(); - value["version"] = 2.into(); + assert_eq!(value["version"], 2, "records are written at version 2"); + assert!(value["fromKind"].is_string() && value["toKind"].is_string()); + match mutate { + "row-version-unsupported" => value["version"] = 3.into(), + "row-version-downgrade" => value["version"] = 1.into(), + _ => { + value.as_object_mut().unwrap().remove("fromKind"); + } + } fs::write(&row, serde_json::to_vec(&value).unwrap()).unwrap(); } _ => unreachable!(), diff --git a/tests/nomad_survival.rs b/tests/nomad_survival.rs index 876f9631..4b10d248 100644 --- a/tests/nomad_survival.rs +++ b/tests/nomad_survival.rs @@ -721,6 +721,7 @@ fn presentation_changes_patch_the_exact_live_pty_without_restarting_it() { "--catalog", fx.catalog.to_str().unwrap(), command, + "--id", &session_id, value, ]) @@ -792,6 +793,7 @@ fn presentation_changes_patch_the_exact_live_pty_without_restarting_it() { "--catalog", fx.catalog.to_str().unwrap(), command, + "--id", &session_id, "--clear", ]) diff --git a/tests/predecessor_ding_migration.rs b/tests/predecessor_ding_migration.rs index 63992e23..a9230ba5 100644 --- a/tests/predecessor_ding_migration.rs +++ b/tests/predecessor_ding_migration.rs @@ -435,7 +435,7 @@ fn predecessor_drains_only_legacy_ding_then_candidate_adopts_provider_and_replac [ "/usr/bin/sh", "-c", - "st2 ding --identity h.provider --root $ST_ROOT" + "st2 ding --id h.provider --root $ST_ROOT" ], "candidate launched a non-canonical derived Ding command" ); @@ -516,9 +516,10 @@ fn assert_migration_catalog_safe(catalog: &Path, expected_ids: &[&str]) -> Resul )); } let task = &spec.tasks[0]; - let bus = spec.bus_id(HOST); - let expected_id = format!("{bus}.ding"); - let expected_command = format!("st2 ding --identity {bus} --root $ST_ROOT"); + // The derived Ding companion is keyed by the immutable agent ID, never by a route. + let agent_id = spec.agent_id(HOST); + let expected_id = format!("{agent_id}.ding"); + let expected_command = format!("st2 ding --id {agent_id} --root $ST_ROOT"); if task.kind != TaskKind::Exec || !task.derived || task.name != "ding" diff --git a/tests/reconcile.rs b/tests/reconcile.rs index 100435d5..255d5235 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -418,7 +418,10 @@ fn spec( tasks: Vec, ) -> AgentSpec { AgentSpec { + // An unmigrated legacy declaration: no explicit `id`, no explicit `address`. + id: None, identity: identity.to_string(), + address: None, name: None, description: None, host: host.map(String::from), @@ -638,11 +641,13 @@ fn live_pty_presentation_is_exact_id_metadata_and_not_lifecycle_drift() { assert_eq!( primary.tags, BTreeMap::from([ - ("agent.presentation.schema".to_owned(), Some("1".to_owned())), + ("agent.presentation.schema".to_owned(), Some("2".to_owned())), + ("agent.subject.id".to_owned(), Some("hetz.worker".to_owned())), ( - "agent.actor.path".to_owned(), + "agent.subject.address".to_owned(), Some("hetz.worker".to_owned()) ), + ("agent.actor.path".to_owned(), None), ( "agent.presentation.description".to_owned(), Some("Owns build delivery".to_owned()), @@ -659,11 +664,13 @@ fn live_pty_presentation_is_exact_id_metadata_and_not_lifecycle_drift() { assert_eq!( secondary.tags, BTreeMap::from([ - ("agent.presentation.schema".to_owned(), Some("1".to_owned())), + ("agent.presentation.schema".to_owned(), Some("2".to_owned())), + ("agent.subject.id".to_owned(), Some("hetz.worker".to_owned())), ( - "agent.actor.path".to_owned(), - Some("hetz.worker".to_owned()), + "agent.subject.address".to_owned(), + Some("hetz.worker".to_owned()) ), + ("agent.actor.path".to_owned(), None), ( "agent.presentation.description".to_owned(), Some("Owns build delivery".to_owned()), @@ -709,8 +716,9 @@ fn live_pty_presentation_only_queues_observed_drift() { owner.description = Some("Owns build delivery".to_owned()); let specs = [owner]; let exact_tags = BTreeMap::from([ - ("agent.presentation.schema".to_owned(), "1".to_owned()), - ("agent.actor.path".to_owned(), "hetz.worker".to_owned()), + ("agent.presentation.schema".to_owned(), "2".to_owned()), + ("agent.subject.id".to_owned(), "hetz.worker".to_owned()), + ("agent.subject.address".to_owned(), "hetz.worker".to_owned()), ( "agent.presentation.description".to_owned(), "Owns build delivery".to_owned(), @@ -969,7 +977,7 @@ fn generated_ding_only_job_is_unrunnable_and_does_not_launch() { TaskKind::Exec, "ding", Some("hetz.nr.ding"), - Some("st2 ding --identity hetz.nr --root $ST_ROOT"), + Some("st2 ding --id hetz.nr --root $ST_ROOT"), ); ding.derived = true; let specs = vec![svc("nr", Some(HOST), vec![ding])]; @@ -985,7 +993,7 @@ fn generated_ding_launches_alongside_authored_work() { TaskKind::Exec, "ding", Some("hetz.runnable.ding"), - Some("st2 ding --identity hetz.runnable --root $ST_ROOT"), + Some("st2 ding --id hetz.runnable --root $ST_ROOT"), ); ding.derived = true; let specs = vec![svc("runnable", Some(HOST), vec![agent, ding])]; diff --git a/tests/request_cli.rs b/tests/request_cli.rs index 87369a74..98bbed04 100644 --- a/tests/request_cli.rs +++ b/tests/request_cli.rs @@ -188,10 +188,59 @@ fn bare_request_recipient_does_not_resolve_an_agent_on_another_host() { ); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("no agent 'worker' found")); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("no routable agent answers address 'worker'"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); assert!(!tmp.path().join("remote/worker/resources/inbox").exists()); } +#[test] +fn a_duplicate_agent_id_refuses_the_address_path_instead_of_publishing_into_one_of_them() { + // The address book dedups its candidates BY agent ID, so an address naming one of two + // subjects that share an explicit `id` resolves to exactly one Subject; only the back-mapping + // to a declaration can catch it. Publishing first-match would write the request into the + // wrong subject's inbox. + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "h/worker/agent.kdl", + "agent \"worker\" {\n identity \"worker\"\n host \"h\"\n id \"shared-id\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", + ); + write( + tmp.path(), + "h/spare/agent.kdl", + "agent \"spare\" {\n identity \"spare\"\n host \"h\"\n id \"shared-id\"\n type \"service\"\n pty \"agent\" { command \"x\" }\n}\n", + ); + declare_principal(tmp.path(), "example-ci"); + + let output = request( + tmp.path(), + &[ + "send", + "h.worker", + "--as", + "h.example-ci", + "--idempotency-key", + "one", + "-m", + "{}", + "--json", + ], + ); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("declared by more than one subject"), + "a duplicate-id catalog must refuse the request: {stderr}" + ); + assert!(!tmp.path().join("h/worker/resources/inbox").exists()); + assert!(!tmp.path().join("h/spare/resources/inbox").exists()); +} + #[test] fn malformed_or_misplaced_principal_declarations_fail_before_publication() { for declaration in [ diff --git a/tests/resync.rs b/tests/resync.rs index 81a27e3d..04b3cf2d 100644 --- a/tests/resync.rs +++ b/tests/resync.rs @@ -41,6 +41,27 @@ fn write_agent_with_goal_scheme(root: &Path) -> PathBuf { dir } +/// A migrated subject: an explicit immutable `id` plus a mutable `address`. +fn write_migrated_agent(root: &Path, address: &str) -> PathBuf { + let dir = root.join("agents/hetz/worker"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("agent.kdl"), + format!( + r#"agent "worker" {{ + id "worker-uuid" + address "{address}" + host "hetz" + command "agent" + resource "goal" uri="resources/goal.md" reason="Mission." +}}"# + ), + ) + .unwrap(); + st2::event::publish_owner_binding_for_test(root, "hetz").unwrap(); + dir +} + fn resync_events(agent_dir: &Path) -> Vec { let inbox = agent_dir.join("resources/inbox"); let mut subjects = Vec::new(); @@ -107,7 +128,7 @@ fn carrier_change_emits_one_superseded_resync_event_and_silent_stores_stay_quiet let error = st2::event::emit( catalog.path(), "hetz", - "hetz.worker", + &st2::AgentSelector::address("hetz.worker"), "resync", "forged-resync", Some("goal"), @@ -335,3 +356,51 @@ fn declared_profile_class_governs_and_resolver_failures_stay_contained() { .try_resolve(broken_dir.parent().unwrap(), "dev.schickling.agent-goal://hetz/w") .is_err()); } + +/// A resync subscription is ownership, so it keys on the immutable agent ID. Changing the +/// subject's address is an immediate cutover of its human route and nothing else: the same watch +/// set stays installed under the same key, and the event still lands in the same agent's inbox. +#[test] +fn an_address_change_keeps_the_subscription_and_its_delivery_target() { + let catalog = tempfile::tempdir().unwrap(); + let agent_dir = write_migrated_agent(catalog.path(), "chat"); + let registry = Default::default(); + + let before = st2::discover_strict(catalog.path()).specs; + assert_eq!( + st2::resync::watch_set_for(&before[0], "hetz", ®istry).agent_id, + "worker-uuid", + "the subscription key is the immutable ID, never the route" + ); + + let supervisor = + st2::resync::ResyncSupervisor::spawn(catalog.path().to_path_buf(), "hetz".to_owned()); + assert!( + supervisor + .refresh(&before, &before, "hetz", &[], &[]) + .is_empty() + ); + + // Cut the address over, then re-refresh from the new catalog generation. + write_migrated_agent(catalog.path(), "renamed"); + let after = st2::discover_strict(catalog.path()).specs; + assert_eq!( + st2::resync::watch_set_for(&after[0], "hetz", ®istry).agent_id, + "worker-uuid", + "an address change must not move the subscription to a new key" + ); + assert!( + supervisor + .refresh(&after, &after, "hetz", &[], &[]) + .is_empty() + ); + + // A carrier change after the cutover still reaches this subject's own inbox. + let goal = agent_dir.join("resources/goal.md"); + fs::create_dir_all(goal.parent().unwrap()).unwrap(); + fs::write(&goal, "mission changed\n").unwrap(); + assert!( + wait_for(|| resync_events(&agent_dir).len(), 1), + "the resync event must still be delivered after the address cutover" + ); +} diff --git a/tests/run.rs b/tests/run.rs index 67a84417..7615e5dd 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -8,8 +8,8 @@ use std::time::Instant; use st2::message; use st2::reconcile::{ - Launch, PtyPresentation, ReconcilePlan, Session, TaskCompileContext, TaskLaunch, TaskTarget, - Teardown, compile_generated_tasks, + Launch, PtyPresentation, ReconcilePlan, Session, SupervisorTarget, TaskCompileContext, + TaskLaunch, TaskTarget, Teardown, compile_generated_tasks, }; use st2::run::Runner; use st2::run::{CrashLoop, surface_crash_loop, up_once_selected, up_once_selected_specs}; @@ -226,6 +226,8 @@ fn selected_one_shot_unknown_refuses_before_runner_list() { fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { AgentSpec { + id: None, + address: None, identity: identity.into(), name: None, description: None, @@ -607,7 +609,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_id: "host.owner".to_owned(), + bus_address: "host.owner".to_owned(), name: "work".to_owned(), derived: false, launch: TaskLaunch::Shell("true".to_owned()), @@ -631,6 +634,7 @@ fn lifecycle_work_precedes_a_bounded_presentation_batch() { spec: &spec, tasks: vec![target], live_derived: Vec::new(), + supervisor: st2::reconcile::SupervisorTarget::Undeclared, }], teardown: vec![Teardown { spec: &spec, @@ -772,11 +776,31 @@ fn runner_owned_identity_metadata_is_form_equivalent_and_role_scoped() { assert_eq!(compact_agent.presentation, explicit_agent.presentation); assert_eq!(compact_agent.tags, explicit_agent.tags); + // Schema 2: the subject is named by immutable ID, the current route is a separate owned tag, + // and schema 1's `agent.actor.path` route alias is explicitly deleted on every managed PTY. + // `agent.actor.id` names the external actor and is never in an st2-owned snapshot. let primary_tags = &compact_agent.presentation.as_ref().unwrap().tags; assert_eq!( - primary_tags.get("agent.actor.path"), + primary_tags.get("agent.presentation.schema"), + Some(&Some("2".to_owned())) + ); + assert_eq!( + primary_tags.get("agent.subject.id"), Some(&Some("hetz.demo".to_owned())) ); + assert_eq!( + primary_tags.get("agent.subject.address"), + Some(&Some("hetz.demo".to_owned())) + ); + assert_eq!( + primary_tags.get("agent.actor.path"), + Some(&None), + "the schema-1 route alias is deleted, not carried" + ); + assert!( + !primary_tags.contains_key("agent.actor.id"), + "the external actor tag is not st2-owned: {primary_tags:?}" + ); assert_eq!(primary_tags.get("role"), Some(&Some("agent".to_owned()))); assert!(!primary_tags.contains_key("run.role")); @@ -786,9 +810,11 @@ fn runner_owned_identity_metadata_is_form_equivalent_and_role_scoped() { .unwrap(); let secondary_tags = &secondary.presentation.as_ref().unwrap().tags; assert_eq!( - secondary_tags.get("agent.actor.path"), - Some(&Some("hetz.demo".to_owned())) + secondary_tags.get("agent.subject.id"), + Some(&Some("hetz.demo".to_owned())), + "a secondary PTY belongs to the same immutable subject" ); + assert_eq!(secondary_tags.get("agent.actor.path"), Some(&None)); assert_eq!(secondary_tags.get("role"), Some(&None)); assert!(!secondary_tags.contains_key("run.role")); @@ -981,7 +1007,8 @@ fn fresh_compact_agent_launches_with_its_derived_ding() { &TaskLaunch::Argv(vec![ std::env::current_exe().unwrap().display().to_string(), "ding".into(), - "--identity".into(), + // DING receives an exact-ID selector, never a mutable route. + "--id".into(), "hetz.demo".into(), "--root".into(), tmp.path().display().to_string(), @@ -1417,9 +1444,16 @@ fn flapping_cap_parks_a_fail_mode_task_that_keeps_dying() { "agents/hetz/demo/agent.toml", "identity=\"demo\"\nsupervisor=\"cos-claude\"\n[restart]\nattempts=3\ninterval=\"60s\"\nmode=\"fail\"\n[pty.agent]\nid=\"hetz.demo-claude\"\ncommand=\"x\"\n", ); + // The supervisor is named by a bare ADDRESS. Its declaration is unmigrated, so its immutable + // ID is `hetz.cos-claude` — different bytes from the reference the child authored. + write( + tmp.path(), + "agents/hetz/cos-claude/agent.toml", + "identity=\"cos-claude\"\n[pty.agent]\nid=\"hetz.cos\"\ncommand=\"x\"\n", + ); let found = discover(tmp.path()); let runner = FakeRunner { - sessions: vec![dead("hetz.demo-claude")], + sessions: vec![dead("hetz.demo-claude"), live("hetz.cos")], ..Default::default() }; let mut cap = FlappingCap::default(); @@ -1445,9 +1479,12 @@ fn flapping_cap_parks_a_fail_mode_task_that_keeps_dying() { assert_eq!(last.crash_loops.len(), 1); let cl = &last.crash_loops[0]; assert_eq!(cl.pty_id, "hetz.demo-claude"); - assert_eq!(cl.identity, "demo"); - assert_eq!(cl.supervisor.as_deref(), Some("cos-claude")); - assert_eq!(cl.agent_bus_id("hetz"), "hetz.demo"); + assert_eq!(cl.agent_id, "hetz.demo"); + assert_eq!( + cl.supervisor, + SupervisorTarget::Resolved("hetz.cos-claude".to_owned()), + "the authored address must already be resolved to the parent's immutable ID" + ); } #[test] @@ -1688,9 +1725,8 @@ fn surface_crash_loop_notifies_the_supervisor_over_the_bus() { let cl = CrashLoop { pty_id: "hetz.demo-claude".to_string(), - identity: "demo".to_string(), - host: Some("hetz".to_string()), - supervisor: Some("cos-claude".to_string()), + agent_id: "hetz.demo".to_string(), + supervisor: SupervisorTarget::Resolved("hetz.cos-claude".to_string()), }; surface_crash_loop(tmp.path(), "hetz", &cl); @@ -1711,6 +1747,81 @@ fn surface_crash_loop_notifies_the_supervisor_over_the_bus() { ); } + +/// Equal bytes across the two namespaces must not let the wrong subject receive the alert. +/// +/// The child is UNMIGRATED, so its `supervisor "boss"` is a positional ADDRESS. A third, +/// unrelated subject has been migrated with the immutable ID `boss` and holds no such address. +/// An ID-first-then-address resolution answers with the impostor and the real parent never hears +/// about the park; the child's own migration state is what makes the namespace unambiguous. +#[test] +fn a_legacy_supervisor_address_is_not_captured_by_an_equal_byte_agent_id() { + let tmp = tempfile::tempdir().unwrap(); + // The impostor: immutable ID is literally `boss`, address is something else entirely. + write( + tmp.path(), + "agents/hetz/impostor/agent.kdl", + "agent \"impostor\" { id \"boss\"; host \"hetz\"; address \"unrelated.impostor\"; \ + pty \"agent\" { id \"hetz.impostor-seat\"; command \"true\" } }\n", + ); + // The real parent: unmigrated, so its effective address is its identity `boss` and its frozen + // immutable ID is `hetz.boss`. + write( + tmp.path(), + "agents/hetz/boss/agent.toml", + "identity=\"boss\"\n[pty.agent]\nid=\"hetz.boss-seat\"\ncommand=\"true\"\n", + ); + write( + tmp.path(), + "agents/hetz/demo/agent.toml", + "identity=\"demo\"\nsupervisor=\"boss\"\n[restart]\nattempts=1\ninterval=\"60s\"\nmode=\"fail\"\n[pty.agent]\nid=\"hetz.demo-claude\"\ncommand=\"x\"\n", + ); + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let runner = FakeRunner { + sessions: vec![ + dead("hetz.demo-claude"), + live("hetz.impostor-seat"), + live("hetz.boss-seat"), + ], + ..Default::default() + }; + let mut cap = FlappingCap::default(); + + let mut last = UpReport::default(); + for _ in 0..3 { + let plan = reconcile(&found.specs, &runner.sessions, "hetz"); + last = UpReport::default(); + execute(&plan, &runner, &mut cap, &mut last); + } + + assert_eq!(last.crash_loops.len(), 1); + let cl = &last.crash_loops[0]; + assert_eq!( + cl.supervisor, + SupervisorTarget::Resolved("hetz.boss".to_owned()), + "the address `boss` belongs to the real parent; the subject whose ID is `boss` must not \ + capture the edge" + ); + + surface_crash_loop(tmp.path(), "hetz", cl); + + let real = message::inbox_dir(&tmp.path().join("agents/hetz/boss")); + let msgs = message::list_dir(&real).unwrap(); + assert_eq!( + msgs.len(), + 1, + "the real parent must receive the crash-loop notice" + ); + assert!(msgs[0].body.contains("hetz.demo-claude")); + + let impostor = message::inbox_dir(&tmp.path().join("agents/hetz/impostor")); + assert!( + message::list_dir(&impostor).map_or(true, |messages| messages.is_empty()), + "the equal-byte ID holder must receive nothing" + ); +} + /// A park whose cause is structural must not advise `st2 unpark`. /// /// `unpark` relaunches the task, and a session socket path over the portable `sun_path` bound @@ -1760,9 +1871,8 @@ fn a_structurally_unrecoverable_park_does_not_advise_unpark() { "hetz", &CrashLoop { pty_id: pty_id.to_string(), - identity: "demo".to_string(), - host: Some("hetz".to_string()), - supervisor: Some("cos".to_string()), + agent_id: "hetz.demo".to_string(), + supervisor: SupervisorTarget::Resolved("hetz.cos".to_string()), }, ); let mut fresh = message::list_dir(&inbox) @@ -1847,14 +1957,45 @@ fn surface_crash_loop_without_supervisor_sends_nothing() { ); let cl = CrashLoop { pty_id: "hetz.demo-claude".to_string(), - identity: "demo".to_string(), - host: Some("hetz".to_string()), - supervisor: None, + agent_id: "hetz.demo".to_string(), + supervisor: SupervisorTarget::Undeclared, }; // Must not panic; there is simply nobody to notify. surface_crash_loop(tmp.path(), "hetz", &cl); } +/// A `supervisor` reference that named nothing must not be mistaken for "no supervisor": nothing +/// is delivered, but the outcome is a distinct, loud diagnostic rather than a silent success. +#[test] +fn surface_crash_loop_with_an_unresolved_supervisor_sends_nothing() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/hetz/demo/agent.toml", + "identity=\"demo\"\nsupervisor=\"org.gone\"\n[pty.agent]\nid=\"hetz.demo-claude\"\ncommand=\"x\"\n", + ); + write( + tmp.path(), + "agents/hetz/cos/agent.toml", + "identity=\"cos\"\n[pty.agent]\nid=\"hetz.cos\"\ncommand=\"x\"\n", + ); + let cl = CrashLoop { + pty_id: "hetz.demo-claude".to_string(), + agent_id: "hetz.demo".to_string(), + supervisor: SupervisorTarget::Unresolved("org.gone".to_string()), + }; + + surface_crash_loop(tmp.path(), "hetz", &cl); + + // No inbox is guessed at: an unresolved parent means nobody receives the notice. + let inbox = message::inbox_dir(&tmp.path().join("agents/hetz/cos")); + assert!( + // Either spelling of "nothing arrived": no inbox was created, or it is empty. + message::list_dir(&inbox).map_or(true, |messages| messages.is_empty()), + "an unresolvable supervisor must not have the notice delivered to a bystander" + ); +} + #[test] fn up_once_surfaces_discovery_errors_and_unrunnable() { let tmp = tempfile::tempdir().unwrap(); @@ -2109,12 +2250,22 @@ fn a_crash_looping_stream_parks_and_surfaces_without_disturbing_its_agent() { "agents/hetz/demo/agent.kdl", COMPACT_AGENT_WITH_STREAM, ); + // The supervisor edge is resolved when the plan is computed, so the parent has to be in the + // same catalog snapshot. It is declared by ADDRESS (`cos-claude`) while its immutable ID is + // `hetz.cos-claude`, which is exactly the case that used to drop the notice. + write( + tmp.path(), + "agents/hetz/cos-claude/agent.kdl", + "agent \"cos-claude\" { host \"hetz\"; command \"true\" }\n", + ); let found = discover(tmp.path()); let runner = FakeRunner { sessions: vec![ live("hetz.demo"), live("hetz.demo.ding"), dead("hetz.demo.stream-gh-ci"), + // Adopted, so the supervisor's own task contributes no lifecycle op. + live("hetz.cos-claude"), ], ..Default::default() }; @@ -2170,15 +2321,13 @@ fn a_crash_looping_stream_parks_and_surfaces_without_disturbing_its_agent() { assert_eq!(last.crash_loops.len(), 1); let cl = &last.crash_loops[0]; assert_eq!(cl.pty_id, "hetz.demo.stream-gh-ci"); - assert_eq!(cl.identity, "demo"); - assert_eq!(cl.supervisor.as_deref(), Some("cos-claude")); - assert_eq!(cl.agent_bus_id("hetz"), "hetz.demo"); - - write( - tmp.path(), - "agents/hetz/cos-claude/agent.kdl", - "agent \"cos-claude\" { host \"hetz\"; command \"true\" }\n", + assert_eq!(cl.agent_id, "hetz.demo"); + assert_eq!( + cl.supervisor, + SupervisorTarget::Resolved("hetz.cos-claude".to_owned()), + "the authored address is already resolved to the parent's immutable ID" ); + surface_crash_loop(tmp.path(), "hetz", cl); let inbox = message::inbox_dir(&tmp.path().join("agents/hetz/cos-claude")); let msgs = message::list_dir(&inbox).unwrap(); diff --git a/tests/status_agents.rs b/tests/status_agents.rs index c4f6bd36..58c070ec 100644 --- a/tests/status_agents.rs +++ b/tests/status_agents.rs @@ -71,6 +71,59 @@ fn roster_keeps_presence_separate_from_suspended_desired_state() { ); } +/// R24: the roster carries three separate values per row. `identity` keeps its original meaning — +/// the positional `.` declaration key — while `id` is the immutable subject and +/// `bus_address` is the mutable route, absent entirely for a proved non-routable retired subject. +#[test] +fn roster_projects_immutable_id_effective_address_and_nullable_route() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "h/worker/agent.kdl", + &agent_kdl("worker", "h").replace( + " type \"service\"\n", + " type \"service\"\n id \"worker-id\"\n address \"fleet.builder\"\n", + ), + ); + write( + root, + "h/gone/agent.kdl", + &retired_agent_kdl("gone", "h").replace( + " type \"service\"\n", + " type \"service\"\n id \"gone-id\"\n", + ), + ); + write(root, "h/plain/agent.kdl", &agent_kdl("plain", "h")); + + let rows = roster(root, "h"); + let find = |identity: &str| { + rows.iter() + .find(|row| row.identity == identity) + .unwrap_or_else(|| panic!("no roster row for {identity}")) + }; + + let worker = find("h.worker"); + assert_eq!(worker.id, "worker-id"); + assert_eq!(worker.address, "fleet.builder"); + assert_eq!(worker.bus_address.as_deref(), Some("h.fleet.builder")); + + // An unmigrated declaration's ID is exactly its positional key, which is why migration moves + // no durable state: the two values are the same bytes by construction. + let plain = find("h.plain"); + assert_eq!(plain.id, "h.plain"); + assert_eq!(plain.address, "plain"); + assert_eq!(plain.bus_address.as_deref(), Some("h.plain")); + + let gone = find("h.gone"); + assert_eq!(gone.id, "gone-id", "retirement preserves the ID"); + assert_eq!(gone.address, "gone"); + assert!( + gone.bus_address.is_none(), + "a retired subject releases its address" + ); +} + /// The roster enumerates every catalog agent by bus id (sorted), projects each one's presence, and — /// with enrich data — its inbox count and last-activity. #[test] @@ -249,9 +302,13 @@ fn roster_json_and_human_output_distinguish_retirement_from_presence() { .output() .unwrap(); assert!(!absent.status.success()); + // An ordinary reference that names nobody now refuses with an address-specific diagnostic: + // absence is a routing answer, not a row count. assert!( String::from_utf8_lossy(&absent.stderr) - .contains("expected exactly one Agent Spec with identity `h.missing`, found 0") + .contains("no routable agent answers address 'h.missing' on host 'h'"), + "{}", + String::from_utf8_lossy(&absent.stderr) ); let filtered_out = Command::new(env!("CARGO_BIN_EXE_st2")) @@ -327,9 +384,46 @@ fn exact_identity_rejects_duplicates_before_status_filtering() { .output() .unwrap(); assert!(!selected.status.success()); + // Two declarations of one subject share one agent ID, so the address resolves to a single + // subject and the refusal comes from mapping that subject back to a declaration: a broken + // global ID namespace is refused before any row is selected or filtered. assert!( String::from_utf8_lossy(&selected.stderr) - .contains("expected exactly one Agent Spec with identity `h.worker`, found 2") + .contains("agent id 'h.worker' is declared by more than one subject"), + "{}", + String::from_utf8_lossy(&selected.stderr) + ); +} + +#[test] +fn an_address_resolved_in_a_duplicate_id_catalog_refuses_instead_of_picking_a_declaration() { + // `resolve_address` dedups its candidates BY agent ID, so an address naming one of two + // subjects that share an explicit `id` resolves cleanly to a single Subject. Mapping that + // Subject back to a declaration is therefore where the address path proves ID uniqueness: a + // first-match scan would act on whichever declaration discovery happened to order first. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "declarations/one/agent.kdl", + &agent_kdl("worker", "h").replace(" type \"service\"\n", " type \"service\"\n id \"shared-id\"\n"), + ); + write( + root, + "declarations/two/agent.kdl", + &agent_kdl("spare", "h").replace(" type \"service\"\n", " type \"service\"\n id \"shared-id\"\n"), + ); + + let refused = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["status", "h.worker", "--set", "available"]) + .args(["--root", root.to_str().unwrap(), "--host", "h"]) + .output() + .unwrap(); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("declared by more than one subject"), + "a duplicate-id catalog must refuse the address path: {stderr}" ); } @@ -463,12 +557,14 @@ fn roster_joins_a_real_context_record_independently_of_observed_state() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); + // `harness_context::Writer` derives its control-plane staging directory from the exact + // canonical `/agents//` shape, so this fixture must use it. write( root, - "hetz/filling/agent.kdl", + "agents/hetz/filling/agent.kdl", &agent_kdl("filling", "hetz"), ); - let agent_dir = root.join("hetz/filling"); + let agent_dir = root.join("agents/hetz/filling"); set_state(&status_path(&agent_dir), State::Busy).unwrap(); // No record: the axis is emitted as `null`, not omitted. diff --git a/tests/task_inventory_cli.rs b/tests/task_inventory_cli.rs index 328e2c77..a531bacc 100644 --- a/tests/task_inventory_cli.rs +++ b/tests/task_inventory_cli.rs @@ -201,6 +201,9 @@ fn tasks_cli_emits_stable_complete_generation_without_mutation() { assert_eq!(value["complete"], true); assert_eq!(value["tasks"].as_array().unwrap().len(), 1); assert_eq!(value["tasks"][0]["agent"], "h.worker"); + // The route travels beside the ID on the wire. For this unmigrated declaration the two are + // the same bytes; the point is that they are two fields with two meanings. + assert_eq!(value["tasks"][0]["busAddress"], "h.worker"); assert_eq!(value["tasks"][0]["task"], "agent"); assert_eq!(value["tasks"][0]["runtimeId"], "h.worker"); assert_eq!(value["tasks"][0]["desiredState"], "running"); @@ -330,6 +333,9 @@ fn completed_catalog_aba_during_runtime_observation_is_incomplete() { .unwrap() .to_string(); snapshot(&prepared_b); + // The B state only has to be a *different* admitted catalog: `catalog apply` validates fully, + // and retiring the host's sole agent would leave it with no counted root, refusing the apply + // for a reason that has nothing to do with the ABA this test is about. let b_spec = prepared_b.join("agents/h/worker/agent.kdl"); let bytes = fs::read_to_string(&b_spec) .unwrap() @@ -363,6 +369,8 @@ fn completed_catalog_aba_during_runtime_observation_is_incomplete() { std::thread::yield_now(); } + // `catalog apply` binds the exact prepared input it was handed, so the fixture computes that + // digest from the same transaction hash domain rather than restating a literal. let apply = |prepared: &Path, expected: &str| { let input_sha256 = st2::catalog_transaction::digest_prepared(&catalog, prepared) .unwrap() @@ -500,8 +508,9 @@ fn completed_single_agent_writer_abas_during_runtime_observation_are_incomplete( } } else { for args in [ - vec!["rename", "h.worker", "temporary", "--host", "h"], - vec!["rename", "h.worker", "--clear", "--host", "h"], + // `rename` takes the subject by exact immutable ID, never by a route. + vec!["rename", "--id", "h.worker", "temporary", "--host", "h"], + vec!["rename", "--id", "h.worker", "--clear", "--host", "h"], ] { let output = Command::new(env!("CARGO_BIN_EXE_st2")) .arg("--catalog") diff --git a/tests/validate.rs b/tests/validate.rs index 162c92e9..fe970b8d 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -462,6 +462,214 @@ fn a_duplicate_bus_id_is_an_error() { assert!(has(&validate(c.path()), "dup-id", Severity::Error)); } +// ---- R24 uniqueness: two typed namespaces, two admission rules ------------------------------- + +#[test] +fn a_duplicate_explicit_agent_id_is_an_error_even_with_distinct_addresses() { + // Catalog-global ID uniqueness, proved independently of routing: both declarations answer at + // their own address, so an address-only check would pass this catalog. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + "h/one/agent.kdl", + r#"agent "one" { host "h"; id "twin-id"; supervisor "root-id"; command "x" }"#, + ), + ( + "h/two/agent.kdl", + r#"agent "two" { host "h"; id "twin-id"; supervisor "root-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert!( + r.issues.iter().any(|i| i.code == "dup-id" + && i.severity == Severity::Error + && i.message.contains("twin-id")), + "expected a dup-id naming the ID, got {:?}", + r.issues + ); + assert!( + !has(&r, "dup-address", Severity::Error), + "distinct addresses must not also report an address collision: {:?}", + r.issues + ); +} + +#[test] +fn an_explicit_address_colliding_with_an_identity_fallback_is_an_error() { + // The collision the pre-decision model could not see: `beta` claims `alpha`'s effective + // address explicitly, while `alpha` holds it only through the positional identity fallback. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + "h/alpha/agent.kdl", + r#"agent "alpha" { host "h"; id "id-a"; supervisor "root-id"; command "x" }"#, + ), + ( + "h/beta/agent.kdl", + r#"agent "beta" { host "h"; id "id-b"; address "alpha"; supervisor "root-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert!( + r.issues.iter().any(|i| i.code == "dup-address" + && i.severity == Severity::Error + && i.message.contains("alpha") + && i.message.contains("id-a") + && i.message.contains("id-b")), + "expected a dup-address naming both claimants, got {:?}", + r.issues + ); + assert!( + !has(&r, "dup-id", Severity::Error), + "distinct IDs must not be reported as an ID duplicate: {:?}", + r.issues + ); +} + +#[test] +fn an_id_that_shares_bytes_with_another_subjects_address_is_not_a_collision() { + // ID and address are separate typed namespaces: `alpha` carries the ID `beta`, and `beta` + // carries the effective address `beta`. Equal bytes across namespaces never collide. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + "h/alpha/agent.kdl", + r#"agent "alpha" { host "h"; id "beta"; supervisor "root-id"; command "x" }"#, + ), + ( + "h/beta/agent.kdl", + r#"agent "beta" { host "h"; id "id-b"; supervisor "root-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert_eq!(r.errors(), 0, "unexpected errors: {:?}", r.issues); +} + +#[test] +fn a_retired_subject_does_not_occupy_the_address_namespace() { + // Retirement releases the effective address while keeping the ID, so a live subject may claim + // the retired one's route immediately — no alias, no reservation, no waiting period. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + "h/gone/agent.kdl", + r#"agent "gone" { host "h"; id "old-id"; desired-state "retired" reason="Replaced by live"; command "x" }"#, + ), + ( + "h/live/agent.kdl", + r#"agent "live" { host "h"; id "new-id"; address "gone"; supervisor "root-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert_eq!(r.errors(), 0, "unexpected errors: {:?}", r.issues); +} + +#[test] +fn a_live_id_colliding_with_an_archived_id_is_a_duplicate() { + // R24: agent IDs are unique across the live catalog AND the structural archive. An archived + // subject keeps its ID, so a live declaration reusing those bytes denotes the same subject + // twice — and live discovery alone cannot see it, which is why the archive must be read. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + "h/reused/agent.kdl", + r#"agent "reused" { host "h"; id "gone-id"; supervisor "root-id"; command "x" }"#, + ), + ( + ".st2/archive/h/gone/agent.kdl", + r#"agent "gone" { host "h"; id "gone-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert!( + r.issues.iter().any(|i| i.code == "dup-id" + && i.severity == Severity::Error + && i.message.contains("gone-id") + && i.message.contains("structural archive")), + "expected a dup-id naming the archived holder, got {:?}", + r.issues + ); + assert!( + !has(&r, "dup-address", Severity::Error), + "an archived subject released its address and occupies no host namespace: {:?}", + r.issues + ); +} + +#[test] +fn two_archived_subjects_sharing_an_id_are_reported_even_with_no_live_declarant() { + // The conflict is attributed to a live declaration when there is one. When every holder is + // archived there is no declaration path to blame, and dropping the conflict for want of one + // would report zero errors for a catalog whose global ID namespace is provably broken — + // exactly the catalog `catalog apply` and `agent publish` refuse. + let c = catalog(&[ + ( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + ), + ( + ".st2/archive/h/gone/agent.kdl", + r#"agent "gone" { host "h"; id "shared-id"; command "x" }"#, + ), + ( + ".st2/archive/h/also-gone/agent.kdl", + r#"agent "also-gone" { host "h"; id "shared-id"; command "x" }"#, + ), + ]); + + let r = validate(c.path()); + assert!( + r.issues.iter().any(|i| i.code == "dup-id" + && i.severity == Severity::Error + && i.message.contains("shared-id") + && i.message.contains("structural archive")), + "an archived-only duplicate id must still be reported, got {:?}", + r.issues + ); +} + +#[test] +fn an_unreadable_archive_refuses_instead_of_proving_uniqueness() { + // Decision 4: a reader feeding a global uniqueness proof must treat an entry it cannot explain + // as uncertainty. Silently skipping it would hide an occupied ID from the very check that is + // about to declare the namespace free. + let c = catalog(&[( + "h/root/agent.kdl", + r#"agent "root" { host "h"; id "root-id"; command "x" }"#, + )]); + let archived = c.path().join(".st2/archive/h/gone"); + std::fs::create_dir_all(&archived).unwrap(); + std::os::unix::fs::symlink(c.path().join("nowhere.kdl"), archived.join("agent.kdl")).unwrap(); + + let r = validate(c.path()); + assert!( + has(&r, "uniqueness-unprovable", Severity::Error), + "an archive entry that cannot be explained must refuse, got {:?}", + r.issues + ); +} + + #[test] fn explicit_identity_and_host_are_path_independent_but_still_unique() { let c = catalog(&[(