Skip to content

FM-401: observed-state normalization, the difference model, and the planner - #94

Merged
Andreas-Froyland merged 4 commits into
mainfrom
fm-401-observed-diff-planner
Sep 19, 2026
Merged

Andreas-Froyland merged 4 commits into
mainfrom
fm-401-observed-diff-planner

Conversation

@Andreas-Froyland

@Andreas-Froyland Andreas-Froyland commented Sep 19, 2026

Copy link
Copy Markdown
Member

Closes #90 (FM-401). Part of epic #6.

What

  • Difference model (fleet-core/src/difference.rs): the documented drift vocabulary — missing, extra, changed, unknown, unsupported — with one comparison point (compare_field) every normalizer funnels through. unknown and unsupported are honest terminal states: never coerced into changed, never actionable, and an unknown blocks the readiness claim while an unsupported field is reported without blocking it.
  • Observed-state normalization (fleet-application/src/observed.rs): capability facts (tool presence + versions), checkout discoveries, and Skills Manager deployment status normalize into the desired resources' shapes through a provider-agnostic input surface. Normalization is total: every observation becomes a field value or an explicit unknown, never dropped silently. Desired tool sets are additive, not exclusive — an observed tool with no desired counterpart is not drift.
  • Planner (fleet-application/src/planner.rs): a pure function from a difference set to dependency-ordered actions — clone before install before skills — with contiguous order numbering. unknown and unsupported differences are carried in the plan's unactionable list, reported and never acted on. The dry-run rendering is one serializer shared by the API, web, and fleetctl --output json, so the three surfaces cannot drift.
  • No execution: the planner produces a plan document; nothing here creates an operation.

Tests

  • Difference model: stable serialization of all five states, every comparison combination, actionability rules, canonicalization (sort + dedupe), convergence semantics.
  • Normalization: fact shapes (versioned/unversioned tools), honest gaps, provider-agnostic checkout observations (unavailable is not an observation), per-agent skills, comparison coverage (match/change/missing/extra), unanswered inventory → honest unknown, converged machine → empty set.
  • Planner: dependency order, contiguous numbering, unknown/unsupported refusal, purity (same set → same plan), dry-run round-trip, extra checkout plans a clone.

cargo xtask verify passes.


Summary by cubic

Implements FM-401's observed-state normalization, difference model, and apply planner so machine observations compare against desired resources into a five-state drift vocabulary and produce dependency-ordered dry-run plans. Closes #90.

Behavior

  • Normalization is total: every observation becomes a field value or explicit unknown, and per-surface answered flags make an unanswered inventory, deployment status, or discovery an honest unknown, never an actionable absence.
  • Tool facts keep their own availability — absent is always missing, regardless of any version string — and version output is canonicalized so an installed tool doesn't stay changed because of a command prefix.
  • unknown and unsupported are terminal states: never planned, only unknown blocks readiness, and duplicate identities resolve toward these states so a planner can't act on a guess.
  • Desired tool sets are additive; observed-only skills and checkouts are extra, and a checkout with no readable remote is unknown.
  • The planner orders clone before install before skills; extra skills plan undeploy, while extra tools/checkouts and unresolved identities are reported as unsupported, never silently dropped.
  • The plan is a document only, and one dry-run serializer is shared by the API, web, and fleetctl --output json.

Tests

  • Adds coverage for the difference model, normalization edge cases, planner ordering and purity, and dry-run round-trip.

Written for commit d9591e3. Summary will update on new commits.

Review in cubic

…lanner

The difference model (fleet-core/src/difference.rs) carries the
documented drift vocabulary — missing, extra, changed, unknown,
unsupported — with one comparison point every normalizer funnels
through. unknown and unsupported are honest terminal states: never
coerced into changed, never actionable, and an unknown blocks the
readiness claim while an unsupported field is reported without blocking.

Observed-state normalization (fleet-application/src/observed.rs) maps
capability facts, checkout discoveries, and Skills Manager deployment
status into the desired resources' shapes through a provider-agnostic
input surface. Normalization is total: every observation becomes a
field value or an explicit unknown, never dropped silently. Desired
tool sets are additive, not exclusive — an observed tool with no
desired counterpart is not drift.

The planner (fleet-application/src/planner.rs) is a pure function from
a difference set to dependency-ordered actions — clone before install
before skills — with contiguous order numbering and a single dry-run
serializer shared by the API, web, and fleetctl so the surfaces cannot
drift. unknown and unsupported differences are carried in the plan's
unactionable list, reported and never acted on.

Tests: seven difference-model tests (stable serialization, every
comparison combination, actionability, canonicalization, convergence),
seven normalization tests (fact shapes, honest gaps, provider-agnostic
checkouts, per-agent skills), and seven planner tests (dependency
order, contiguous numbering, unknown/unsupported refusal, purity, dry
run round-trip, extra checkout).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/fleet-application/src/observed.rs Outdated
Comment thread crates/fleet-application/src/observed.rs Outdated
Comment thread crates/fleet-core/src/difference.rs
Comment thread crates/fleet-application/src/planner.rs
Comment thread crates/fleet-application/src/observed.rs
Comment thread crates/fleet-application/src/observed.rs
Comment thread crates/fleet-core/src/difference.rs
Comment thread crates/fleet-application/src/observed.rs Outdated
Comment thread crates/fleet-application/src/observed.rs Outdated
Comment thread crates/fleet-application/src/observed.rs Outdated
Availability is honest end to end. The observed state carries per-surface
answered flags (mise, Skills Manager deployment status, checkout
discovery): an unanswered surface makes its desired fields honest
unknowns carrying their desired values, never actionable absences. Tool
facts preserve their own availability (unknown and stale statuses are
unknown, not absent), and provider version output is canonicalized to
the bare version so an installed tool no longer stays changed because of
a command-formatted prefix.

