healthcloud-cli 3.7.8__tar.gz → 3.7.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/PKG-INFO +52 -17
  2. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/README.md +51 -16
  3. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/commands.py +5 -4
  4. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/operations.py +272 -34
  5. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/context.py +76 -0
  6. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/errors.py +5 -4
  7. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/pretty.py +26 -13
  8. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/pyproject.toml +1 -1
  9. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/.gitignore +0 -0
  10. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/hatch_build.py +0 -0
  11. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/__init__.py +0 -0
  12. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/__init__.py +0 -0
  13. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/commands.py +0 -0
  14. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/group.py +0 -0
  15. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/operations.py +0 -0
  16. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/__init__.py +0 -0
  17. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/__init__.py +0 -0
  18. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/group.py +0 -0
  19. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/projections.py +0 -0
  20. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/auth.py +0 -0
  21. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/commands.py +0 -0
  22. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/compatibility.py +0 -0
  23. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/__init__.py +0 -0
  24. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/commands.py +0 -0
  25. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/group.py +0 -0
  26. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/operations.py +0 -0
  27. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/projections.py +0 -0
  28. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/__init__.py +0 -0
  29. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/commands.py +0 -0
  30. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/group.py +0 -0
  31. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/operations.py +0 -0
  32. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/projections.py +0 -0
  33. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/domain_errors.py +0 -0
  34. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/group.py +0 -0
  35. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/__init__.py +0 -0
  36. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/commands.py +0 -0
  37. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/group.py +0 -0
  38. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/operations.py +0 -0
  39. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/projections.py +0 -0
  40. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/__init__.py +0 -0
  41. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/commands.py +0 -0
  42. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/group.py +0 -0
  43. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/operations.py +0 -0
  44. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/projections.py +0 -0
  45. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/output.py +0 -0
  46. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/__init__.py +0 -0
  47. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/commands.py +0 -0
  48. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/group.py +0 -0
  49. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/operations.py +0 -0
  50. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/projections.py +0 -0
  51. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/registry.py +0 -0
  52. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/results.py +0 -0
  53. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/session.py +0 -0
  54. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/__init__.py +0 -0
  55. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/commands.py +0 -0
  56. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/group.py +0 -0
  57. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/operations.py +0 -0
  58. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/projections.py +0 -0
  59. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/__init__.py +0 -0
  60. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/commands.py +0 -0
  61. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/group.py +0 -0
  62. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/operations.py +0 -0
  63. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/projections.py +0 -0
  64. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/target.py +0 -0
  65. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/__init__.py +0 -0
  66. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/commands.py +0 -0
  67. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/group.py +0 -0
  68. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/operations.py +0 -0
  69. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/projections.py +0 -0
  70. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/__init__.py +0 -0
  71. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/commands.py +0 -0
  72. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/group.py +0 -0
  73. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/operations.py +0 -0
  74. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/projections.py +0 -0
  75. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tuning.py +0 -0
  76. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/ui.py +0 -0
  77. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/windows_files.py +0 -0
  78. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/cli.py +0 -0
  79. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/context.py +0 -0
  80. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/contracts/__init__.py +0 -0
  81. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/contracts/mcp-tools.manifest.json +0 -0
  82. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/main.py +0 -0
  83. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/manifest.py +0 -0
  84. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/output.py +0 -0
  85. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/params.py +0 -0
  86. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/__init__.py +0 -0
  87. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/commands.py +0 -0
  88. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/group.py +0 -0
  89. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/operations.py +0 -0
  90. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/util.py +0 -0
  91. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/scripts/__init__.py +0 -0
  92. {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/scripts/bundle_manifest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: healthcloud-cli
3
- Version: 3.7.8
3
+ Version: 3.7.9
4
4
  Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
5
5
  Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
6
6
  Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
@@ -529,14 +529,15 @@ and a missing `--agent-id` fails with one envelope on stderr and exit 1:
529
529
  ```
530
530
 
531
531
  Agent lifecycle changes are two first-class, send-once write commands. They
532
- require a saved `hc login` session (env-var credentials are not accepted for
533
- writes) and an explicit `--expected-config-version`, `--confirm` acknowledgement:
532
+ require a saved `hc login` session (the command refuses before any request when
533
+ none is saved; env-var credentials are not accepted here) and the `--confirm`
534
+ acknowledgement. `--expected-config-version` is optional:
534
535
 
535
536
  ```bash
536
537
  uv run --frozen hc ams agents deactivate --environment dev --tenant-id TENANT_ID \
537
- --agent-id AGENT_ID --expected-config-version N --confirm
538
+ --agent-id AGENT_ID [--expected-config-version N] --confirm
538
539
  uv run --frozen hc ams agents activate --environment dev --tenant-id TENANT_ID \
539
- --agent-id AGENT_ID --expected-config-version N --confirm
540
+ --agent-id AGENT_ID [--expected-config-version N] --confirm
540
541
  ```
541
542
 
542
543
  Each command issues exactly one `GET /agents/{agent_id}`, local pre-checks,
@@ -566,9 +567,21 @@ explicitly that
566
567
  fields — a disabled agent can still be selected by those paths — and that
567
568
  in-flight runs, WSS sessions and schedules are untouched by this route.
568
569
 
570
+ `data.config_version_check` says which concurrent-edit check actually ran:
571
+ `matched` (the read `config_version` equals `--expected-config-version`),
572
+ `not_run` (the flag was omitted, so **no** version check ran and the reviewed
573
+ target plus `--confirm` are the approval), never a silent success. It is carried
574
+ on the confirmed, refused **and** unknown envelopes alike, so an outcome where
575
+ the write may already have committed still says which check ran. A deployment
576
+ whose agent payload omits or nulls `config_version` cannot honour the flag: the
577
+ command refuses that flag with a way forward (`Re-run without
578
+ --expected-config-version`) instead of refusing every lifecycle command forever
579
+ and instead of interpolating a Python `None` repr.
580
+
569
581
  Default `pretty` output prints those facts as labelled lines — `transition`,
570
- `requested`, `evidence` (`saved`, `side_effects`, `runtime`), `eligibility`,
571
- `mutation_replayed: false` — and closes with a `verification:` block, so a
582
+ `requested`, `config_version_check`, `evidence` (`saved`, `side_effects`,
583
+ `runtime`), `eligibility`, `mutation_replayed: false` — and closes with a
584
+ `verification:` block, so a
572
585
  deactivate reads `transition: deactivate`, then
573
586
  `evidence: / saved: / status: confirmed`, then `verification: / saved_state:
574
587
  confirmed`, `side_effects: unverified`, `runtime: unverified`; it is never one
@@ -592,24 +605,45 @@ tenant, an already in-effect state, an unsupported `archived`/`draft` target
592
605
  (named as having no supported reversible transition here), or a
593
606
  `config_version` that no longer matches `--expected-config-version` — all of
594
607
  these are also enforced by the preview, which still sends nothing. A missing
595
- `--confirm` exits 2. That version check is a local best-effort
608
+ `--confirm` exits 2. An `active` value the server emits as `1`/`0`/`"true"`/
609
+ `"false"` is interpreted as a flag (an already in-effect state is still
610
+ recognised and a correct write is still confirmed); any other value is
611
+ unconfirmed, never a false success. That version check is a local best-effort
596
612
  guard only: the route has no `If-Match` and the server never compares the
597
613
  version, so it cannot prove atomicity and a request could still race between
598
614
  the read and the PUT.
599
615
 
600
- A transport error or `5xx` from the PUT is reported as `outcome: "unknown"`
601
- with `mutation_replayed: false`, `verification.saved_state: "unknown"` and a
616
+ A transport error, a `5xx`/`408` from the PUT, or an interrupt (`Ctrl-C`) during
617
+ the PUT or the readback is reported as `outcome: "unknown"` with
618
+ `mutation_replayed: false`, `verification.saved_state: "unknown"` and a
602
619
  `reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
603
620
  does not re-read automatically — run `hc ams agents get` before doing anything
604
621
  else. A failed readback after an accepted PUT is reported the same way (unknown,
605
622
  not success). A `2xx` PUT answer whose body cannot be read as an agent object is
606
- also `outcome: "unknown"` (sent once, saved state unverified), never an internal
607
- error and never a success.
623
+ also `outcome: "unknown"` (attempted once; the request may not have been sent
624
+ and its saved state is unverified), never an internal error and never a
625
+ success.
626
+
627
+ A non-`5xx` rejection of the PUT (`409`, `422`, `400`, `403`, …) is worded as a
628
+ **write** that the server refused, never as a read: `outcome: "failed"`,
629
+ `verification.saved_state: "failed"`, `mutation_replayed: false`, the pre-write
630
+ state under `data.evidence.saved.before`, and a `reconciliation` saying the CLI
631
+ did not re-read. A `2xx` body that is not an agent object at all (for example a
632
+ plain-text body) is a bounded `validation` refusal with the same
633
+ `verification: not_applicable` as any read, never an `AttributeError` and never a
634
+ traceback.
635
+
636
+ The saved-session requirement above is enforced at the command boundary: the
637
+ shared AMS authentication path still has the `HC_AMS_ADMIN_EMAIL`/
638
+ `HC_AMS_ADMIN_PASSWORD` tenant-admin fallback that every AMS command group uses,
639
+ so the guarantee is "these commands refuse without a saved session", not "no AMS
640
+ write anywhere can reach the server on env-var credentials".
608
641
 
609
642
  Capability and organization assignments are one first-class, send-once write
610
- command. It requires a saved `hc login` session (env-var credentials are not
611
- accepted for writes), an explicit `--expected-config-version`, and the
612
- `--confirm` acknowledgement of the target and the printed reviewed diff:
643
+ command. It requires a saved `hc login` session (the command refuses before any
644
+ request when none is saved; env-var credentials are not accepted here), an
645
+ explicit `--expected-config-version`, and the `--confirm` acknowledgement of the
646
+ target and the printed reviewed diff:
613
647
 
614
648
  ```bash
615
649
  uv run --frozen hc ams agents assignments set --environment dev --tenant-id TENANT_ID \
@@ -706,8 +740,9 @@ carries a reference field is refused by field name by
706
740
  capabilities.
707
741
 
708
742
  Deleting an agent is one first-class, send-once destructive command. It requires
709
- a saved `hc login` session (env-var credentials are not accepted for writes) and
710
- the `--confirm` acknowledgement of the target and disclosure block it prints:
743
+ a saved `hc login` session (the command refuses before any request when none is
744
+ saved; env-var credentials are not accepted here) and the `--confirm`
745
+ acknowledgement of the target and disclosure block it prints:
711
746
 
712
747
  ```bash
713
748
  uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
@@ -511,14 +511,15 @@ and a missing `--agent-id` fails with one envelope on stderr and exit 1:
511
511
  ```
512
512
 
513
513
  Agent lifecycle changes are two first-class, send-once write commands. They
514
- require a saved `hc login` session (env-var credentials are not accepted for
515
- writes) and an explicit `--expected-config-version`, `--confirm` acknowledgement:
514
+ require a saved `hc login` session (the command refuses before any request when
515
+ none is saved; env-var credentials are not accepted here) and the `--confirm`
516
+ acknowledgement. `--expected-config-version` is optional:
516
517
 
517
518
  ```bash
518
519
  uv run --frozen hc ams agents deactivate --environment dev --tenant-id TENANT_ID \
519
- --agent-id AGENT_ID --expected-config-version N --confirm
520
+ --agent-id AGENT_ID [--expected-config-version N] --confirm
520
521
  uv run --frozen hc ams agents activate --environment dev --tenant-id TENANT_ID \
521
- --agent-id AGENT_ID --expected-config-version N --confirm
522
+ --agent-id AGENT_ID [--expected-config-version N] --confirm
522
523
  ```
523
524
 
524
525
  Each command issues exactly one `GET /agents/{agent_id}`, local pre-checks,
@@ -548,9 +549,21 @@ explicitly that
548
549
  fields — a disabled agent can still be selected by those paths — and that
549
550
  in-flight runs, WSS sessions and schedules are untouched by this route.
550
551
 
552
+ `data.config_version_check` says which concurrent-edit check actually ran:
553
+ `matched` (the read `config_version` equals `--expected-config-version`),
554
+ `not_run` (the flag was omitted, so **no** version check ran and the reviewed
555
+ target plus `--confirm` are the approval), never a silent success. It is carried
556
+ on the confirmed, refused **and** unknown envelopes alike, so an outcome where
557
+ the write may already have committed still says which check ran. A deployment
558
+ whose agent payload omits or nulls `config_version` cannot honour the flag: the
559
+ command refuses that flag with a way forward (`Re-run without
560
+ --expected-config-version`) instead of refusing every lifecycle command forever
561
+ and instead of interpolating a Python `None` repr.
562
+
551
563
  Default `pretty` output prints those facts as labelled lines — `transition`,
552
- `requested`, `evidence` (`saved`, `side_effects`, `runtime`), `eligibility`,
553
- `mutation_replayed: false` — and closes with a `verification:` block, so a
564
+ `requested`, `config_version_check`, `evidence` (`saved`, `side_effects`,
565
+ `runtime`), `eligibility`, `mutation_replayed: false` — and closes with a
566
+ `verification:` block, so a
554
567
  deactivate reads `transition: deactivate`, then
555
568
  `evidence: / saved: / status: confirmed`, then `verification: / saved_state:
556
569
  confirmed`, `side_effects: unverified`, `runtime: unverified`; it is never one
@@ -574,24 +587,45 @@ tenant, an already in-effect state, an unsupported `archived`/`draft` target
574
587
  (named as having no supported reversible transition here), or a
575
588
  `config_version` that no longer matches `--expected-config-version` — all of
576
589
  these are also enforced by the preview, which still sends nothing. A missing
577
- `--confirm` exits 2. That version check is a local best-effort
590
+ `--confirm` exits 2. An `active` value the server emits as `1`/`0`/`"true"`/
591
+ `"false"` is interpreted as a flag (an already in-effect state is still
592
+ recognised and a correct write is still confirmed); any other value is
593
+ unconfirmed, never a false success. That version check is a local best-effort
578
594
  guard only: the route has no `If-Match` and the server never compares the
579
595
  version, so it cannot prove atomicity and a request could still race between
580
596
  the read and the PUT.
581
597
 
582
- A transport error or `5xx` from the PUT is reported as `outcome: "unknown"`
583
- with `mutation_replayed: false`, `verification.saved_state: "unknown"` and a
598
+ A transport error, a `5xx`/`408` from the PUT, or an interrupt (`Ctrl-C`) during
599
+ the PUT or the readback is reported as `outcome: "unknown"` with
600
+ `mutation_replayed: false`, `verification.saved_state: "unknown"` and a
584
601
  `reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
585
602
  does not re-read automatically — run `hc ams agents get` before doing anything
586
603
  else. A failed readback after an accepted PUT is reported the same way (unknown,
587
604
  not success). A `2xx` PUT answer whose body cannot be read as an agent object is
588
- also `outcome: "unknown"` (sent once, saved state unverified), never an internal
589
- error and never a success.
605
+ also `outcome: "unknown"` (attempted once; the request may not have been sent
606
+ and its saved state is unverified), never an internal error and never a
607
+ success.
608
+
609
+ A non-`5xx` rejection of the PUT (`409`, `422`, `400`, `403`, …) is worded as a
610
+ **write** that the server refused, never as a read: `outcome: "failed"`,
611
+ `verification.saved_state: "failed"`, `mutation_replayed: false`, the pre-write
612
+ state under `data.evidence.saved.before`, and a `reconciliation` saying the CLI
613
+ did not re-read. A `2xx` body that is not an agent object at all (for example a
614
+ plain-text body) is a bounded `validation` refusal with the same
615
+ `verification: not_applicable` as any read, never an `AttributeError` and never a
616
+ traceback.
617
+
618
+ The saved-session requirement above is enforced at the command boundary: the
619
+ shared AMS authentication path still has the `HC_AMS_ADMIN_EMAIL`/
620
+ `HC_AMS_ADMIN_PASSWORD` tenant-admin fallback that every AMS command group uses,
621
+ so the guarantee is "these commands refuse without a saved session", not "no AMS
622
+ write anywhere can reach the server on env-var credentials".
590
623
 
591
624
  Capability and organization assignments are one first-class, send-once write
592
- command. It requires a saved `hc login` session (env-var credentials are not
593
- accepted for writes), an explicit `--expected-config-version`, and the
594
- `--confirm` acknowledgement of the target and the printed reviewed diff:
625
+ command. It requires a saved `hc login` session (the command refuses before any
626
+ request when none is saved; env-var credentials are not accepted here), an
627
+ explicit `--expected-config-version`, and the `--confirm` acknowledgement of the
628
+ target and the printed reviewed diff:
595
629
 
596
630
  ```bash
597
631
  uv run --frozen hc ams agents assignments set --environment dev --tenant-id TENANT_ID \
@@ -688,8 +722,9 @@ carries a reference field is refused by field name by
688
722
  capabilities.
689
723
 
690
724
  Deleting an agent is one first-class, send-once destructive command. It requires
691
- a saved `hc login` session (env-var credentials are not accepted for writes) and
692
- the `--confirm` acknowledgement of the target and disclosure block it prints:
725
+ a saved `hc login` session (the command refuses before any request when none is
726
+ saved; env-var credentials are not accepted here) and the `--confirm`
727
+ acknowledgement of the target and disclosure block it prints:
693
728
 
694
729
  ```bash
695
730
  uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
@@ -93,10 +93,11 @@ def create_lifecycle_command(dependencies: AmsDependencies, transition: str) ->
93
93
  @_agent
94
94
  @click.option(
95
95
  "--expected-config-version",
96
- required=True,
97
96
  type=int,
98
- help="The config_version read before the write. A local best-effort guard only: the route has no "
99
- "If-Match, so it never proves atomicity.",
97
+ default=None,
98
+ help="Optional: the config_version read before the write. A local best-effort guard only, because "
99
+ "the route has no If-Match. Omitting it runs no version check and says so in the envelope; a "
100
+ "deployment that reports no config_version refuses the flag rather than the whole command.",
100
101
  )
101
102
  @click.option(
102
103
  "--confirm",
@@ -111,7 +112,7 @@ def create_lifecycle_command(dependencies: AmsDependencies, transition: str) ->
111
112
  environment: str | None,
112
113
  tenant_id: str | None,
113
114
  agent_id: str,
114
- expected_config_version: int,
115
+ expected_config_version: int | None,
115
116
  confirm: bool,
116
117
  ) -> None:
117
118
  target_data = {
@@ -50,9 +50,32 @@ def list_agents(dependencies: AmsDependencies, target: TenantTarget) -> dict:
50
50
  )
51
51
 
52
52
 
53
+ def _agent_object(value) -> bool:
54
+ """True only for a body that really is an agent object.
55
+
56
+ ``TypedClient.model`` returns a non-dict 200 body unchanged (``http.py``
57
+ hands back ``resp.text`` when JSON parsing fails), so an id/tenant check
58
+ written as ``value.id`` would crash with ``AttributeError`` and reach the
59
+ operator as a generic internal failure - after a committed write, in the
60
+ readback case. Every agent read and readback goes through this guard first.
61
+ """
62
+
63
+ return (
64
+ value is not None
65
+ and not isinstance(value, (str, bytes, bytearray, int, float, list, tuple, dict))
66
+ and getattr(value, "id", None) is not None
67
+ and getattr(value, "tenant_id", None) is not None
68
+ )
69
+
70
+
53
71
  def _verified_agent(sdk, target: TenantTarget, agent_id: str):
54
72
  agent = sdk.ams_admin.get_agent(agent_id)
55
- if agent is None or agent.id != agent_id or agent.tenant_id != target.tenant_id:
73
+ if not _agent_object(agent):
74
+ raise AgentReadRefused(
75
+ "validation",
76
+ f"The server did not return an agent object for '{agent_id}'; stopped before reading or writing anything.",
77
+ )
78
+ if agent.id != agent_id or agent.tenant_id != target.tenant_id:
56
79
  raise AgentReadRefused("validation", "Agent/tenant identity mismatch; stopped.")
57
80
  return agent
58
81
 
@@ -110,10 +133,22 @@ UNKNOWN_LIFECYCLE_OUTCOME = (
110
133
  "`hc ams agents get` before doing anything else."
111
134
  )
112
135
 
113
- UNREADABLE_LIFECYCLE_RESPONSE = (
114
- "The agent lifecycle write was sent once and the server answered, but its response could not be read "
115
- "as an agent object, so the saved state is unknown. The mutation was not replayed. Re-read the agent "
116
- "with `hc ams agents get` before doing anything else."
136
+ # A TypeError (a drifted/local SDK fault around the call) and a ValueError (a 2xx
137
+ # body that cannot be built into an agent) are indistinguishable at the call
138
+ # site, so the message covers both honestly instead of claiming a send: the
139
+ # request may never have left the process, and if it did, its outcome is
140
+ # unknown.
141
+ MAYBE_UNSENT_LIFECYCLE_OUTCOME = (
142
+ "The agent lifecycle write was attempted once and the installed SDK raised instead of returning a "
143
+ "readable agent response, so its outcome is unknown: the request may not have been sent, and if it "
144
+ "was, it may have been applied. The mutation was not replayed. Re-read the agent with "
145
+ "`hc ams agents get` before doing anything else."
146
+ )
147
+
148
+ INTERRUPTED_LIFECYCLE_OUTCOME = (
149
+ "The agent lifecycle write was interrupted after the request was attempted, so its outcome is unknown: "
150
+ "it may already have been applied. The mutation was not replayed. Re-read the agent with "
151
+ "`hc ams agents get` before doing anything else."
117
152
  )
118
153
 
119
154
  _RUNTIME_UNVERIFIED = (
@@ -152,7 +187,89 @@ def _state(agent) -> dict:
152
187
  }
153
188
 
154
189
 
155
- def _lifecycle_precheck(agent, agent_id: str, status: str, active: bool, expected_config_version: int) -> None:
190
+ def _active_flag(value) -> bool | None:
191
+ """Interpret a server ``active`` value, or ``None`` when it is not a flag.
192
+
193
+ ``AgentConfig`` is a plain dataclass fed by ``json.loads``: a server that
194
+ emits ``1``/``0``/``"true"`` would make an identity check (``1 is True``)
195
+ false, so a correctly applied write would be reported "not confirmed" and the
196
+ already-in-effect precheck would not fire. Never a false success: only an
197
+ unambiguous flag maps, everything else stays unknown.
198
+ """
199
+
200
+ if isinstance(value, bool):
201
+ return value
202
+ if isinstance(value, int) and value in (0, 1):
203
+ return bool(value)
204
+ if isinstance(value, str):
205
+ lowered = value.strip().lower()
206
+ if lowered in ("true", "1", "yes"):
207
+ return True
208
+ if lowered in ("false", "0", "no"):
209
+ return False
210
+ return None
211
+
212
+
213
+ def _usable_config_version(value) -> bool:
214
+ """A ``config_version`` is only usable as a number; a bool, string or None is not.
215
+
216
+ ``AgentConfig`` is a plain dataclass fed by ``json.loads``, so a deployment can
217
+ legitimately emit ``"3"``. Nothing downstream may assume ``+ 1`` works on it.
218
+ """
219
+
220
+ return isinstance(value, int) and not isinstance(value, bool)
221
+
222
+
223
+ def _config_version_check(agent, agent_id: str, expected_config_version: int | None) -> dict:
224
+ """Report which concurrency check actually ran, refusing a check that cannot.
225
+
226
+ ``--expected-config-version`` is a local best-effort guard the route cannot
227
+ honour (no If-Match). A deployment whose agent payload omits or nulls
228
+ ``config_version`` has no version to compare against, so asking for the check
229
+ is refused with a way forward - but omitting the flag is never a silent
230
+ success: the envelope says plainly that no version check ran.
231
+ """
232
+
233
+ observed = getattr(agent, "config_version", None)
234
+ usable = _usable_config_version(observed)
235
+ if expected_config_version is None:
236
+ return {
237
+ "status": "not_run",
238
+ "expected": None,
239
+ "observed": observed if usable else None,
240
+ "detail": (
241
+ "--expected-config-version was not passed, so no local concurrent-edit check ran. The "
242
+ "route has no If-Match and the server never compares a version; the reviewed target and "
243
+ "--confirm are the approval."
244
+ ),
245
+ }
246
+ if not usable:
247
+ raise AgentWriteRefused(
248
+ "validation",
249
+ f"Agent '{agent_id}' reports no usable config_version, so --expected-config-version cannot be "
250
+ "checked against it; nothing was sent. Re-run without --expected-config-version to send this "
251
+ "change with no local version check, or repair the agent's config_version first.",
252
+ )
253
+ if observed != expected_config_version:
254
+ raise AgentWriteRefused(
255
+ "validation",
256
+ f"--expected-config-version {expected_config_version} does not match the agent's config_version "
257
+ f"{observed}; a concurrent edit was refused locally and nothing was sent. This is a "
258
+ "best-effort local check because the route has no If-Match: the server never compares the "
259
+ "version, so a request could still race between a read and the write.",
260
+ )
261
+ return {
262
+ "status": "matched",
263
+ "expected": expected_config_version,
264
+ "observed": observed,
265
+ "detail": (
266
+ "The read config_version equals --expected-config-version. This is a best-effort local check "
267
+ "only: the route has no If-Match, so the server never compared it."
268
+ ),
269
+ }
270
+
271
+
272
+ def _lifecycle_precheck(agent, agent_id: str, status: str, active: bool, expected_config_version: int | None) -> None:
156
273
  """Refuse locally, before the one write, when the transition is not valid."""
157
274
 
158
275
  if agent.status in ("archived", "draft"):
@@ -167,15 +284,8 @@ def _lifecycle_precheck(agent, agent_id: str, status: str, active: bool, expecte
167
284
  f"Agent '{agent_id}' reports an unsupported lifecycle status '{agent.status}'; only 'active' and "
168
285
  "'disabled' are written. Nothing was sent.",
169
286
  )
170
- if agent.config_version != expected_config_version:
171
- raise AgentWriteRefused(
172
- "validation",
173
- f"--expected-config-version {expected_config_version} does not match the agent's config_version "
174
- f"{agent.config_version}; a concurrent edit was refused locally and nothing was sent. This is a "
175
- "best-effort local check because the route has no If-Match: the server never compares the "
176
- "version, so a request could still race between a read and the write.",
177
- )
178
- if agent.status == status and agent.active is active:
287
+ _config_version_check(agent, agent_id, expected_config_version)
288
+ if agent.status == status and _active_flag(agent.active) is active:
179
289
  raise AgentWriteRefused(
180
290
  "validation",
181
291
  f"Agent '{agent_id}' is already status '{status}' with active {str(active).lower()}; the change "
@@ -196,15 +306,23 @@ def _usable_readback(agent, agent_id: str, target: TenantTarget) -> bool:
196
306
  and getattr(agent, "id", None) == agent_id
197
307
  and getattr(agent, "tenant_id", None) == target.tenant_id
198
308
  and getattr(agent, "status", None) is not None
199
- and getattr(agent, "active", None) is not None
309
+ and _active_flag(getattr(agent, "active", None)) is not None
200
310
  )
201
311
 
202
312
 
203
313
  def _lifecycle_evidence(before, after, status: str, active: bool, agent_id: str, target: TenantTarget) -> dict:
204
314
  identity = after is not None and after.id == agent_id and after.tenant_id == target.tenant_id
205
- matched = identity and after.status == status and after.active is active
315
+ matched = identity and after.status == status and _active_flag(after.active) is active
316
+ # Making --expected-config-version optional removed the pre-write refusal that
317
+ # used to reject a non-int config_version, so this runs after the write: it must
318
+ # degrade to "not observed", never raise on a value it cannot add to.
319
+ before_version = getattr(before, "config_version", None)
320
+ after_version = None if after is None else getattr(after, "config_version", None)
206
321
  advanced = (
207
- after is not None and before.config_version is not None and after.config_version == before.config_version + 1
322
+ after is not None
323
+ and _usable_config_version(before_version)
324
+ and _usable_config_version(after_version)
325
+ and after_version == before_version + 1
208
326
  )
209
327
  if matched:
210
328
  detail = "The readback shows exactly the status/active pair that was sent for this agent in this tenant."
@@ -259,7 +377,14 @@ def _eligibility(after) -> dict:
259
377
 
260
378
 
261
379
  def _lifecycle_envelope(
262
- target: TenantTarget, agent_id: str, transition: str, status: str, active: bool, before, after
380
+ target: TenantTarget,
381
+ agent_id: str,
382
+ transition: str,
383
+ status: str,
384
+ active: bool,
385
+ before,
386
+ after,
387
+ version_check: dict,
263
388
  ) -> dict:
264
389
  envelope = succeeded(
265
390
  f"agents.{transition}",
@@ -267,6 +392,7 @@ def _lifecycle_envelope(
267
392
  {
268
393
  "transition": transition,
269
394
  "requested": {"status": status, "active": active},
395
+ "config_version_check": version_check,
270
396
  "evidence": _lifecycle_evidence(before, after, status, active, agent_id, target),
271
397
  "eligibility": _eligibility(after),
272
398
  "mutation_replayed": False,
@@ -281,17 +407,89 @@ def _lifecycle_envelope(
281
407
  return envelope
282
408
 
283
409
 
410
+ def _rejected_lifecycle(
411
+ operation: str,
412
+ target: TenantTarget,
413
+ agent_id: str,
414
+ status: str,
415
+ active: bool,
416
+ before,
417
+ version_check: dict,
418
+ http_status: int,
419
+ ) -> None:
420
+ """Report a write the server refused: sent once, definitively not saved.
421
+
422
+ The request left the process, so the outcome must not be described as a read
423
+ ("read conflicted") and the verification block must not claim
424
+ ``not_applicable``: the server answered, so ``saved_state`` is ``failed``.
425
+ """
426
+
427
+ from ..errors import http_category
428
+
429
+ category = http_category(operation, http_status)
430
+ message = (
431
+ f"Agent '{agent_id}' lifecycle write was sent once and the server refused it with HTTP {http_status}; "
432
+ "nothing was written, nothing was replayed and no readback was taken. Review the agent with "
433
+ "`hc ams agents get` before retrying."
434
+ )
435
+ envelope = succeeded(
436
+ operation,
437
+ _lifecycle_target(target, agent_id),
438
+ {
439
+ "transition": operation.split(".", 1)[-1],
440
+ "requested": {"status": status, "active": active},
441
+ "config_version_check": version_check,
442
+ "mutation_replayed": False,
443
+ "reconciliation": {
444
+ "status": "not_read",
445
+ "detail": (
446
+ "The server refused the write, so this command did not re-read the agent. Re-read with "
447
+ "`hc ams agents get` before doing anything else."
448
+ ),
449
+ },
450
+ "evidence": {
451
+ "saved": {
452
+ "status": "failed",
453
+ "agent_id": agent_id,
454
+ "detail": (
455
+ "The server refused the lifecycle write, so the requested status/active pair is "
456
+ "not saved. The state below was read before the write was sent."
457
+ ),
458
+ "before": _state(before),
459
+ },
460
+ "side_effects": {"status": "unverified", "detail": _SIDE_EFFECTS_UNVERIFIED},
461
+ "runtime": {"status": "unverified", "detail": _RUNTIME_UNVERIFIED},
462
+ },
463
+ },
464
+ read_only=False,
465
+ )
466
+ envelope["outcome"] = "failed"
467
+ envelope["error"] = {"category": category, "message": message}
468
+ envelope["verification"] = {
469
+ "saved_state": "failed",
470
+ "side_effects": "unverified",
471
+ "runtime": "unverified",
472
+ }
473
+ raise DomainOutcomeFailed(category, message, envelope)
474
+
475
+
284
476
  def _unknown_lifecycle(
285
477
  operation: str,
286
478
  target: TenantTarget,
287
479
  agent_id: str,
288
480
  status: str,
289
481
  active: bool,
482
+ version_check: dict,
290
483
  *,
291
484
  reconciliation_detail: str,
292
485
  message: str = UNKNOWN_LIFECYCLE_OUTCOME,
293
486
  ) -> None:
294
- """Report an uncertain send once, without replaying or re-reading it."""
487
+ """Report an uncertain send once, without replaying or re-reading it.
488
+
489
+ ``version_check`` is carried on this envelope too: the write may have
490
+ committed, so which concurrent-edit check actually ran is exactly what the
491
+ operator needs while deciding whether a second edit could have been in play.
492
+ """
295
493
 
296
494
  envelope = unknown(
297
495
  operation,
@@ -302,6 +500,7 @@ def _unknown_lifecycle(
302
500
  "mutation_replayed": False,
303
501
  "requested": {"status": status, "active": active},
304
502
  "reconciliation": {"status": "not_read", "detail": reconciliation_detail},
503
+ "config_version_check": version_check,
305
504
  },
306
505
  read_only=False,
307
506
  )
@@ -319,27 +518,47 @@ def set_lifecycle(
319
518
  agent_id: str,
320
519
  *,
321
520
  transition: Literal["activate", "deactivate"],
322
- expected_config_version: int,
521
+ expected_config_version: int | None,
323
522
  ) -> dict:
324
523
  """Change one agent's lifecycle availability once, then read it back.
325
524
 
326
525
  Sequence: exactly one ``GET /agents/{agent_id}``, local pre-checks, exactly
327
526
  one ``PUT /agents/{agent_id}`` whose body is only ``status``+``active``, then
328
527
  exactly one ``GET`` readback. There is never a retry, a replay, a second
329
- write or a generic update fallback. ``--expected-config-version`` is a local
330
- best-effort guard only: the route has no version/If-Match check.
528
+ write or a generic update fallback. ``--expected-config-version`` is an
529
+ optional local best-effort guard: the route has no version/If-Match check,
530
+ and omitting the flag is reported as ``config_version_check: not_run`` rather
531
+ than pretending a check happened.
331
532
  """
332
533
 
333
534
  from healthcloud.errors import HealthCloudHTTPError, HealthCloudNetworkError
334
535
 
536
+ from ..results import is_unknown_write_status
537
+
335
538
  agent_id = _agent_id(agent_id)
336
539
  status, active = _lifecycle_write_pair(transition)
337
540
  operation = f"agents.{transition}"
338
541
  with dependencies.authenticated_sdk_factory(target) as sdk:
339
542
  before = _verified_agent(sdk, target, agent_id)
340
543
  _lifecycle_precheck(before, agent_id, status, active, expected_config_version)
544
+ version_check = _config_version_check(before, agent_id, expected_config_version)
341
545
  try:
342
546
  sdk.ams_admin.set_agent_lifecycle(agent_id, status=status, active=active)
547
+ except KeyboardInterrupt:
548
+ # BaseException: every ordinary handler above is bypassed, and the
549
+ # write may already have committed. Report the uncertain outcome
550
+ # instead of a plain transport failure that claims nothing was sent.
551
+ _unknown_lifecycle(
552
+ operation,
553
+ target,
554
+ agent_id,
555
+ status,
556
+ active,
557
+ version_check,
558
+ message=INTERRUPTED_LIFECYCLE_OUTCOME,
559
+ reconciliation_detail="The command was interrupted after the write was attempted and did not "
560
+ "re-read the agent; run `hc ams agents get` before doing anything else.",
561
+ )
343
562
  except HealthCloudNetworkError:
344
563
  _unknown_lifecycle(
345
564
  operation,
@@ -347,38 +566,56 @@ def set_lifecycle(
347
566
  agent_id,
348
567
  status,
349
568
  active,
569
+ version_check,
350
570
  reconciliation_detail="This command did not re-read the agent after the uncertain write; "
351
571
  "run `hc ams agents get` before doing anything else.",
352
572
  )
353
573
  except HealthCloudHTTPError as error:
354
- if 500 <= (getattr(error, "status_code", 0) or 0) < 600:
574
+ http_status = getattr(error, "status_code", None)
575
+ # No usable status is itself an uncertain answer for a sent write.
576
+ if not isinstance(http_status, int) or is_unknown_write_status(http_status):
355
577
  _unknown_lifecycle(
356
578
  operation,
357
579
  target,
358
580
  agent_id,
359
581
  status,
360
582
  active,
583
+ version_check,
361
584
  reconciliation_detail="This command did not re-read the agent after the uncertain write; "
362
585
  "run `hc ams agents get` before doing anything else.",
363
586
  )
364
- raise
587
+ # A non-5xx answer is a definite refusal of a request that was sent:
588
+ # never described as a read, and never ``saved_state: not_applicable``.
589
+ _rejected_lifecycle(operation, target, agent_id, status, active, before, version_check, http_status)
365
590
  except TypeError, ValueError:
366
- # The PUT was answered 2xx, but the body could not be built into an
367
- # agent (a partial object or an unexpected shape). The write was
368
- # sent exactly once, so this is an unknown outcome, never a crash
369
- # and never a success.
591
+ # Either the PUT was answered 2xx and the body could not be built
592
+ # into an agent, or a drifted SDK raised around the call. Both were
593
+ # attempted once, neither is a crash and neither is a success.
370
594
  _unknown_lifecycle(
371
595
  operation,
372
596
  target,
373
597
  agent_id,
374
598
  status,
375
599
  active,
376
- message=UNREADABLE_LIFECYCLE_RESPONSE,
377
- reconciliation_detail="This command did not re-read the agent after the uncertain write; "
378
- "run `hc ams agents get` before doing anything else.",
600
+ version_check,
601
+ message=MAYBE_UNSENT_LIFECYCLE_OUTCOME,
602
+ reconciliation_detail="The SDK did not report a readable response, so the request may not "
603
+ "have been sent. Re-read with `hc ams agents get` before doing anything else.",
379
604
  )
380
605
  try:
381
606
  after = sdk.ams_admin.get_agent(agent_id)
607
+ except KeyboardInterrupt:
608
+ _unknown_lifecycle(
609
+ operation,
610
+ target,
611
+ agent_id,
612
+ status,
613
+ active,
614
+ version_check,
615
+ message=INTERRUPTED_LIFECYCLE_OUTCOME,
616
+ reconciliation_detail="The write was accepted, but the command was interrupted during the "
617
+ "readback; run `hc ams agents get` before doing anything else.",
618
+ )
382
619
  except TypeError, ValueError:
383
620
  # An accepted write with an unreadable readback is unconfirmed
384
621
  # evidence, not an internal error and not a replay: fall through to
@@ -391,12 +628,13 @@ def set_lifecycle(
391
628
  agent_id,
392
629
  status,
393
630
  active,
631
+ version_check,
394
632
  reconciliation_detail="The write was accepted, but the readback could not be read. The saved "
395
633
  "state is unverified; run `hc ams agents get` before doing anything else.",
396
634
  )
397
635
  if not _usable_readback(after, agent_id, target):
398
636
  after = None
399
- envelope = _lifecycle_envelope(target, agent_id, transition, status, active, before, after)
637
+ envelope = _lifecycle_envelope(target, agent_id, transition, status, active, before, after, version_check)
400
638
  if envelope["data"]["evidence"]["saved"]["status"] == "confirmed":
401
639
  return envelope
402
640
  if after is None:
@@ -1118,7 +1356,7 @@ def preview_lifecycle(
1118
1356
  agent_id: str,
1119
1357
  *,
1120
1358
  transition: Literal["activate", "deactivate"],
1121
- expected_config_version: int,
1359
+ expected_config_version: int | None,
1122
1360
  ) -> None:
1123
1361
  """Print the exact lifecycle change without sending it.
1124
1362
 
@@ -103,6 +103,75 @@ def preflight_discovery(invocation: AmsContext) -> None:
103
103
  raise click.ClickException("Discover requires explicit environment/tenant flags, without a target file.")
104
104
 
105
105
 
106
+ # The exact call this CLI makes on each ``ams_admin`` agent method: positional
107
+ # argument count plus the keyword names it passes. A callable check alone is not
108
+ # enough: an installed SDK whose signature drifted raises ``TypeError`` before any
109
+ # bytes leave the process, which a sent-once write must never report as "may have
110
+ # committed". Refusing the drifted signature here keeps that impossible.
111
+ _AGENT_METHOD_CALLS = {
112
+ "get_agent": (1, ()),
113
+ "delete_agent": (1, ()),
114
+ "set_agent_lifecycle": (1, ("status", "active")),
115
+ "set_agent_assignments": (
116
+ 1,
117
+ (
118
+ "department_id",
119
+ "team_id",
120
+ "skill_ids",
121
+ "tool_ids",
122
+ "policy_ids",
123
+ "guardrail_ids",
124
+ "data_source_ids",
125
+ ),
126
+ ),
127
+ }
128
+
129
+
130
+ def _signature_binds(method, positional: int, keywords: tuple[str, ...]) -> bool:
131
+ """True when ``method`` accepts this CLI's call; un-introspectable is allowed."""
132
+
133
+ import inspect
134
+
135
+ try:
136
+ params = list(inspect.signature(method).parameters.values())
137
+ except TypeError, ValueError:
138
+ # A C callable or a stub tells us nothing; the call site keeps its own guard.
139
+ return True
140
+ var_keyword = any(param.kind is inspect.Parameter.VAR_KEYWORD for param in params)
141
+ var_positional = any(param.kind is inspect.Parameter.VAR_POSITIONAL for param in params)
142
+ named = {param.name: param for param in params}
143
+ for keyword in keywords:
144
+ param = named.get(keyword)
145
+ if param is not None and param.kind is inspect.Parameter.POSITIONAL_ONLY:
146
+ return False
147
+ if param is None and not var_keyword:
148
+ return False
149
+ positional_params = [
150
+ param
151
+ for param in params
152
+ if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
153
+ ]
154
+ if not var_positional and positional > len(positional_params):
155
+ return False
156
+ # A required POSITIONAL_OR_KEYWORD parameter the CLI passes by name is covered by
157
+ # ``keywords``, so it must not also be counted against the positional budget.
158
+ required_positional = [
159
+ param
160
+ for param in positional_params
161
+ if param.default is inspect.Parameter.empty
162
+ and not (param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD and param.name in keywords)
163
+ ]
164
+ if len(required_positional) > positional:
165
+ return False
166
+ required_keyword_only = [
167
+ param
168
+ for param in params
169
+ if param.kind is inspect.Parameter.KEYWORD_ONLY and param.default is inspect.Parameter.empty
170
+ ]
171
+ kwargs_covered = var_keyword or all(param.name in keywords for param in required_keyword_only)
172
+ return kwargs_covered
173
+
174
+
106
175
  def preflight_agents(
107
176
  invocation: AmsContext,
108
177
  target: TenantTarget | None = None,
@@ -131,6 +200,13 @@ def preflight_agents(
131
200
  raise click.ClickException(
132
201
  "SDK missing ams_admin agent lifecycle support; use the reviewed SDK version required by this CLI."
133
202
  )
203
+ for method in methods:
204
+ call = _AGENT_METHOD_CALLS.get(method)
205
+ if call is not None and not _signature_binds(getattr(admin, method), *call):
206
+ raise click.ClickException(
207
+ f"Installed SDK ams_admin {method} does not accept the call this CLI makes; use the "
208
+ "reviewed SDK version required by this CLI. Nothing was sent."
209
+ )
134
210
  finally:
135
211
  sdk.close()
136
212
 
@@ -12,7 +12,7 @@ from pydantic import ValidationError
12
12
 
13
13
  from ..output import output_mode
14
14
  from .domain_errors import DomainReadRefused
15
- from .pretty import error_line, success_text, unknown_details
15
+ from .pretty import error_line, outcome_details, success_text
16
16
  from .results import failed
17
17
 
18
18
  _MESSAGE_TEMPLATES = {
@@ -76,7 +76,8 @@ _AMS_READ_DOMAINS = {
76
76
  }
77
77
 
78
78
 
79
- def _http_category(operation: str, status: object) -> str:
79
+ def http_category(operation: str, status: object) -> str:
80
+ """The bounded category for one HTTP status (writes reuse the same mapping)."""
80
81
  shared_categories = {
81
82
  401: "authentication",
82
83
  403: "authorization",
@@ -115,7 +116,7 @@ def _classification(operation: str, error: BaseException) -> tuple[str, int]:
115
116
  if isinstance(error, DomainReadRefused):
116
117
  return error.category, 1
117
118
  if isinstance(error, (HealthCloudHTTPError, SDKError)):
118
- return _http_category(operation, getattr(error, "status_code", None)), 1
119
+ return http_category(operation, getattr(error, "status_code", None)), 1
119
120
  if isinstance(error, (HealthCloudNetworkError, NetworkError)):
120
121
  return "transport", 1
121
122
  if isinstance(error, click.ClickException):
@@ -171,7 +172,7 @@ def _render_failure(envelope: dict, mode: str) -> None:
171
172
  # An unknown mutation outcome carries the read-only reconciliation the
172
173
  # operator must see before acting again; pretty is the default mode, so
173
174
  # dropping it here would only expose it under ``--output json``.
174
- details = unknown_details(envelope)
175
+ details = outcome_details(envelope)
175
176
  if details:
176
177
  click.echo(details, err=True)
177
178
  return
@@ -272,25 +272,38 @@ def _detail_lines(mapping: dict, depth: int = 0) -> list[str]:
272
272
  return lines
273
273
 
274
274
 
275
- def unknown_details(envelope: dict) -> str:
276
- """Render the data and verification an ``unknown`` mutation envelope carries.
277
-
278
- An unknown mutation outcome may already have committed, so the default
279
- (pretty) mode must show the read-only reconciliation and
280
- ``mutation_replayed: false`` the operator needs before acting again, not
281
- just the bounded error line. Any other envelope renders as that one line.
275
+ # Outcomes that carry a written-mutation evidence block. ``unknown`` means the
276
+ # write may have committed and ``failed`` means it was provably sent but the
277
+ # readback (or the server's refusal) says it did not land: both must show the
278
+ # operator what was sent and what the saved state is. A plain read failure has
279
+ # no such block and stays one line.
280
+ _EVIDENCED_OUTCOMES = ("unknown", "failed")
281
+
282
+
283
+ def outcome_details(envelope: dict) -> str:
284
+ """Render the data and verification a sent-mutation envelope carries.
285
+
286
+ An unknown mutation outcome may already have committed, and a failed one was
287
+ provably sent, so the default (pretty) mode must show the read-only
288
+ reconciliation, ``mutation_replayed: false`` and the ``saved_state`` the
289
+ operator needs before acting again, not just the bounded error line.
290
+
291
+ The block is gated on the envelope carrying ``data`` evidence, not on the
292
+ outcome alone: a local refusal built by ``failed(..., read_only=False)``
293
+ (every ``run_domain(read_only=False)`` refusal: teams, policies,
294
+ departments, guardrails, agents) was never sent, has no evidence to show,
295
+ and stays one line even though it carries a ``not_applicable`` verification.
282
296
  """
283
297
 
284
- if envelope.get("outcome") != "unknown":
298
+ if envelope.get("outcome") not in _EVIDENCED_OUTCOMES:
285
299
  return ""
286
- lines: list[str] = []
287
300
  data = envelope.get("data")
288
- if isinstance(data, dict) and data:
289
- lines.extend(_detail_lines(data))
301
+ if not isinstance(data, dict) or not data:
302
+ return ""
303
+ lines = _detail_lines(data)
290
304
  verification = envelope.get("verification")
291
305
  if isinstance(verification, dict) and verification:
292
- if lines:
293
- lines.append("")
306
+ lines.append("")
294
307
  lines.append("verification:")
295
308
  lines.extend(_detail_lines(verification, 1))
296
309
  return "\n".join(lines)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "healthcloud-cli"
7
- version = "3.7.8"
7
+ version = "3.7.9"
8
8
  description = "Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"