Triaging a twin candidate

The twin_type_declaration detector reports two declarations with the same field shape. Shape is what a detector can see. It is not what decides whether the declarations are one thing written twice.

The rule, stated once:

Shape similarity is not semantic identity. Two declarations that look alike are frequently two meanings that happen to have the same fields, and collapsing them is how a distinction the code was carrying gets destroyed.

Shape similarity is not semantic identity. Two declarations that look alike are frequently two meanings that happen to have the same fields, and collapsing them is how a distinction the code was carrying gets destroyed.

Shape similarity is not semantic identity. Two declarations that look alike are frequently two meanings that happen to have the same fields, and collapsing them is how a distinction the code was carrying gets destroyed.

Why acting wrongly is not neutral

This is not a caution about being careful. It is a measured result. Under #6761 four candidates were investigated in sequence and all four were wrong in the same direction — each looked like a mechanical collapse and was not one. The two changes that were worth making both went the opposite way: they made a distinction the code had lost visible again.

Four discriminators

Read a finding as a question raised, never as a verdict returned.

1. Lifecycle plus a disposition, not one lifecycle

Collapsing a false twin is worse than leaving it alone, because the union type makes previously-impossible states representable. When the wider type is also serialized somewhere durable, the over-wide shape becomes a persisted format that permits values no reader can interpret. Leaving a twin costs a hand-synced copy. Collapsing a false one costs a format.

Each of these fired on a real candidate in this repository. Symbols are given rather than line numbers, because line numbers rot and a document that cannot notice its own staleness is the thing this page exists to warn about.

CleanupState, FinalizationState, and ArtifactRetentionStatus in crates/contracts/homeboy-lifecycle-contract/src/run_lifecycle_record.rs share Pending and Failed, and two of the three share Running and Succeeded. That is most of a lifecycle, three times.

rust
cleanup.state = Retained;             // meaningless
artifact_retention.status = Preserved; // meaningless

They are not three spellings of one enum. ArtifactRetentionStatus has no Running and no Succeeded, and Retained is not a phase — it is the outcome of a retention decision that has already finished. Preserved belongs only to cleanup, NotRequested only to finalization.

A union is roughly a dozen variants of which each subsystem legitimately uses six or seven, so nonsense typechecks:

Ask: does one side carry terminal outcomes the others lack? Then the shared part is the phase, and the outcome is a second, per-subsystem field.

2. A different vocabulary, not a different format

There is real duplication inside this one, and it is worth fixing on its own: FinalizationState::NotRequested and ArtifactRetentionStatus::NotApplicable are two spellings of "this did not apply". Collapsing those carries none of the risk above.

Tracked as #13398. Note that the issue’s own snapshot has already drifted from the code — it describes three not-applicable spellings and variants (Expired, Deleted, Blocked) that no longer exist. Check the enums before quoting it.

JobStatus in crates/contracts/homeboy-api-jobs-contract/src/types.rs carries two functions returning &'static str, which reads as one value formatted two ways. Only one was redundant.

as_str returns what #[serde(rename_all)] already produces. That one was a copy, and it is now pinned to the serialized form by a test.

run_status_label spells the terminal states pass and fail, and homeboy runs list --status pass filters on those strings. Merging it into as_str breaks that filter through a string comparison with no compile error. It is kept, and a test pins the distinction because the merge is what a reader who sees "two label functions" reaches for first.

3. A parse boundary, where the narrowing is the work

Ask: do the two spellings have different consumers? A CLI filter, a wire contract, a durable record. If merging changes what some consumer matches on, they are two vocabularies and the resemblance is a coincidence.

Landed as #13396.

A type that takes Option<Value> off the wire and a type that narrows it to Option<Map<String, Value>> share every field name. They are not duplicates. The narrowing is the entire purpose of the second declaration.

4. One sequence under several bindings

The detector already handles this: it groups on each field’s resolved type_id rather than its name, so an unresolved shape is skipped instead of reported. See the module documentation in crates/homeboy-code-audit/src/detectors/twin_types.rs.

Ask: is one side the narrowed or validated form of the other? Then the pair is a boundary, and the second declaration is what makes the boundary checkable.

dispatch_with_provider_requirements in crates/homeboy-agents/src/agent_task_dispatch_service.rs and run_loaded_plan_with_derived_cook_baseline_in_optional_store in crates/homeboy-agents/src/agent_task_service/execution.rs both build a harvest context, mark running, run the scheduler, and record an aggregate. About twenty-five lines, written twice.

They differ on four independent axes — whether a lifecycle store is threaded, whether a derived cook baseline is carried, which scheduler entry point is called, and whether snapshot attestation runs — and they return different shapes. A facade covering all of that is a four-parameter configuration object standing in front of twenty-five lines, in one of the repository’s most defect-dense crates. A third consumer, AgentTaskFanoutScheduler, was not in the original count.

Two signals that are worth acting on

Ask: how large is the shared part relative to the axes of variation? If unifying requires an option per caller, the sequence was never the abstraction.

A projection that claims to be total and is not

Declined under #6761, with the analysis recorded there.

The rule above is mostly about restraint. These are the shapes that justify a change, and both of them add enforcement rather than removing a declaration.

Score a candidate on "does this remove a place where a correct-looking change is wrong" — never on lines removed.

A copy with nothing tying it to its source

AgentTaskRunState‘s projection carried a doc comment asserting the two enums "share every agent-task variant 1:1". The match beneath it was eight variants to six. CandidateRecoverable, PartialRecoverable, and PartialFailure all collapsed to PartialFailure, so an orchestrator reading the projected state could not tell "there is a promotable candidate" from "this partially failed".

Landed as #13395.

The reason the first case survived

A hand-written function that restates a value some other mechanism already derives — a serde attribute, a config key, a filename convention — will drift, because nothing fails when the two disagree.

The fix is usually not to merge the copy. It is to pin it: a test asserting that the copy still equals its source. That keeps a genuinely useful second spelling while removing the drift.