Skip to content

Merging

All domain entities — Scope, UserStory, and AcceptanceCriterion — inherit from MergeableModel (provided by the pleroma library). This gives them controlled merge semantics: a child entity overrides a parent entity field by field.

Scalar field merging

For any simple field (str, bool, int, Enum), the child's value replaces the parent's:

from fushinryu_model import AcceptanceCriterion

parent = AcceptanceCriterion(id=1, given="a user is logged in", then="the dashboard is shown")
child  = AcceptanceCriterion(id=1, given=None, then="the profile is shown")

merged = AcceptanceCriterion.merge([parent, child])
# merged.given == "a user is logged in"   ← parent kept (child was None)
# merged.then  == "the profile is shown"  ← child wins

None values from the child do not override a non-None parent value unless overwrite_none=True is passed explicitly.

Collection merging with merge_by_id

Collections of entities (acceptance criteria within a user story, user stories within a scope) are merged using the internal merge_by_id utility: entities are keyed by their integer id; overlapping ids are merged recursively; non-overlapping entities from both sets are included as-is.

from fushinryu_model import AcceptanceCriterion, UserStory, UserStoryType

parent_story = UserStory(
    id=1, who="developer", what="X", why="Y", type=UserStoryType.FUNCTIONAL,
    acceptance_criteria=frozenset([
        AcceptanceCriterion(id=1, then="original condition"),
        AcceptanceCriterion(id=2, then="only in parent"),
    ]),
)
child_story = UserStory(
    id=1, who="developer", what="X", why="Y", type=UserStoryType.FUNCTIONAL,
    acceptance_criteria=frozenset([
        AcceptanceCriterion(id=1, then="overridden condition"),  # same id → merged
        AcceptanceCriterion(id=3, then="only in child"),          # new id → added
    ]),
)

merged = UserStory.merge([parent_story, child_story])
# AC id=1 → "overridden condition"
# AC id=2 → "only in parent"
# AC id=3 → "only in child"

Validation record merging

Validation records follow different rules depending on their type.

Type Merge behaviour
ManualValidation All records from both parent and child are accumulated
AutomatedValidation Deduplicated by (source, name); the record with the most recent timestamp wins

This means manual review evidence is never discarded, while automated test results are automatically superseded by their latest run.

Rule category merging

Scope.rule_categories follows different rules from other collections because categories form a tree, not a flat list keyed by integer id.

Tree-position matching

When two scopes are merged, categories are matched by their name string at each level. Categories present in only one scope are included unchanged. Categories present in both are merged recursively.

from fushinryu_model import Category, Commandment, Ruleset, Scope

c1 = Commandment(body="Rule from parent")
c2 = Commandment(body="Rule from child")
cat_parent = Category(name="quality", applicability="When writing code",
                      ruleset=Ruleset(commandments=frozenset({c1})))
cat_child  = Category(name="quality", applicability="When writing code",
                      ruleset=Ruleset(commandments=frozenset({c2})))

parent = Scope(id="base", name="Base", rule_categories=frozenset({cat_parent}))
child  = Scope(id="ext",  name="Ext",  rule_categories=frozenset({cat_child}),
               parents=(parent,))

flat = child.collapse()
merged_cat = next(c for c in flat.rule_categories if c.name == "quality")
# merged_cat.ruleset.commandments == {c1, c2}  ← union of both

Commandment merging

Commandments from both parent and child are always accumulated (set union, deduplicated by body). A commandment appearing in both scopes is kept once.

Suggestion merging

Suggestion merging depends on the child category's additive flag:

additive value Behaviour
False (default) Child's suggestions replace the parent's entirely
True Child's suggestions are unioned with the parent's (deduplicated by body)
s_parent = Suggestion(body="Consider X")
s_child  = Suggestion(body="Consider Y")

# additive=False (default): child replaces parent
cat_p = Category(name="style", applicability="Always",
                 ruleset=Ruleset(suggestions=frozenset({s_parent})))
cat_c = Category(name="style", applicability="Always", additive=False,
                 ruleset=Ruleset(suggestions=frozenset({s_child})))
# merged suggestions → {s_child} only

# additive=True: union
cat_c_additive = Category(name="style", applicability="Always", additive=True,
                          ruleset=Ruleset(suggestions=frozenset({s_child})))
# merged suggestions → {s_parent, s_child}

Subcategory merging

Subcategories within a category are merged by the same tree-position rules, applied recursively. The full rule-category tree is therefore merged in a single recursive pass during Scope.collapse().

Role merging

Scope.roles is a frozenset[Role] matched by Role.name. When two scopes are merged:

  • Roles present in only one scope are included unchanged.
  • Roles present in both scopes are linked: the child's role receives the parent scope's role as its parent field, establishing an explicit hierarchy chain.

Description composition

The child's description_mode field controls how the two descriptions are combined:

description_mode Result
OVERRIDE (default) Child's description replaces the parent's entirely
EXTEND Parent's description prepended to child's, separated by a blank line
PREPEND Child's description prepended to parent's, separated by a blank line

Scope.collapse() calls Role.flatten() on every role in the result, resolving and removing all parent chains. The leaf role's field values (including the already-composed description) are preserved as the effective values.

