Skip to content

feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) - #703

Merged
martinsifra merged 18 commits into
mainfrom
ms/dmd-1899/cli-layer-2
Sep 3, 2026
Merged

martinsifra merged 18 commits into
mainfrom
ms/dmd-1899/cli-layer-2

Conversation

@martinsifra

@martinsifra martinsifra commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Layer 2 of the merge-request stack (DMD-1899): services/merge_request_service.py over the Layer 3 namespace shipped in #556. No Layer 1 commands yet — those are DMD-1900, so none of the convention-#17 doc surfaces or E2E tests apply to this PR; unit tests only (tests/test_merge_request_service.py, 94 tests).

The design record — the L2 RFC plus the wire-truth notes every decision below is written down in — lives on branch ms/merge-requests-rfcs (RFCs deliberately do not merge to main). A full self-review pass (2026-08-27) was applied in the final commit; see "Review fixes" below.

What's in

  • Status-derivation polyfill (pure module-level functions): derived_state (the UI list badge's decision table incl. the rejected / closed-by-creator reviewer overrides), merge_blockers + mergeable (a list, so concurrent blockers don't mask each other), allowed_actions (state-machine table), viewer (is_creator/has_approved, anchored on verify_token's new admin_id). Every function reads the future server-serialized field first (DMD-1988) and falls back to the local table — the fallbacks get deleted when Connection serializes.
  • Reads: list_merge_requests (client-side state filter over the closed derived + raw vocabulary, typos refused), find_merge_request_for_branch (the resolver behind L1's optional --mr-id; a branch has at most one MR ever), get_merge_request (detail with full derived status; conflicts fetched only for open MRs).
  • Writes: create_merge_request (targets the default branch itself; refuses the default as source with a readable error instead of the backend's 404), update_merge_request, request_review / approve / request_changes — all behind the branches-merge-requests pre-flight raising FEATURE_NOT_ENABLED (a missing feature is otherwise a 403 byte-identical to a role denial; SOX-fence assumption in the comment).
  • merge: 409 remapped onto its two wire shapes — MR_NOT_READY_TO_MERGE (retryable; carries storage.mergeRequests.notReadyToMerge) vs MR_MERGE_CONFLICT (not retryable, names merge-request conflicts as the next step); backend counterpart asking for codes on every MR error is DMD-1984. Post-merge cleanup mirrors BranchService.delete_branch (conditional active_branch_id reset, sync-mapping unlink, best-effort with warnings); output says the source branch "is being deleted", never that it's gone.
  • Conflict resolution: list_conflicts, get_config_diff flattened to a per-path changed_by: ours|theirs|both classification (+ agreed: true when both sides made the identical change; ours_deleted/theirs_deleted flags for tombstoned sides), and resolve_conflict where every mode goes through the rebase endpoint: take=ours|theirs composes the full replace body from the side's diff envelope with version=theirs.version, a deleted side collapses to the delete resolution (both directions), a custom body must spell out name/rows/configuration (rebase REPLACES — a defaulted key would be silent data loss). The branch is derived from the MR itself, never caller-supplied. The UI's reset-to-default alternative for take=theirs is tracked as DMD-1987.
  • Infra: http_base now surfaces a Keboola user error's machine string code as KeboolaApiError.details["api_error_code"] (additive, all four raise sites); TokenVerifyResponse gains admin_id/admin_name; FEATURE_BRANCHES_MERGE_REQUESTS renamed to BRANCHES_MERGE_REQUESTS_FEATURE; the isDefault scan hoisted to services.base.find_default_branch_id and the config/sync/workspace copies migrated; json_utils.compute_diff split into structured compute_diff_entries + a byte-identical formatter.

Review fixes (final commit, tasks/pr-703-review.md)

The self-review found one critical wire-shape error: the diff sides were assumed flat, but the verified shape (connection ConfigurationVersionResponse + ConfigurationDiffData) nests all content under a diff envelope with version/isDeleted as side metadata — the original take/classify code would have been dead on arrival against the live API, invisibly, because the test fixtures encoded the same wrong assumption. Now recorded in docs/merge-requests-notes.md's wire-truth table. Also fixed: resolve_conflict could rebase into an unrelated branch (branch now derived from the MR), deleted-side asymmetry, _same_id on branch ids, FEATURE_NOT_ENABLED, spec'd mocks at the L3 seam, and regression tests for every finding.

Second review: wire truth vs. Connection (2026-08-27)

An adversarial Opus review verified every wire assumption against the Connection source (report: tasks/pr-703-opus-wire-review.md): 7 CONFIRMED, 3 MISMATCH, all fixed in the final commit:

  • The conflict 409 is not code-less — ExceptionConverter serializes storage.mergeRequests.validation top-level plus the conflicting configs in params.errors. The remap now matches both codes explicitly (unknown 409 codes pass through unmapped), and http_base surfaces params as details.api_error_params — the conflict list travels with the error.
  • approve exists only in in_review (from approved the backend answers 422 — the UI button offering it there is wrong); allowed_actions corrected, update added to in_merge. With the non-SOX default of 0 required approvals, approve is 422 everywhere and in_review is unreachable.
  • derive_state's rejected/self-closed rows are best-effort by wire design: reviewers[].status needs a review_requested anchor that skip_review never writes, and explicit reviewers shadow non-reviewer decisions — so on a default non-SOX project those states are underivable from reviewers[]. The UI badge has the identical blind spot (our table is its port). Documented, not re-derived client-side — the reliable fix is server-side and is now recorded on DMD-1988 (derive from the activity log).

Layer 1 RFC findings applied (2026-08-28, commit 07daa50)

Writing the Layer 1 command RFC (DMD-1900, PR #708) surfaced seven Layer 2 findings (tasks/dmd-1899-findings-from-layer1.md); all verified against Connection and applied:

  • get_config_diff derives the branch from the MR (signature now alias, merge_request_id, component_id, config_id) — the same make-the-wrong-call-unrepresentable reasoning resolve_conflict already had; the resolved branch_id is echoed, a published/canceled MR is refused readably. The old tests passed shifted positional args straight into MagicMock — rewritten to pin the wiring.
  • Every enriched return carries allowed_actions (list rows, find, create, update, transitions, merge's post-merge state) — a --json consumer answers "what can I do next" without a second call.
  • Safety fact recorded: autoMergeStrategy=immediately makes a background backend tick merge any approved MR through the same MergeProcessor under a system token (AutoMergeCandidateRepository.php:38-47, AutoMergeTickHandler.php:86) — create + request-review can end in a production merge with merge() never called. New notes section + both docstrings.
  • Docs: full MergeRequestResponse wire shape in the notes table (merge{} is nested, createdAt top-level, autoMerge* are response fields); MergeRequestVoter cited (scoped token → 403 on detail/conflicts, a different axis than role whitelisting) + 403 added to the two read rows in the layer3 doc.
  • Empty list now carries feature_enabled — 200 + [] on a project without the feature stops being indistinguishable from a genuinely empty project (the extra verify_token GET is spent only on the empty case).
  • STATE_FILTER_VOCABULARY and TAKE_MODES are public so Layer 1 enumerates them in help text and pre-validates to exit 2 (precedent: notification_service.KNOWN_EVENTS).

Known CI caveats

  • Rebased onto current main (0.92.0) — make changelog-check is green again. No version bump here: this lands in the DMD-1899/1900 stack and the bump PR owns the changelog entry (the feat(token): add token list, stop retrying non-idempotent writes (#599) #616 precedent). Note for the release PR: the entry must also cover details.api_error_params (+ api_error_params_truncated) — a cross-cutting, bounded addition to the --json error envelope on every 4xx/5xx from all clients — not just the merge-request command group.
  • tests/test_changelog_render.py fails (2 tests) whenever FORCE_COLOR is set in the environment (Warp exports FORCE_COLOR=3): Rich then emits ANSI inside the asserted substrings (New: renders bold, splitting "New: alpha thing."). Pre-existing main-branch test fragility, fully reproducible and unrelated to this PR; worth a tiny follow-up fix on main.

Review pointers

  • The derivation tables are 1:1 with the RFC section "Derived status" and DMD-1988 — that's the contract; if a table looks wrong here, it's wrong there first.
  • resolve_conflict's conflict-set check, the MR-derived branch, and onto_version = theirs.version are the service-level guards Layer 3 deliberately does not have.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

DMD-1899

martinsifra added a commit that referenced this pull request Aug 26, 2026
The big one (#2, upgraded): the diff sides' wire shape was WRONG in the
implementation and in the test fixtures that defined it. Verified against
connection (ConfigurationVersionResponse + ConfigurationDiffData OA
schemas, now recorded in the notes wire-truth table): each side is
{version, isDeleted, diff: {name, description, changeDescription,
isDisabled, configuration, rows}} -- content NESTED under diff,
version/deletion as side metadata. The classification and both take modes
now read the envelope; the flat-side code would have been dead on arrival
against the live API.

#1: resolve_conflict no longer takes branch_id -- it derives the branch
from the MR itself (branches.branchFromId), so the conflict-set guard and
the branch being written to can never disagree; a caller-supplied id could
point the REPLACing rebase at an unrelated dev branch the guard never
checked. A published/canceled MR (null branchFromId) is refused readably.

#3: take=theirs of a deleted side collapses to the delete resolution,
symmetric with ours ('production deleted it, dev changed it' is a live
conflict shape).
#4: deletion surfaces as top-level ours_deleted/theirs_deleted booleans on
get_config_diff (None = side never existed) -- it is side metadata, not a
content path.
#5: a 'both' row where the sides agree on the identical value carries
agreed: true -- agreement, not a conflict hotspot.
#2 (message half): a take side missing required envelope keys is reported
as a backend contract violation pointing at the resolved-body workaround,
not as caller error.
#6: wire ids compared via _same_id / int-coerced (find_merge_request_for_
branch, merge()'s was_active) -- a string-serialized branchFromId can no
longer silently defeat the post-merge cleanup.
#7: the feature pre-flight raises FeatureNotEnabledError carrying the new
ErrorCode.FEATURE_NOT_ENABLED (value matches the string SearchService
already emits; categorized 'configuration' like PAYG_NOT_AVAILABLE).
#8: ConfigError imported from ..errors like every other service; the
isDefault scan hoisted to services.base.find_default_branch_id and the
copies in config/sync/workspace services migrated (lib.py keeps its own
loop -- the SDK facade does not import the services layer); verify_token
is skipped when the server already serialized viewer (the polyfill's cost
dies with the polyfill); list --state validates against the closed
vocabulary instead of returning a silent count: 0 on a typo.
#9: test imports hoisted (no mid-file noqa), mocks spec'd at the L3 seam
(KeboolaClient + MergeRequests -- a renamed L3 method now fails the tests),
and regression tests added for every finding (82 tests total).

Doc drift: layer2/layer3 references updated to BRANCHES_MERGE_REQUESTS_
FEATURE, the layer3 open nit closed, tokens.py line ref fixed.

Review: tasks/pr-703-review.md (2026-08-27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra force-pushed the ms/dmd-1899/cli-layer-2 branch from 0e7e87f to 51eaa2c Compare August 26, 2026 23:40
@martinsifra
martinsifra marked this pull request as ready for review August 27, 2026 00:25
@martinsifra martinsifra changed the title feat(service): merge-request Layer 2 -- lifecycle, derived status, conflict resolution (DMD-1899) feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) Aug 27, 2026
@martinsifra
martinsifra requested review from padak and a lite review from Copilot August 27, 2026 00:25
@martinsifra martinsifra self-assigned this Aug 27, 2026

Copilot AI 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.

Pull request overview

Implements Layer 2 (services) support for Keboola “merge requests” (Branches 2.0), including client-side derived-status polyfills, merge lifecycle orchestration, and conflict resolution built on the existing Layer 3 client namespace.

Changes:

  • Adds MergeRequestService with list/get/create/update/review transitions, merge error remapping, and conflict diff/resolve workflows.
  • Introduces derived-status polyfill helpers (derived_state, merge_blockers, allowed_actions, viewer) with “server-first, fallback” behavior.
  • Extends shared infrastructure: structured diff entries, richer API error details (code/params), verify-token viewer anchoring (admin_id), and a shared default-branch resolver.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_merge_request_service.py New unit tests covering MR service behavior, derived status, merge 409 remapping, and conflict resolution flows.
tests/test_json_utils.py Adds coverage for new compute_diff_entries() structured diff walker and its formatting parity with compute_diff().
tests/test_http_base.py Adds tests ensuring API error body code and params are surfaced into KeboolaApiError.details.
src/keboola_agent_cli/services/workspace_service.py Switches default-branch detection to shared find_default_branch_id().
src/keboola_agent_cli/services/sync_service.py Uses shared find_default_branch_id() for sync init default branch resolution.
src/keboola_agent_cli/services/merge_request_service.py New Layer 2 service: lifecycle ops, derived status, merge cleanup, conflict diff/resolve.
src/keboola_agent_cli/services/config_service.py Replaces duplicated default-branch scan with find_default_branch_id().
src/keboola_agent_cli/services/base.py Adds shared helper find_default_branch_id().
src/keboola_agent_cli/models.py Extends TokenVerifyResponse with admin_id/admin_name used for viewer-relative derivation.
src/keboola_agent_cli/json_utils.py Adds DiffEntry + compute_diff_entries(); refactors compute_diff() into a formatter over entries.
src/keboola_agent_cli/http_base.py Surfaces API error code/params into KeboolaApiError.details for higher-layer branching.
src/keboola_agent_cli/errors.py Adds FEATURE_NOT_ENABLED, MR-specific error codes, and FeatureNotEnabledError.
src/keboola_agent_cli/constants.py Renames merge-request feature flag constant to BRANCHES_MERGE_REQUESTS_FEATURE.
src/keboola_agent_cli/client/tokens.py Parses verify-token admin block into TokenVerifyResponse.admin_id/admin_name.
src/keboola_agent_cli/client/merge_requests.py Updates docs/comments to new constant name and clarifies merge 409 conflict shape.
docs/merge-requests-notes.md Adds verified backend “wire truth” notes, including 409 shapes and diff/rebase envelopes.
docs/merge-requests-layer3.md Documents shipped Layer 3 surface and contracts; updated with verified conflict 409 code/params.
docs/merge-requests-layer2.md Design/decision record for Layer 2 service behavior, derivations, preflight, merge cleanup, and conflict resolution.
docs/merge-requests-layer1.md Working notes for future CLI commands and UX wording (Layer 1).
docs/error-codes.md Documents newly introduced error codes for feature gating and MR merge conflict/not-ready cases.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +371 to +373
admin_id: int | None = None
if not isinstance(mr.get("viewer"), dict):
admin_id = client.verify_token().admin_id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid catch — the skip predicate was looser than derive_viewer's server-field predicate, so a bare viewer: {} (or one with foreign keys) would skip verify_token and yield {None, None} instead of the local derivation. Fixed in cbdf1d1: both sites now share a single _server_viewer() predicate (dict AND has isCreator/hasApproved), so they cannot disagree; regression test added (test_empty_server_viewer_falls_back_to_local_derivation).

@zajca zajca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested. See the published review findings.

@zajca zajca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Selected findings from the automated read-only review.

# it nullable, but the rebase validator requires a non-empty
# trimmed string -- a null would sail through a bare presence
# check straight into a server 400.
missing = [key for key in ("name", "rows", "configuration") if key not in body]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed by Opus.

The guard exists precisely because rebase REPLACES rather than patches ("a missing key would silently wipe data, so it is refused instead of defaulted"), and the code already recognises that a present-but-null value defeats a bare presence check -- that is exactly why the name special case on the next line was added.

configuration has the same hole and a worse outcome. missing only tests key not in body, so a body carrying "configuration": None passes, and rebase_config(..., configuration=body["configuration"]) posts {"configuration": null}. Per this PR's own wire-truth table (docs/merge-requests-notes.md: RebaseRequest::mapValidatedData uses ?? new stdClass()), PHP's null-coalescing operator fires on null as well as on an absent key, so the backend substitutes {} -- the configuration body is replaced with an empty object and the rebase returns 200. No error, no warning.

Failure scenario: a caller invokes resolve_conflict(alias, mr_id, component_id, config_id, resolved={"name": "My config", "rows": [], "configuration": None}). missing is empty (all three keys present), the non-empty check applies only to name, so rebase_config POSTs {"version": N, "diff": {"name": "My config", "rows": [], "configuration": null}}. The backend's ?? new stdClass() fires on the null and REPLACES the dev-branch configuration body with {}. The call returns 200 with resolution: "custom" -- the entire configuration payload is destroyed with no error surfaced.

Reachable two ways:

  • The caller-authored path is fully under caller control. resolve_conflict(resolved={...}) is the public service entry point that Layer 1 (DMD-1900) will feed from a user-supplied JSON/YAML resolution file; a file with a null configuration is refused nowhere.
  • The take=ours|theirs path composes body from the server's diff envelope. The comment above acknowledges that envelope declares name nullable despite being required; if configuration is nullable on the same schema, a null there is indistinguishable from a legitimate one here.

Suggested fix: extend the non-null check beyond name -- treat a None value for configuration (and rows, whose []-deletes-all-rows semantics make a null equally ambiguous) as missing, so both take the existing refusal path rather than reaching rebase_config.

The existing regression test test_null_name_on_a_take_side_is_a_contract_violation covers only name; no test pins the null-configuration case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1e239b4 — the guard now refuses present-but-null for every required key (key not in body or body[key] is None), so a configuration: null (which PHP's ?? new stdClass() would turn into a silent full-body wipe) and a null rows both take the existing refusal path. A genuine rows: [] (delete-all) stays legal — pinned by test_empty_rows_list_stays_a_legal_resolution; new regressions cover null configuration on the resolved path and null rows on a take side.

return {"alias": alias, **_enrich_row(mr)}
finally:
client.close()
raise KeboolaApiError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed by Opus.

This PR added feature_enabled to list_merge_requests for exactly this ambiguity, with the rationale spelled out in the comment at line 330: "The list endpoint is ungated, so a project without the feature answers 200 + [] -- indistinguishable from a genuinely empty project, while every subsequent write would fail."

find_merge_request_for_branch reads the same ungated client.merge_requests.list() and did not get the same treatment. On a project without branches-merge-requests the loop sees an empty list and raises NOT_FOUND whose message asserts a fact it cannot know ("Branch N has no merge request") and prescribes a next step that is guaranteed to fail -- create_merge_request runs _require_merge_requests_feature first and refuses with FEATURE_NOT_ENABLED.

Failure scenario: on a project where branches-merge-requests is not enabled, find_merge_request_for_branch(alias, 123) gets 200 + [] from the ungated list endpoint, matches nothing, and raises NOT_FOUND with "Branch 123 has no merge request in project 'prod'. Create one with kbagent merge-request create." The user runs that command and gets FEATURE_NOT_ENABLED ("Merge requests are not enabled on this project") -- the first error's diagnosis and its prescribed next step were both wrong, and the actual cause was knowable from the already-loaded features cache.

This matters more than for list: the docstring identifies this method as "the resolver behind L1's optional --mr-id", so it is on the path of every merge-request command invoked without an explicit id. The user is sent in a loop -- told to create an MR, then told merge requests are not enabled.

Suggested fix: mirror the list_merge_requests treatment -- on the no-match path only (so the cost is not paid on the happy path), call client.has_feature(BRANCHES_MERGE_REQUESTS_FEATURE) before raising, and either raise FeatureNotEnabledError or qualify the message when the feature is absent. The client.close() in the finally currently runs before the raise, so the check needs to move inside the try.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1e239b4 — find_merge_request_for_branch now runs _require_merge_requests_feature on the no-match path (inside the try, so the client is still open), mirroring the list_merge_requests treatment: a featureless project gets FEATURE_NOT_ENABLED (with the SOX/enable message split) instead of a NOT_FOUND prescribing a create that cannot succeed. The check is spent only when nothing matched — test_find_happy_path_does_not_spend_the_feature_call pins that the happy path never pays the extra GET.

Comment thread src/keboola_agent_cli/http_base.py Outdated
# re-fetch data the error already delivered.
api_error_params = body.get("params")
if isinstance(api_error_params, dict) and api_error_params:
details["api_error_params"] = api_error_params

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed by Opus.

api_error_params copies the response body's params object verbatim into KeboolaApiError.details, with no size bound, at all four raise sites (401/403/404 and the generic path) -- so it now rides along on every 4xx/5xx from every BaseHttpClient subclass (KeboolaClient, AiServiceClient, DataScienceClient, AuthClient via its _raise_api_error override), not just the merge 409 this was added for. Numerous command-layer sites re-propagate it with details=exc.details (commands/data_app.py, commands/_data_app_runtime.py, commands/_data_app_git.py, commands/_semantic_layer_helpers.py), and output.py emits details into the --json error envelope whenever non-empty.

The asymmetry is the concern: api_message twenty lines below is deliberately capped at MAX_API_ERROR_LENGTH, and the comment at line 495 establishes bounding server-supplied text as the rule for this function. params gets no equivalent. The merge-conflict case is the motivating example and also the worst one -- params.errors carries one entry per conflicting configuration, so a branch with hundreds of conflicts produces a proportionally large blob. In a CLI whose --json output is consumed by agents, that lands unbounded in a context window.

Failure scenario: a merge over a branch with several hundred conflicting configurations answers 409 with params.errors listing every one. _raise_api_error copies the whole object into details["api_error_params"], MergeRequestService._remap_merge_conflict forwards details=exc.details into the MR_MERGE_CONFLICT error, and the --json error envelope emits the entire list verbatim -- while the human error message beside it was truncated to MAX_API_ERROR_LENGTH.

Suggested fix: bound what is copied (cap the serialized size, or cap list lengths inside params) so the details payload has a ceiling like the message does. Alternatively, narrow the passthrough to the specific keys a caller is documented to branch on rather than copying the whole object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1e239b4 — api_error_params is now bounded like the message beside it: over MAX_API_ERROR_PARAMS_LENGTH (8192 serialized) every top-level list is truncated to MAX_API_ERROR_PARAMS_LIST_ITEMS (20) entries; if still over, params is dropped entirely. Either way details.api_error_params_truncated: true marks it, and an unserializable payload is dropped rather than crashing the error path. Three regression tests (small passes verbatim / oversized list truncated with the kept prefix verbatim / untruncatable dropped with marker).

Comment thread docs/merge-requests-layer1.md Outdated
- `--auto-merge-strategy` accepts exactly `immediately` | `scheduled` | `none`.
- `--external-id` is capped at 255 characters server-side.
- **`--state` stays a plain `str`, not a Typer enum.** The vocabulary lives in the service
(`_STATE_FILTER_VOCABULARY`) and an unknown value already fails with the accepted list. A

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed by Sonnet.

The Layer 1 RFC says the state-filter vocabulary "lives in the service (_STATE_FILTER_VOCABULARY)", implying an underscore-prefixed private name. The constant this same PR actually defines in src/keboola_agent_cli/services/merge_request_service.py:80 is the public STATE_FILTER_VOCABULARY (no leading underscore) — its own comment says "Public: Layer 1 enumerates it in --state help text". A future implementer of DMD-1900 following this doc verbatim would try to import a name that doesn't exist. Low impact since it's a design doc for not-yet-built Layer 1, but worth a one-word fix to avoid confusion later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already resolved — the Layer 1 RFC rewrite (980b28d, now pushed as part of this branch) references the public STATE_FILTER_VOCABULARY and TAKE_MODES names (docs/merge-requests-layer1.md:86: "importing the now-public STATE_FILTER_VOCABULARY and TAKE_MODES from the service"); the underscore-prefixed mention this comment anchored to no longer exists in the current revision.

martinsifra added a commit that referenced this pull request Aug 31, 2026
The big one (#2, upgraded): the diff sides' wire shape was WRONG in the
implementation and in the test fixtures that defined it. Verified against
connection (ConfigurationVersionResponse + ConfigurationDiffData OA
schemas, now recorded in the notes wire-truth table): each side is
{version, isDeleted, diff: {name, description, changeDescription,
isDisabled, configuration, rows}} -- content NESTED under diff,
version/deletion as side metadata. The classification and both take modes
now read the envelope; the flat-side code would have been dead on arrival
against the live API.

#1: resolve_conflict no longer takes branch_id -- it derives the branch
from the MR itself (branches.branchFromId), so the conflict-set guard and
the branch being written to can never disagree; a caller-supplied id could
point the REPLACing rebase at an unrelated dev branch the guard never
checked. A published/canceled MR (null branchFromId) is refused readably.

#3: take=theirs of a deleted side collapses to the delete resolution,
symmetric with ours ('production deleted it, dev changed it' is a live
conflict shape).
#4: deletion surfaces as top-level ours_deleted/theirs_deleted booleans on
get_config_diff (None = side never existed) -- it is side metadata, not a
content path.
#5: a 'both' row where the sides agree on the identical value carries
agreed: true -- agreement, not a conflict hotspot.
#2 (message half): a take side missing required envelope keys is reported
as a backend contract violation pointing at the resolved-body workaround,
not as caller error.
#6: wire ids compared via _same_id / int-coerced (find_merge_request_for_
branch, merge()'s was_active) -- a string-serialized branchFromId can no
longer silently defeat the post-merge cleanup.
#7: the feature pre-flight raises FeatureNotEnabledError carrying the new
ErrorCode.FEATURE_NOT_ENABLED (value matches the string SearchService
already emits; categorized 'configuration' like PAYG_NOT_AVAILABLE).
#8: ConfigError imported from ..errors like every other service; the
isDefault scan hoisted to services.base.find_default_branch_id and the
copies in config/sync/workspace services migrated (lib.py keeps its own
loop -- the SDK facade does not import the services layer); verify_token
is skipped when the server already serialized viewer (the polyfill's cost
dies with the polyfill); list --state validates against the closed
vocabulary instead of returning a silent count: 0 on a typo.
#9: test imports hoisted (no mid-file noqa), mocks spec'd at the L3 seam
(KeboolaClient + MergeRequests -- a renamed L3 method now fails the tests),
and regression tests added for every finding (82 tests total).

Doc drift: layer2/layer3 references updated to BRANCHES_MERGE_REQUESTS_
FEATURE, the layer3 open nit closed, tokens.py line ref fixed.

Review: tasks/pr-703-review.md (2026-08-27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra force-pushed the ms/dmd-1899/cli-layer-2 branch from 1e239b4 to c218f2a Compare August 31, 2026 22:45
martinsifra added a commit that referenced this pull request Aug 31, 2026
Turns the working notes into an implementation-ready RFC for the
`kbagent merge-request` group over MergeRequestService (DMD-1899, #703).

Decided in this pass:

- `--mr-id` is optional everywhere, resolved `--mr-id` -> `resolve_branch()`
  -> `find_merge_request_for_branch()`; `merge` is not exempt.
- `merge` is classified `destructive`, so `--deny-destructive` lets an agent
  run the whole flow and hands only the last step to a human.
- No `--wait`/`--timeout` on merge in v1 (L3 always awaits, 600 s) and no
  `resolve --all` (rebase replaces; conflicts are meant to be walked).
- A full `server/routers/merge_requests.py` ships with the commands, plus a
  serve-only `by-branch` route; routers are not gated by CI, so a skip would
  reach users as an HTTP 404 with nothing red.

Facts the analysis surfaced that shape the commands:

- The MR serializer emits no timestamps, so no date column is possible and
  the renderer must preserve the server's `createdAt DESC` order.
- `FeatureNotEnabledError` carries `FEATURE_NOT_ENABLED`; the common
  `except ConfigError` idiom would flatten it to `CONFIG_ERROR`.
- An empty `--reviewer-id` list is sent as `reviewerIds: []` and clears the
  reviewer set -- it must be normalised to None.
- `detail`/`conflicts` 403 on a scoped token while `list` works.
- `approve` answers 422 in every state on a 0-approval project, and
  `request-review` lands directly in `approved` -- neither has a happy path
  to assert, and there is no `close` command for the same reason.

E2E is deliberately left open: no project carries the feature, kbagent
cannot provision one, and the happy path necessarily merges into
production. The RFC records the proposed path and marks it unsettled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@martinsifra
martinsifra requested review from zajca and a lite review from Copilot August 31, 2026 22:50

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Comment on lines +1015 to +1026
configuration = client.rebase_config(
component_id,
config_id,
branch_id,
version=onto_version,
name=body["name"],
rows=body["rows"],
configuration=body["configuration"],
is_disabled=bool(body.get("isDisabled", False)),
description=body.get("description"),
change_description=change_description,
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid — and independently raised as the blocking finding of the human review a day later; fixed in 01fb0fa. The guard now requires all five replaced-body keys: name/rows/configuration/isDisabled refuse absent-or-null (a null isDisabled would still reach bool() as False — the same silent re-enable), description refuses absence only — an explicit description: null is a legitimate clear-the-description decision, exactly as this comment suggests. is_disabled forwards verbatim via body["isDisabled"]. Pinned by test_missing_is_disabled_is_refused_not_defaulted and test_missing_description_is_refused_but_explicit_null_is_legal.

@zajca zajca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the full diff on the branch, ran the complete test suite and every quality gate locally.

Gate status (on the PR branch): pytest tests/ 6527 passed / 183 skipped, ruff check + ruff format --check clean, ty check clean on the new modules, make loc-check / check-sentinel-guards / changelog-check all OK. I also reproduced the FORCE_COLOR caveat — FORCE_COLOR=3 pytest tests/test_changelog_render.py does fail 2 tests, pre-existing on main, unrelated to this PR.

The design work here is genuinely good — see "What works well" at the bottom. One finding blocks.


BLOCKING

1. resolve_conflict guards 3 of the 5 replace keys — isDisabled / description silently lose data

src/keboola_agent_cli/services/merge_request_service.py:1023-1024

is_disabled=bool(body.get("isDisabled", False)),
description=body.get("description"),

Layer 3 warns about exactly this in rebase_config's own docstring (client/configs.py):

is_disabled: Required: on a missing key the backend substitutes false, re-enabling a config that was disabled.
description: … It has no default precisely because that default would silently drop an existing description.

Layer 2 reintroduces both defaults. The guard at :987-991 covers only name / rows / configuration, and its stated reasoning — "rebase REPLACES; a missing key would silently wipe data" — applies verbatim to the other two:

  • a custom resolved body without isDisabled silently re-enables a disabled configuration in the dev branch, and the merge then pushes that into production;
  • a custom body without description wipes the description;
  • the take= path has the same hole whenever the envelope omits a key — and the guard on the other three exists precisely because the envelope is not trusted.

tests/test_merge_request_service.py:892 (test_custom_body_rebases_verbatim) pins the permissive behaviour as expected: a body of {name, rows, configuration} passes and sends is_disabled=False.

Suggested fix — check for presence, not non-None (description: None is a legitimate resolved value, and the _side fixture sends exactly that):

missing = [k for k in ("name", "rows", "configuration") if k not in body or body[k] is None]
missing += [k for k in ("isDisabled", "description") if k not in body]

plus is_disabled=bool(body["isDisabled"]).


NON-BLOCKING

2. allowed_actions points at a write that cannot succeed

_enrich_row (merge_request_service.py:244) is state-only. On a project without the feature, list_merge_requests returns feature_enabled: false and rows carrying allowed_actions: ["request_review", "merge", "update", "resolve_conflicts"]. A --json/agent consumer — the stated reason the field exists — is steered straight into the guaranteed FEATURE_NOT_ENABLED loop that find_merge_request_for_branch:371 deliberately closes. The features cache is already warm at that point on the empty-list path, so correcting it is free.

3. api_error_params is a cross-cutting output-contract change with no changelog line

http_base.py:485-497 adds up to 8 KB of server-supplied params to details on every 4xx/5xx from all seven clients, and existing commands already forward details=exc.details into --json (commands/data_app.py, commands/job.py:442, _semantic_layer_helpers.py, …). Two consequences:

  • consumers treating details as a presence flag (it was empty on 401/403/404 until now) start seeing content;
  • params reaches the output with no masking at all, whereas the message at least goes through truncation. Low risk, but it deserves to be a stated decision rather than a side effect.

Deferring the changelog to the bump PR follows the #616 precedent, but that entry needs to mention this too — not just "merge-request Layer 2".

4. _classify_three_way: a null side with a non-empty base emits contradictory signals

merge_request_service.py:846-852 — when one side is null while base exists, every base key comes out as "that side removed it" (changed_by: "theirs", theirs: None), while theirs_deleted reports None, meaning "the side does not exist at all". A Layer 1 renderer will make nonsense of that pair. Either skip changes for a null side or flag it separately.

5. change_description is silently dropped for take="delete"

merge_request_service.py:973-975 — rebase_config_delete has no such parameter. Layer 1 will expose --change-description, the user will pass it, and nothing reports that it was ignored. A warning in the result (or plumbing it through L3) would close it.


NIT

  • Comments narrating history/process, against the repo's own "Code Comments — never narrate the change" rule: constants.py:539 ("Renamed from FEATURE_BRANCHES_MERGE_REQUESTS"), services/base.py:124 ("previously copy-pasted"), merge_request_service.py:47,113 ("Opus wire review 2026-08-27"), :371,978 ("Zajca's PR review"). "Verified against MergeRequestLifecycleStateMachine" is a fact about the code and belongs there; dated review references and reviewer names belong in the PR description.
  • services/search_service.py:205 still uses the raw string "FEATURE_NOT_ENABLED". Now that the enum exists, this is a one-line cleanup in the same spirit as the find_default_branch_id hoist.
  • merge() — the except Exception wraps set_project_branch too, so a failed config write also skips the mapping cleanup. Two separate try blocks would keep the two cleanups independent.
  • merge() :660 — int(raw_branch_from) raises ValueError on a non-numeric wire string, i.e. an unhandled exception instead of a KeboolaApiError. _same_id exists precisely because the payload mixes id types.
  • find_default_branch_id — int(branch["id"]) raises KeyError for an isDefault: true entry with no id; the rest of the function is defensive (branch.get("isDefault")).
  • cleanup_branch_id_from_mapping(branch_from_id) matches on the numeric id alone with no project scoping — a sync workspace of a different project in the CWD with the same branch id gets unlinked. Inherited from BranchService.delete_branch, so not a regression, but merge extends it to another path.

What works well

  • The tests are above average: a regression test per review finding, MagicMock(spec=KeboolaClient) + spec=MergeRequests at the L3 seam (a renamed client method fails the tests instead of leaving them falsely green), and the _DEFAULT_SIDE sentinel distinguishing "keep the fixture default" from "the side does not exist".
  • _server_viewer as the single predicate for "did DMD-1988 land" — both derive_viewer and the verify_token skip read it, so they cannot drift apart. Right call.
  • resolve_conflict / get_config_diff deriving the branch from the MR instead of taking it as a parameter is honest make-the-wrong-call-unrepresentable design.
  • The 409 remap letting an unknown code pass through unmapped rather than guessing "conflict" is exactly right.
  • The find_default_branch_id hoist and the compute_diff → compute_diff_entries split are clean refactors with tests; I verified the formatted output stays byte-identical.
  • The layer1/2/3 + notes doc set carrying the wire-truth table is what made the nested-diff-envelope bug visible before production.

Verdict: NEEDS-WORK — finding #1 (silent isDisabled / description loss on rebase). Everything else is non-blocking.

@martinsifra

Copy link
Copy Markdown
Contributor Author

Thanks — applied in 01fb0fa. Per finding:

BLOCKING #1 (isDisabled/description) — confirmed and fixed. All five replaced-body keys are now required; one refinement over the suggested fix: isDisabled refuses absent-or-null (a null would still reach bool() as False — the exact silent re-enable), while description refuses only absence (an explicit null is a decision on a genuinely nullable field, an omission is not). is_disabled forwards verbatim. Regression tests: missing isDisabled, missing vs. explicit-null description, take-side isDisabled: true forwarded.

#2 (allowed_actions on a featureless project) — respectfully declined, with the reasoning now documented on _enrich_row. The stated scenario cannot occur: feature_enabled: false is emitted only on the empty-list path, where there are no rows to carry allowed_actions. The real residual is a project that lost the feature after MRs existed — closing that costs a verify_token GET on every non-empty list (the cache is warm only on the empty path), for a rare configuration where the pre-flight already answers any attempted write with the precise FEATURE_NOT_ENABLED. The proper fix is DMD-1988's server-side allowedActions, which can honor features.

#3 (api_error_params output contract) — agreed. The unmasked pass-through is now a stated decision in the code (server-authored error context, same trust as the message text), and the PR body's bump-PR note now says the release changelog entry must cover details.api_error_params (+_truncated) as a cross-cutting output-contract addition, not just "merge-request Layer 2".

#4 (null side vs. base) — confirmed and fixed: _classify_three_way emits no per-path rows when either side is null; the side-level fact stays on ours_deleted/theirs_deleted. Docstring + test updated.

#5 (change_description on take=delete) — confirmed; fixed with a warnings[] entry in the result. Plumbing through L3 is not possible: changeDescription lives inside the diff envelope and the delete tombstone is exactly {"version": N, "diff": {}} — the wire has nowhere to carry it (the server records its default message). Layer 1 will additionally reject the combination at exit 2 per its RFC.

Nits — all applied except the last: history-narrating comments removed (facts like "verified against MergeRequestLifecycleStateMachine" stayed, names/dates went); search_service now uses ErrorCode.FEATURE_NOT_ENABLED; merge()'s two cleanups run in independent try blocks (pinned by a test that a failed config write no longer skips the mapping unlink); wire branch ids coerce via a _coerce_branch_id helper (non-numeric degrades instead of raising); find_default_branch_id tolerates an isDefault entry without an id. The unscoped cleanup_branch_id_from_mapping is real but inherited from BranchService.delete_branch — fixing it means fixing both call sites together, so it's noted as a follow-up rather than diverging the two here.

Full triage record: tasks/pr-703-zajca-review-2.md (local). Gates on the branch: 107 service tests, ruff/format/ty clean.

@martinsifra
martinsifra requested a review from zajca September 2, 2026 20:51
martinsifra and others added 5 commits September 2, 2026 23:31
compute_diff returned pre-formatted strings, so a caller needing the
changed paths as data (the MR three-way conflict classification, DMD-1899,
intersects two pairwise diffs per path) had nothing to build on short of
parsing the strings back. Split the recursive walk into
compute_diff_entries() returning frozen DiffEntry dataclasses (path +
old/new with an _ABSENT sentinel distinct from an explicit None), and keep
compute_diff() as a formatter over it -- byte-identical output, pinned by
a delegation test against the existing format tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etails [DMD-1899]

Three pieces of error plumbing the merge-request service needs:

- ErrorCode.MR_NOT_READY_TO_MERGE / MR_MERGE_CONFLICT (+ docs/error-codes.md,
  both categorized 'conflict'). The merge 409 has four causes in two wire
  shapes; the split follows the backend's own line (the three transient
  causes carry storage.mergeRequests.notReadyToMerge, a conflict carries no
  code). Mapping happens in the service -- only it knows the 409 came from
  the merge endpoint.
- http_base: a Keboola user error's machine string `code` now survives into
  KeboolaApiError.details['api_error_code'] (all four raise sites). The
  message holds only the human `error` text, so without this no caller can
  branch on the code. Additive; no behavior change when absent.
- constants: FEATURE_BRANCHES_MERGE_REQUESTS renamed to
  BRANCHES_MERGE_REQUESTS_FEATURE (the file's dominant suffix convention;
  the constant was unused until now). SOX-fence assumption spelled out in
  the comment per the L2 RFC.

Backend counterpart asking for codes on every MR error: DMD-1984.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fy [DMD-1899]

The four derivates from the L2 RFC ('Derived status'), as pure module-level
functions in the new services/merge_request_service.py: derive_state (the UI
list badge's decision table incl. the rejected / closed-by-creator reviewer
overrides), derive_merge_blockers (a list, so concurrent blockers don't mask
each other; conflicts=None means not-fetched, not conflict-free),
derive_allowed_actions (state-only; roles/features stay with the pre-flight),
derive_viewer (is_creator / has_approved relative to the caller).

All four are a POLYFILL: they read the future server-serialized field first
(derivedState / mergeBlockers / allowedActions / viewer -- Connection issue
DMD-1988) and fall back to the local tables; the fallbacks get deleted when
DMD-1988 lands. Same defensive-read pattern as changeLog and the DMD-1969
approvals count.

verify_token now also parses the response's top-level admin block into
TokenVerifyResponse.admin_id/admin_name (additive; absent for scoped
tokens) -- the anchor derive_viewer compares creator.id and approverId
against. approverId is a string on the wire, ids are ints; comparisons
normalize via str() like the UI does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ail [DMD-1899]

The service class (BaseService DI: ConfigStore + client_factory) and its
three read methods:

- list_merge_requests: rows enriched with derived_state; --state filters
  client-side (the endpoint has no query params) and matches the derived
  vocabulary (rejected/merged/closed/...) as well as raw states.
- find_merge_request_for_branch: the branch->MR resolver behind L1's
  optional --mr-id (a branch has at most one MR ever, so the match is
  unambiguous); no MR -> NOT_FOUND naming merge-request create as the next
  step.
- get_merge_request: detail with the full derived status -- merge_blockers/
  mergeable/allowed_actions/viewer + the live conflicts list, fetched only
  for open MRs (a published/canceled MR's source branch is deleted, the
  conflicts endpoint is moot). viewer anchors on verify_token's admin id;
  a scoped token yields honest None flags. Derivations stay informational:
  the merge 409 remains the authority.

Also the write pre-flight helper (_require_merge_requests_feature) with the
SOX-fence assumption spelled out, used by the write methods that follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- create_merge_request: resolves the target itself (always the default
  branch -- the backend rejects any other) and refuses the default branch
  as source with a readable error instead of the backend's confusing 404.
  The source branch arrives explicit; L1 resolves it via the house
  resolve_branch() idiom per the RFC decision.
- update_merge_request: None = leave unchanged (the API cannot clear to
  null; empty string clears description/externalId server-side).
- request_review / approve / request_changes: thin transitions with the
  feature pre-flight; request_changes doubles as the close mechanism (no
  cancel endpoint exists -- the UI's cancel is this call by the creator,
  and derived_state renders it as closed).

Every write runs _require_merge_requests_feature first; returns are the raw
MR enriched with derived_state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@soustruh soustruh mentioned this pull request Sep 7, 2026
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…ilt, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Sep 15, 2026
…rvice (DMD-1900) (#736)

* Merge requests RFCs: general notes + per-layer (L3 as-built, L2 as-built, L1 as-implemented) [DMD-1899, DMD-1900]

The five documents, at the state that holds after PR #703 (Layer 2, merged
to main as 5281eef) and the Layer 1 implementation branch (PR #736):

- merge-requests-notes.md      verified backend facts, all layers
- merge-requests-layer3.md     the HTTP client, as shipped in #556
- merge-requests-layer2.md     the service RFC + "Additions made for Layer 1"
                               (get_merge_request_row, resolution_candidate,
                               merge() cleanup_warnings -> warnings)
- merge-requests-layer1.md     the command RFC after walking #703's review
                               findings into it (seven decisions), plus the
                               pointer to the follow-ups below
- merge-requests-layer2-followups.md  non-blocking leftovers of #703 for L1
                               (F1 done on L1; F2-F8 open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(service): the three Layer 2 additions the Layer 1 RFC needs [DMD-1900]

Decided while walking PR #703's review findings into the Layer 1 RFC
(docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR").
Each exists so Layer 1 does not re-derive something the service knows.

- get_merge_request_row(alias, id): the row tier (_enrich_row) by id --
  one GET, no conflicts(), no verify_token(). list/find already return
  rows but only by branch; the sole by-id method was the detail, three
  round trips and a dependency on the conflicts endpoint that a write
  (request-review on an armed MR, the merge confirmation prompt) has no
  business inheriting. L3's merge_requests.get() was always this GET.

- get_config_diff -> resolution_candidate: the ours envelope through
  _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription
  excluded; null when ours is absent/isDeleted. Composed in L2 so the
  `diff --output` prefill and the five-key replace guard in
  resolve_conflict share one constant -- a candidate built in L1 that
  dropped a null description would be a file kbagent writes and then
  refuses. Pinned by a round-trip test: candidate -> resolve_conflict
  unmodified.

- merge(): cleanup_warnings -> warnings. One soft-failure key for the
  group (resolve_conflict already used `warnings`); a renderer reading
  `warnings` must not silently drop the post-merge ones a user must act
  on. Specificity stays in the text.

Tests: 5 new (row tier cost pinned via assert_not_called on conflicts +
verify_token; candidate shape; null cases; round trip; warnings key).
L2 RFC updated in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): merge-request group skeleton and the four read commands [DMD-1900]

The `kbagent merge-request` group (hidden alias `mr`) over
MergeRequestService: wiring, the helpers every command shares, and
list / detail / conflicts / diff with their renderers. Writes follow.

Skeleton -- the Layer 1 decisions from docs/merge-requests-layer1.md:

- Target resolution (_resolve_target): --merge-request-id/--id optional;
  omitted -> resolve_branch() (--branch, else active branch) ->
  find_merge_request_for_branch(). Both flags at once is exit 2, not
  silent precedence. The resolution is reported on stderr in human mode
  and stamped into every --json result (merge_request_id, branch_from_id,
  resolved_from_branch) so a machine caller can assert on what was
  operated upon.
- One error handler (_handle_error), no per-command except: keeps
  FeatureNotEnabledError's FEATURE_NOT_ENABLED code, which now surfaces
  from the resolver behind every omitted id -- reads included.
- The destructive-under-json rule and the armed-auto-merge escalation
  helpers (used by the writes next): policy check first, then the
  explicit-target rule, which for state-derived escalations can only fire
  after resolution.
- warnings[] rendered identically everywhere; hint-next Rich-only.

Permissions: 11 registry entries (merge = destructive) + the serve-only
by-branch; FLAG_ESCALATIONS gains five state/flag-derived destructive
entries (arming auto-merge on create/update; request-review / approve /
resolve on an armed MR) with the Connection citations that justify them.

Renderers (_merge_request_render.py): every wire string escaped;
derived_state never raw state; list preserves server order and shows
optional columns only when populated; empty list tells feature-off apart
via feature_enabled; detail says the change log is empty by design in
development; diff checks the *_deleted flags BEFORE the table and
recommends the --take, since a null side yields zero rows; --output
writes the service's resolution_candidate verbatim and refuses when there
is nothing to prefill. Table value columns fold rather than crop so
--format full is actually full.

Also hoists parse_json_arg into _helpers (transformation.py had the
private copy; resolve is the third consumer).

35 CLI tests via CliRunner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): merge-request writes -- create, update, transitions, merge, resolve [DMD-1900]

The seven write commands, each routed through the shared skeleton
(_resolve_target, _handle_error, _stamp_target, warnings, hint-next).

Where a human says so, and where a policy does (docs/merge-requests-layer1.md):

- merge: statically destructive. Under --json the explicit-target rule
  fires BEFORE any lookup (no --merge-request-id/--branch -> exit 2); in
  human mode the active-branch fallback stays and the prompt names the MR,
  its title and the branch that will be deleted. --yes skips the prompt.
- create/update --auto-merge-strategy immediately|scheduled: arming is a
  delayed production merge, so it escalates to destructive
  (FLAG_ESCALATIONS), needs an explicit target under --json (--branch for
  create), prompts in human mode worded as arming, and warns afterwards.
  `none` is the disarm and escalates nothing. The strategy/--auto-merge-at
  pairing is validated at exit 2.
- request-review / approve / resolve on an ALREADY-armed MR escalate via
  the state-derived operation strings; the row comes free on the implicit
  path and via get_merge_request_row (one GET, never the detail) on the
  explicit path. Under --json with an implicit target this exits 2 only
  AFTER resolution -- deliberate, the information does not exist earlier;
  the error names the MR and the flag to pass. request-changes moves the
  MR away from approved and never escalates.
- update with no field flags is exit 2 (PUT {} is a server no-op).
  --reviewer-id is normalised to None when absent -- [] would clear the set.
- resolve: exactly one of --take/--resolved (exit 2 otherwise); --resolved
  parsed via the hoisted parse_json_arg and must be an object; a
  --change-description on a delete is the service's warning, not a Layer 1
  refusal (the implicit-delete collapse is only known after the diff).

Escalations are tested against the real engine (--deny-destructive ->
exit 6), not a mocked check. 38 more CLI tests (73 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): split merge_request.py at the 800-code-line soft ceiling [DMD-1900]

829 code lines after the writes landed -- exactly what the RFC predicted
for eleven commands at ~75 each. CONTRIBUTING lets a file sit over the
soft ceiling until the next PR adds to it, but a brand-new module born
over it is debt on day one, so split now:

- merge_request.py      -- app, callback, the four reads; mounts the writes
- _merge_request_common -- what both need: option declarations, the ONE
                           error handler, target resolution, the
                           destructive-under-json rule, escalation, output
- _merge_request_writes -- the seven writes on their own Typer, mounted
                           flat via register(app) so permission keys stay
                           merge-request.* and --help lists one group
                           (precedent: _storage_describe.register)
- _merge_request_render -- unchanged

A third module instead of reads importing writes (or vice versa): both
import common, only merge_request imports writes -- no cycle. Behaviour
unchanged; 73 CLI tests green; every module well under 800.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(serve): merge-requests router, 1:1 with the CLI group, permission-enforced [DMD-1900]

server/routers/merge_requests.py: twelve routes under /merge-requests --
one per CLI command plus GET /{project}/by-branch/{branch_id}, the
branch->MR resolver the CLI hides behind an omitted --merge-request-id
(no active-branch idiom over HTTP; registered as the serve-only
merge-request.by-branch). Declared before /{project}/{merge_request_id}
so FastAPI never tries to read 'by-branch' as an id. Skipped on purpose:
`diff --output PATH` -- GET .../diff returns resolution_candidate and the
caller writes its own file.

Every route declares Depends(require_permission(...)). Until now only
/auth/* did; here it is not optional -- the CLI classifies merge as
destructive and escalates arming auto-merge and the transitions on an
armed MR (FLAG_ESCALATIONS), and without the same checks over HTTP that
analysis would be decorative for serve callers. The static class is a
route dependency; the flag/state-derived escalations run in the route
body: arming in the create/update body -> check_or_raise the flag string;
request-review/approve/resolve -> one row GET (get_merge_request_row,
never the three-call detail) and check_or_raise when armed.

Caller errors (unknown state/take, both-or-neither take/resolved, empty
update body, broken auto-merge pairing) raise INVALID_ARGUMENT -> 400,
the REST twin of the CLI's exit 2. POST .../merge documents that it is
synchronous for up to 600 s.

Wiring: ServiceRegistry.merge_request, include_router, an OPENAPI_TAGS
entry (endpoints-gen would otherwise emit an untagged section), and
docs/web-server-endpoints.md regenerated (endpoints-check green).

14 router tests: kwarg parity per route (the drift this file exists to
catch) and the permission story over HTTP -- merge 403 under
deny_destructive, arming 403 while `none` passes, armed request-review
403 via the row tier, reads pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(cli): merge-request on every convention-#17 surface; deprecate branch merge; E2E [DMD-1900]

The silent-drift surfaces, all of them (nothing but check_command_sync
gates any of this):

- CLAUDE.md "All CLI Commands": the eleven signatures plus the block that
  matters most -- what may happen without a human saying so (merge is
  destructive; arming auto-merge is a delayed production merge; the
  --json explicit-target rule; the 0-approval facts; no `close`; the
  conflict loop; the error shapes; the feature-blind allowed_actions).
- commands/context.py AGENT_CONTEXT: a Merge Requests section after
  Branches, same content compressed for the agent.
- commands-reference.md: the group's cheat sheet.
- gotchas.md: one `(since vNEXT)` entry covering every non-obvious
  behaviour the RFC listed for it.
- keboola-expert.md: a tool-selection-matrix row with the anti-patterns
  (--auto-merge-strategy treated as metadata; --json merge with no
  target; a partial --resolved body; reading allowed_actions as
  feature-aware; approve on a 0-approval project).
- SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review
  request), the description, the workflow link; decision table via
  `make skill-gen`.
- New merge-request-workflow.md: the short path, the --json path, the
  auto-merge table, the conflict loop, output semantics, the error table.
- branch-workflow.md points at the new group.

`branch merge` is deprecated with a CONDITIONAL pointer: it only builds a
UI URL (and unconditionally resets the active branch), but it works on
projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour
unchanged; `deprecation` key in --json, a warning in human mode.

E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on
the branch -> create -> list/detail/conflicts (id and --branch) -> approve
asserts the 422 -> bare --json merge exits 2 -> merge -> config in
production -> explicit teardown. GATED ON THE FEATURE: `list` on a
feature-less project answers feature_enabled: false and the suite skips
with the one-time enable command in the reason -- explicit, never silent.
The E2E project does not carry the feature today and this environment has
no E2E credentials; recorded in the ship ledger, not hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugin): vNEXT tags out of headings; SKILL description back under 1024 chars [DMD-1900]

check_version_gates: a vNEXT inside a heading would rewrite the anchor
slug on release; the three new sections carry the tag on their first body
line instead. test_skill_frontmatter: the description hit 1130/1024 chars;
kept the 'merge request' trigger, dropped the redundant ones, and
compressed three neutral list phrases (and -> /). No trigger word lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,serve,service): apply the Phase-5 self-review round [DMD-1900]

Three reviews (Opus, Sonnet, /code-review) over the implementation; every
finding either fixed here with a pin, or recorded below as deferred.

High:
- _handle_error dropped exc.details, so the merge 409's conflict list and
  the truncation marker never reached --json, and the RFC's human render
  of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was
  never implemented. Both fixed; details flow through, the list renders
  escaped, the truncation line names no number.
- Unescaped wire/user strings in the ad-hoc console.print sites outside
  the renderer module (conflicts hint, diff hint, resolve success line,
  merge message): a `[/x]` in a config id raised MarkupError AFTER the
  rebase had landed server-side. Escaped everywhere.

Medium:
- branch_from_id was null on every explicit --merge-request-id path,
  beside a payload saying branches.branchFromId: 123. _stamp_target now
  derives it from the result (row branches, diff branch_id); conflicts
  fetches the row tier (one GET) since its result carries no branch.
- The auto-merge vocabulary was copied into the CLI and the router -- the
  exact drift the RFC forbids, and a SAFETY divergence (one surface would
  stop escalating an arming value the other still knows). Now
  AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags /
  arms_auto_merge live in the service module; both surfaces import them.
- next_step_hints silently dropped unknown action names; once DMD-1988
  serialises a camelCase vocabulary every hint-next line would vanish.
  Falls back to the raw name.
- Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with
  no details (retry-inviting, list dropped). app.py maps them to 409 and
  _format_error carries non-empty details.
- The service's two caller-mistake refusals (resolving/diffing a finished
  MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over
  serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged.
- resolve_conflict coerced a caller body's isDisabled with bool(), so a
  hand-edited "false" DISABLED the config on replace and returned 200.
  Non-bool is refused (the guard's refuse-don't-default policy).

Low:
- The armed-auto-merge warning is human-only (formatter.warning), no
  longer injected into the payload -- Layer 1 does not manufacture data.
- A hole in the ours envelope no longer becomes an explicit-null candidate
  that resolve then blames the caller for; get_config_diff returns
  resolution_candidate: null + a warning, and --output words the three
  null shapes apart (deleted / absent / envelope hole).
- parse_json_arg turns OSError (a directory, permissions) into the
  ValueError the callers expect; docstring stops claiming config.py's
  copy is gone. --output on an unwritable path is a readable exit 2.
- merge skips the row GET when no prompt will show (--yes / --json).
- --reason cap enforced on the REST route too. CLAUDE.md --state line
  stops hand-listing a subset of the vocabulary.
- FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the
  misnamed CLI "round-trip" test renamed (the real round trip is pinned at
  the service layer).

Deferred to PR #703 (Layer 2 design/refactor findings from /code-review,
which reviewed the L2 branch; too large for the tail of this run):
_classify_three_way missing `both` rows for nested-vs-parent edits; the
`or code is None` 409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return in http_base._bound_error_params;
the post-merge cleanup being a third copy of BranchService's; the
try/finally client idiom vs the context manager.

Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync
are environmental (git commit signing via 1Password unavailable to the
test process), unrelated to this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service): align Layer 1 with Zajca's third Layer 2 review [DMD-1900]

Three second-order effects of rebasing onto 7cd1855:

- _branch_from_id_of: the L2 round split the message for an absent vs a
  non-numeric branchFromId, but the Phase-5 INVALID_ARGUMENT re-code had
  made both faults carry the caller's code. They blame different parties:
  absent = the caller is resolving a finished MR (INVALID_ARGUMENT, 400
  over serve); non-numeric = the server's payload (VALIDATION_ERROR).
- diff renderer: the L2 round makes _classify_three_way return zero rows
  for an empty-envelope side too, not only a null/deleted one. With no
  deletion flag set the renderer would have claimed "this conflict has
  cleared" while the service's warning beside it said "envelope hole".
  When there are no rows and the result carries warnings, say that no
  classification could be produced and let the warning explain.
- RFC: the five-key rule is the CALLER-body rule; a --take side composes
  an absent description as null (wire-identical -- the rebase omits the
  key), and a hole or non-boolean isDisabled there is a backend contract
  violation (VALIDATION_ERROR), never a caller error. The table said
  "refused when absent" for both paths.

Pinned: the two error codes; the no-rows-with-warning render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service): apply the Layer 2 follow-ups inherited by Layer 1 (F2-F5, F7) [DMD-1900]

docs/merge-requests-layer2-followups.md collects the non-blocking leftovers
of PR #703 that go through this PR. Per item:

- F2: _classify_three_way's docstring now says what is true -- the
  classifier is deliberately STRICTER than resolve_conflict (an empty
  envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to
  the delete resolution would destroy a configuration). And the
  empty-envelope half finally has a test. Beyond the docstring: an empty
  envelope on EITHER side is now reported in `warnings` (_diff_warnings
  replaces the ours-only _candidate_warnings), so the diff renderer's
  "no rows + warnings" branch fires instead of claiming the conflict
  cleared -- which it would have done for a theirs-side hole.
- F3: merge() records the branch-id degradation structurally --
  `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says
  "Source branch id could not be read; see warnings." instead of nothing.
  The CLI's merge renderer keys on the flag (a "Local cleanup skipped"
  line naming the raw value) and its hint-next points at branch reset +
  sync branch-unlink. A legitimate published-MR null carries no flag.
- F4: find_default_branch_id logs the skipped non-numeric entry instead of
  folding it into None silently (the callers then say "no default branch"
  for a project that DID report one). The `sync init` exits-0-with-empty-
  branches decision is a UX call left for Martin -- not changed.
- F5: the detail tier is feature-aware for free (has_feature after the
  verify_token it already pays): `feature_enabled` on the detail payload,
  a "Feature: not enabled" line in the panel, and hint-next refusing to
  recommend a write that cannot succeed. `list` stays feature-blind on
  non-empty results, as Layer 2 decided; docs say which is which.
- F7: config_service.py's last `if folder_branch_id:` truthiness test ->
  `is not None`; the positive assertion for "Active branch reset to main."
  on a successful reset; the 110-char docstring line rewrapped. The
  isDisabled-before-missing ordering is left as noted (house pattern).

Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both
call sites, standalone PR) and F8 (test_changelog_render under
FORCE_COLOR -- main, unrelated).

9 new tests; make check exit 0 (6672).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(cli): parenthesize implicit string concatenation in tuples (ruff 0.16 ISC004) [DMD-1900]

main's ruff upgrade (9d823d5, >=0.16 default rule set) fires ISC004 on two
tuple items in the detail renderer. No behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(serve): register the merge-requests routes in SERVE_COMMAND_MAP [DMD-1900]

main's #731 (command telemetry) requires every serve route in the
route->CLI-command map, enforced by
test_serve_telemetry::test_command_map_matches_every_route_exactly. The
twelve /merge-requests routes mirror their commands; by-branch is
serve-only (empty string, logged under its route label).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli,service,e2e): apply Copilot's (Balanced) review of #736 [DMD-1900]

Eight inline findings, all confirmed against the code and fixed with pins:

- E2E setup skipped on ANY `merge-request list` failure, turning a crash or
  an auth regression into a green run. It now asserts success and skips
  only on feature_enabled: false -- the one gate the class documents.
- The E2E covered 6 of 11 commands. The scenario now manufactures a REAL
  conflict (config in production, branch inherits it, both sides change
  it) and walks every command: create, update, list, detail, conflicts
  (via --branch), diff (+ --output candidate), resolve --take ours,
  request-review (-> approved), request-changes (-> development), approve,
  the bare --json merge exit 2, merge, and the production content check.
- `approve`'s refusal was asserted as "any error but FEATURE_NOT_ENABLED";
  it now asserts API_ERROR with the 422 in the message.
- warnings[] text (backend / exception prose) reached Rich unescaped -- an
  unbalanced tag would raise MarkupError after the irreversible operation
  succeeded. Escaped.
- `diff --output` wrote with the platform encoding (a name outside a
  Windows code page would fail the promised round trip); utf-8 now. And the
  path was interpolated into Rich markup unescaped.
- A HOLED (partial) envelope still classified: content() omitted the
  missing key and the intersection reported it as that side's removal;
  holes on theirs were not warned about. A side missing any required
  content key is now unclassifiable on either side, and _diff_warnings
  names the holes for both (one shared _envelope_holes criterion feeds the
  classifier, the candidate and the warnings).

The eighth finding (a stale "code-less conflict" rationale in the L2 RFC)
is fixed on ms/merge-requests-rfcs (76a2adb); this branch's first commit
is rebuilt from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli,serve)!: destructive is a property of the command -- static classes, `auto-merge` command [DMD-1900]

Replaces the flag- and state-derived escalations of the first draft with
one static rule: anything that moves a merge request toward or into
production is destructive, always. Decided with Martin 2026-09-10 after
Zajca's review of #736 pointed at the same hazard twice (a permission
that depends on a GET; an exit 2 that depends on state nobody typed) and
Martin asked for the flag to leave the condition entirely.

  request-review  destructive  (0-approval default lands directly in approved)
  approve         destructive  (the last approval is what a merge waits for)
  resolve         destructive  (removes the blocker a merge waits on)
  merge           destructive
  auto-merge      destructive  (NEW; arms the backend scheduler = delayed merge)
  create/update/request-changes  write

- `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own
  command; `create`/`update` no longer take `--auto-merge-strategy`. Arming
  is a consciously separate step, prompts in human mode; the disarm rides
  the same command, same class (a caller who could not arm never needs to
  disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched.
- FLAG_ESCALATIONS is back to its single original entry. The five
  merge-request escalation keys, `_escalate_if_armed` (CLI + router copies),
  `_warn_armed`, `_Target.armed`, `auto_merge_armed`,
  `armed_escalation_operation` are gone. The router's permission check is
  the route dependency alone -- no body inspection, no prior GET.
- The --json explicit-target rule now runs BEFORE any network call for every
  destructive command, since the class is known from the name. The
  transitions no longer fetch the MR row; the armed warning is read off the
  write's own result.
- PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP).
- Zajca's must-fixes from the review ride along: _deleted_side_message None
  ordering (a missing side recommended --take ours, which resolves as
  DELETE); reason/external-id caps validated once in the service from
  constants.py; derived_state escaped in the shared success renderer;
  get_merge_request_row runs the feature pre-flight lazily on a 403;
  register() through Typer's public app.command(name)(fn); --output OSError
  branch emits warnings first; route-level 403 coverage for every
  destructive route incl. the disarm.
- Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge;
  provenance-named classes dissolved). Docs: all six convention-#17
  surfaces; gotchas answers the --timeout question (retry is harmless
  behind the merge lock).

BREAKING for anyone on the unreleased draft only: --auto-merge-strategy /
--auto-merge-at on create/update are gone; request-review, approve and
resolve are denied under --deny-destructive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(cli,service): the candidate uses the resolve guard's criterion; both field caps exit 2 [DMD-1900]

Zajca's second review of #736, two findings.

1. `_envelope_holes` (the predicate behind `resolution_candidate`, the
   diff warnings and `classifiable()`) tested key PRESENCE, while the
   replace guard in `resolve_conflict` refuses a key that is present but
   `None` and a blank `name`. An ours envelope with `"name": null` thus
   composed into a candidate that `diff --output` wrote and
   `resolve --resolved @file` then refused -- kbagent blaming the caller
   for its own file, the exact drift the shared constant was meant to
   prevent. The predicate now mirrors the guard: absent OR `None` is a
   hole, so is a blank `name`. Candidate suppressed, reason in
   `warnings[]`, side excluded from classification. Pinned in the
   service suite for `None` and `"   "`.

2. `--reason` over its cap was pre-checked to exit 2, `--external-id`
   over its cap reached the service, whose INVALID_ARGUMENT
   `map_error_to_exit_code` does not map -- exit 1. Same kind of flag
   error, two exit codes. `create`/`update` now pre-check `--external-id`
   the same way (`_check_external_id`, one call per command); the service
   keeps the cap as the single rule (serve still answers 400 from it).
   Pinned for all three flag/command pairs, asserting no service call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants