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
parentfield, 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.