from fushinryu_model import DescriptionMode, Role, Scope

r_parent = Role(name="developer", description="Base developer")

# OVERRIDE (default): child description replaces parent
r_override = Role(name="developer", description="Senior developer")
parent = Scope(id="base", name="Base", roles=frozenset({r_parent}))
child  = Scope(id="ext",  name="Ext",  roles=frozenset({r_override}), parents=(parent,))
flat = child.collapse()
role = next(r for r in flat.roles if r.name == "developer")
# role.description == "Senior developer"

# EXTEND: parent prepended to child
r_extend = Role(name="developer", description="Senior developer", description_mode=DescriptionMode.EXTEND)
child2 = Scope(id="ext2", name="Ext2", roles=frozenset({r_extend}), parents=(parent,))
flat2 = child2.collapse()
role2 = next(r for r in flat2.roles if r.name == "developer")
# role2.description == "Base developer\n\nSenior developer"

# PREPEND: child prepended to parent
r_prepend = Role(name="developer", description="Senior developer", description_mode=DescriptionMode.PREPEND)
child3 = Scope(id="ext3", name="Ext3", roles=frozenset({r_prepend}), parents=(parent,))
flat3 = child3.collapse()
role3 = next(r for r in flat3.roles if r.name == "developer")
# role3.description == "Senior developer\n\nBase developer"

Role competence merging

RoleCompetence bindings within a role use max-value semantics. When the same competence appears in both parent and child role:

  • relevance → the higher of the two values wins.
  • proficiency_threshold → the higher of the two values wins.

The two fields are evaluated independently. This guarantees child scopes can only raise, never lower, inherited requirements.

Role assignment merging

Scope.role_assignments uses child-override semantics keyed by the (employee.id, role.name) pair. When the same pair appears in both parent and child scope, the child's RoleAssignment replaces the parent's entirely. Assignments present in only one scope are included unchanged.

Procedure merging

Scope.procedures uses child-override semantics keyed by Procedure.id. Procedures are non-mergeable: when the same id appears in both parent and child scope, the child's Procedure — its entire node graph — replaces the parent's wholesale. There is no field-level or node-level merging, unlike RoleAssignment's field-preserving replacement. Procedures present in only one scope are included unchanged.

from fushinryu_model import EndTerminatorNode, Procedure, Scope, StartTerminatorNode

def _linear(description: str) -> Procedure:
    return Procedure(
        id="review",
        description=description,
        nodes=frozenset({
            StartTerminatorNode(id="s", description="start", next="e"),
            EndTerminatorNode(id="e", description="end"),
        }),
    )

parent = Scope(id="base", name="Base", procedures=frozenset({_linear("base version")}))
child  = Scope(id="ext",  name="Ext",  procedures=frozenset({_linear("ext version")}), parents=(parent,))

flat = child.collapse()
# next(iter(flat.procedures)).description == "ext version"  ← child replaces parent wholesale

Risk merging

Scope.risks is merged with merge_by_id, keyed by integer id — the same rule as user_stories and backlog. Overlapping ids are merged recursively via Risk.merge; non-overlapping risks from both sides are included as-is.

Within a Risk, most fields (including potential_assessment, residual_assessment, and rasci) follow plain scalar-field override: the child's value replaces the parent's when set. risk_controls is the exception — it accumulates (set union, deduplicated by full field equality), since RiskControl has no id of its own to key a child-override merge on.

from fushinryu_model import Risk, RiskAssessment, RiskControl, Scope

assessment_high = RiskAssessment.assess(0.8, 0.8, 0.8)
assessment_low = RiskAssessment.assess(0.2, 0.2, 0.2)

parent_risk = Risk(
    id=1, description="Vendor lock-in",
    potential_assessment=assessment_high, residual_assessment=assessment_high,
    risk_controls=frozenset({RiskControl(description="Negotiate exit clause")}),
)
child_risk = Risk(
    id=1, description="Vendor lock-in",
    potential_assessment=assessment_high, residual_assessment=assessment_low,
    risk_controls=frozenset({RiskControl(description="Adopt an abstraction layer")}),
)

parent = Scope(id="base", name="Base", risks=frozenset({parent_risk}))
child = Scope(id="ext", name="Ext", risks=frozenset({child_risk}), parents=(parent,))

flat = child.collapse()
merged_risk = next(iter(flat.risks))
# merged_risk.residual_assessment == assessment_low                    ← child wins (scalar override)
# len(merged_risk.risk_controls) == 2                                  ← union, not override

History and comment merging

Historized.history and Commentable.comments both use accumulate (union) semantics — the same behaviour as ManualValidation records. When two scopes are merged, every entry from both the parent and child is kept. No history entry and no comment is ever discarded.

This means the audit trail for a UserStory, Task, or Role is monotonically growing: adding history entries or comments in a child scope never removes entries inherited from the parent.

When merging is used

Merging is the mechanism that powers scope hierarchy collapse. When Scope.collapse() is called, every scope in the ancestry is merged from lowest to highest precedence, producing a single flat scope with all inherited stories and criteria resolved.