Research: meta.json merge driver honours the merge base
Decision: base-aware per-key three-way merge for ordinary merges, two-way opt-out for the mission→target leg (every strategy)
- Decision:
reconcile_meta_payloadsgains an optionalbase; with a non-empty ancestor it merges per key (one-sided change/deletion wins, equal collapses, genuine conflict → today's precedence), with coupled key groups moved as a unit; the consolidation pipeline sets an opt-out so its own merges keep the two-way rule. - Rationale: the #5460 merge has disjoint changes on the two sides (ancestor
8a24129: A changedcoordination_branch/discarded_at/flattened/topology, B changedvcs/vcs_locked_at), so a three-way rule yields the correct record with no conflict at all; a squash records no ancestry, so after a reopen its%Ois the stale fork point and three-way would resurrect removed content (post-spec review MAJOR-3) — hence the opt-out (Decision01M493BC3KPSC4FT6XSC7ESNDN). - Alternatives considered: three-way everywhere with the stale-ancestor residual pinned (rejected: a known wrong result on reopen + re-consolidate, and squash results would move); passing the previous squash commit as the ancestor (rejected: needs squash bookkeeping that does not exist); failing closed on genuine conflicts (out of scope, C-003).
Decision: an explicit MISSING sentinel, not _merge_field
- Decision: a module-level sentinel distinguishes "key absent" from
null;_merge_field(acceptance/issue matrices) is not reused. - Rationale:
_merge_fielddocuments that it treats removed andNoneidentically; the meta driver already distinguishes them (key not in ours),mission_number: nullis a meaningful value, and NFR-001 needs deterministic key presence (review MINOR-3). - Alternatives considered: reuse
_merge_field(rejected for the reason above); treatnullas absent (rejected: breaks the #4900 rule and byte stability).
Decision: coupled key groups
- Decision: three groups merge as single units — flatten triple (
coordination_branch,topology,flattened), merge bookkeeping (merged_*), acceptance stamps (accepted_at,accepted_by,accepted_from_commit,acceptance_mode,accept_commit).vcs/vcs_locked_atstay individual keys (independent provenance). - Rationale: per-key merging can produce a half-flattened record (
flattened: truewithtopologyrestored) or a half-deletedmerged_*block (review MAJOR-2); the groups mirror the writersflatten_coordination_metadata,_MERGE_FIELDSandrecord_acceptanceinmission_metadata.py. - Alternatives considered: per-key only (rejected); deep-merging nested values (rejected: out of scope, values compared whole, NOTE-1).
Decision: the mission_number guard applies regardless of ancestor
- Decision: after the per-key choice, if the chosen
mission_numberis unassigned and the other side's is assigned, the assigned value wins. - Rationale: scoping #4900 to genuine conflicts lets
base 5 / ours null / theirs 5resolve tonull(review MAJOR-4); every #4900 test uses an absent or{}ancestor and would not catch it.
Decision: red for the right reason
- Decision: the file-level red test calls
run_meta_driver(O, A, B)(pre-existing signature) with a real ancestor file; the real-git test registers the driver as a recording wrapper aroundsys.executable -m specify_cli merge-driver-metaand asserts git exit 0 and the invocation marker before any content assertion;git rebaseof the same branches is the positive control (green before and after); a squash-opt-out fixture proves two-way vs three-way differ and the pipeline env selects two-way. - Rationale: a stale install or an unregistered driver makes git conflict (non-zero) and would red the test without a content defect; a new reconciler signature is red with a
TypeError(review MAJOR-5, MINOR-5/6).
Decision: goldens
- Decision: the five existing golden directories are left byte-identical (zero diff in the PR); three new cases (
base-one-sided-delete,base-both-changed-precedence,base-empty-file) are hand-authored with the correctexpected_Ain the red commit and anotenaming how the pre-fix driver differs; thebase-absentnote is reworded ("absent ancestor selects the two-way rule"). - Rationale:
_capture.pyregeneratesexpected_Afrom whatever driver is installed, so a post-fix capture would silently green-wash (review MAJOR-6, MINOR-2).
Residuals recorded (not fixed here)
merge_historyis append-only but stays whole-value; a genuine both-sides append loses one side's entry (as today).acceptance_historyunion can exceedHISTORY_CAPby restoring a trimmed entry (as today).- Safety-relevant booleans (
retain_*,commit_to_target) resolve by precedence on a genuine conflict; failing closed is C-003 out of scope. - A criss-cross merge whose virtual ancestor carries conflict markers fails loud (accepted).
- Merge vs rebase flip which side is "ours"; precedence therefore flips on a genuine conflict (documented, unchanged from today's two-way behaviour).
Supersession check (grounding, 2026-10-06, origin/main 0d4c7583e)
Not superseded: the meta driver body is unchanged since #4900 (d7b6450ce); 5b5699e50 only added the decision-index driver; no [Unreleased] changelog entry, ADR or open PR touches the meta driver; #4933 is closed; #5292, #4316, #4169 are out of scope. The issue's reproducer gives trigger: lost / nodriver: conflict on current main.
Supply-chain security check (DIRECTIVE_051)
No dependency is added, upgraded or removed; the supply_chain_security_check step is satisfied as not applicable. No adversarial pass needed on this axis; the adversarial pass that was run (post-spec, reviewer-renata) is recorded above through its dispositions.
Decision: the opt-out travels as an environment variable on the one merge subprocess
- Decision:
SPEC_KITTY_META_MERGE_TWO_WAY=1is set only on the mission→target merge subprocess, for every strategy (_run_squash_mergefor the squash,_with_meta_two_wayon the merge strategy'sgit mergewhen_merge_branch_intogetsmeta_two_way=True); the REBASE strategy stays base-aware because it replays each commit on its real parent._make_merge_envstrips an exported value so no other merge can inherit it. - Alternatives considered: a
-c merge.spec-kitty-meta.driver=...override (reaches the driver's children the same way, throughGIT_CONFIG_PARAMETERS, but needs a new driver option, which changes the pinned help text and completion manifest); a new argv on the driver (same help/completion change, and.gitattributes-registered drivers have no per-invocation argv).