feat(cluster): record policy applies_to bindings in the applied revision

Slice 5A of RFC-005: the state ledger becomes serving-sufficient for the
Phase-5 server boot. StateResource gains an optional applies_to (normalized
typed refs: cluster | graph.<id>), written by apply for every applied policy
create/update from the desired config's validated bindings.

The hole this closes: applies_to is not part of the policy file digest, so a
binding-only edit previously produced NO plan change at all (a 4C e2e even
asserted that — the gap, not a contract). Binding changes are now
first-class: a post-diff pass emits an Update with equal before/after
digests and a binding_change marker (visible in plan/apply JSON and human
output as [bindings]), classification/execution treat it as an ordinary
catalog-tier applied change (payload skips naturally — the blob is
unchanged), and convergence requires zero binding divergence, so stale
bindings can never report converged. Pre-5A ledger entries (no bindings
recorded) surface as the same backfill Update; one apply heals them, exactly
the remedy RFC-005's boot-error path names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aaltshuler 2026-06-10 15:30:33 +03:00
parent 3e8f103804
commit 0b84b1adc3
3 changed files with 225 additions and 12 deletions

View file

@ -815,7 +815,8 @@ fn print_cluster_plan_human(output: &PlanOutput) {
output.approvals_required.len()
);
for change in &output.changes {
println!(" {:?} {}", change.operation, change.resource);
let bindings = if change.binding_change { " [bindings]" } else { "" };
println!(" {:?} {}{bindings}", change.operation, change.resource);
if let Some(migration) = &change.migration {
if !migration.supported {
println!(" migration UNSUPPORTED:");
@ -862,16 +863,17 @@ fn print_cluster_apply_human(output: &ApplyOutput) {
fn print_cluster_apply_changes(changes: &[omnigraph_cluster::PlanChange]) {
for change in changes {
let bindings = if change.binding_change { " [bindings]" } else { "" };
match (&change.disposition, change.reason.as_deref()) {
(Some(disposition), Some(reason)) => println!(
" {:?} {} [{disposition:?}: {reason}]",
" {:?} {}{bindings} [{disposition:?}: {reason}]",
change.operation, change.resource
),
(Some(disposition), None) => println!(
" {:?} {} [{disposition:?}]",
" {:?} {}{bindings} [{disposition:?}]",
change.operation, change.resource
),
_ => println!(" {:?} {}", change.operation, change.resource),
_ => println!(" {:?} {}{bindings}", change.operation, change.resource),
}
}
if changes.is_empty() {