Comparison is bidirectional: observed-only skills and checkouts become
extra so stale deployments and abandoned checkouts can be undeployed or
reported, and a known checkout with no readable remote is an honest
unknown — cloning a duplicate would be worse than reporting. Identities
escape their delimiters through the shared redaction helper.

The planner is state-aware: an extra skill plans skills.undeploy, while
extra tools and checkouts have no bounded removal path and are
classified as unsupported rather than planned destructively; any
identity the planner cannot resolve is classified, never silently
dropped. Duplicate identities in canonicalization resolve by terminal-
state precedence — an honest unknown outranks an actionable state, so a
planner cannot act on a guess.

The dry-run serializer's doc states its wiring honestly: the apply
engine (FM-402) connects the surfaces when the plan gains an execution
path.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/fleet-application/src/observed.rs">

<violation number="1" location="crates/fleet-application/src/observed.rs:383">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

When no checkout is desired, a known checkout with `remote: None` is silently omitted because this loop only handles `Some(remote)`. Emit an explicit `unknown` for remote-less observations to preserve the module’s total-normalization contract.</violation>
</file>

<file name="crates/fleet-application/src/planner.rs">

<violation number="1" location="crates/fleet-application/src/planner.rs:305">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description says an extra checkout plans a clone, but this test now proves the shipped planner reports it as unsupported and creates no action. Update the PR description to describe the implemented behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/fleet-application/src/observed.rs
Comment thread crates/fleet-application/src/planner.rs Outdated
if observed.checkouts_answered == Some(true) {
let desired_remote = desired.checkout.as_ref().map(|(remote, _)| remote);
for checkout in &observed.checkouts {
if let Some(remote) = &checkout.remote

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

When no checkout is desired, a known checkout with remote: None is silently omitted because this loop only handles Some(remote). Emit an explicit unknown for remote-less observations to preserve the module’s total-normalization contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/fleet-application/src/observed.rs, line 383:

<comment>When no checkout is desired, a known checkout with `remote: None` is silently omitted because this loop only handles `Some(remote)`. Emit an explicit `unknown` for remote-less observations to preserve the module’s total-normalization contract.</comment>

<file context>
@@ -155,69 +224,171 @@ pub fn normalize_skills(skill_id: &str, deployed_to: &[&str]) -> Vec<ObservedSki
+    if observed.checkouts_answered == Some(true) {
+        let desired_remote = desired.checkout.as_ref().map(|(remote, _)| remote);
+        for checkout in &observed.checkouts {
+            if let Some(remote) = &checkout.remote
+                && desired_remote != Some(remote)
+            {
</file context>

Comment thread crates/fleet-application/src/observed.rs
"/elsewhere",
)]);
let plan = plan(&set);
assert!(plan.actions.is_empty());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

The PR description says an extra checkout plans a clone, but this test now proves the shipped planner reports it as unsupported and creates no action. Update the PR description to describe the implemented behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/fleet-application/src/planner.rs, line 305:

<comment>The PR description says an extra checkout plans a clone, but this test now proves the shipped planner reports it as unsupported and creates no action. Update the PR description to describe the implemented behavior.</comment>

<file context>
@@ -268,12 +294,16 @@ mod tests {
         )]);
         let plan = plan(&set);
-        assert_eq!(plan.actions[0].kind, "projects.clone");
+        assert!(plan.actions.is_empty());
+        assert_eq!(plan.unactionable.len(), 1);
+        assert_eq!(plan.unactionable[0].state, DifferenceState::Unsupported);
</file context>

Comment thread crates/fleet-application/src/planner.rs
Comment thread crates/fleet-application/src/observed.rs Outdated
The planner is state-aware in its reasons too: an extra skill's
undeploy describes the guarded removal, and a reclassified difference
preserves its observed value so consumers can show the installed version
or checkout root.

Availability is handled before versions: an absent tool is missing
regardless of any version string the fact carried. The unused duplicate
tool type is removed. ObservedState gains a from_observations
constructor that sets every answered flag from whether the observation
actually ran — the production composition path, so callers cannot forget
the flags and leave the planner refusing to act. A remote-less checkout
observation with no desired checkout emits an explicit unknown,
preserving total normalization.
@Andreas-Froyland

Copy link
Copy Markdown
Member Author

Amendment to the PR description: an extra checkout does NOT plan a clone — removing a checkout has no guarded path, so the planner classifies it as unsupported and reports it. The implemented behavior is proven by an_extra_checkout_is_reported_not_planned.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread crates/fleet-application/src/planner.rs Outdated
@Andreas-Froyland
Andreas-Froyland merged commit 35259ad into main Sep 19, 2026
12 checks passed
@Andreas-Froyland
Andreas-Froyland deleted the fm-401-observed-diff-planner branch September 19, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FM-401 — Add observed-state normalization, the difference model, and the planner

1 participant