🐛 fix(cli): resolve rule ids before submitting a standard - #515
987poiuytrewq wants to merge 2 commits into
Conversation
A rule removal or edit is applied by matching payload.targetId against
the rule's id, and compareStandardFields stamped every one of them with
the placeholder createRuleId('unresolved'). The local Markdown carries
no ids, so there was nothing else to put there.
StandardChangeProposalApplier filters on `rule.id !== targetId`, so the
placeholder matches nothing and the change is applied to nothing. With
`--no-review`, where proposals go straight to batchApply, that is the
whole story: the CLI prints "1 standard updated", drops the staged
change, and the rule is still there. Nothing retries it, because the
local playbook no longer knows anything is pending.
The CLI already had the missing piece: StandardsGateway.getRules returns
{ id, content } for a standard and was called from nowhere. buildProposals
now takes a fetcher, reads the rules once per standard, and passes a
content-to-id map down to compareStandardFields.
Left deliberately alone:
- StandardDiffStrategy keeps the placeholder. It feeds `playbook diff`,
which only prints, so no id is ever dereferenced.
- A rule whose content is not in the map keeps the placeholder too, so a
failed or partial lookup behaves as it did before rather than blocking
a submit. It now warns instead of reporting success in silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review caught that the fallback undid the fix. If getRules failed, the submit carried on without a map, the placeholder went out, the server accepted the standard update while skipping the rule operations, and the success path then cleared the staged change. That is the data loss this branch exists to remove, reached by a different route, and a warning is thin cover when the next line reads "1 standard updated" and there is nothing left to retry. A standard whose removals or edits still carry the placeholder is now reported through the skipped path the builder already uses for stale deployed content, so the handler keeps the staged change and exits 1. Additions are exempt. They name no existing rule, so a failed lookup does not make them ambiguous and a standard that only gained rules still submits. That is why the check is on the built proposals rather than on the lookup: it catches a partial answer that omits one rule just as well as a lookup that failed outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Hi @987poiuytrewq, thank you for this PR and for the detailed write-up. You are right on your diagnostic, the --no-review rule removals and edits were silently dropped. We're not going to merge it, though, because the fix belongs on the server. Fixing it in the CLI only helps people who upgrade, and every CLI already installed would keep losing changes. If you can rectify it, else we will open a ticket and do it later. Again, thanks you for your efforts. |
|
Hi @quentinlebourles-packmind - yeah you're right this fix should be done server-side. I did this to unblock my workflow in the meantime. Had a go at doing the server-side fix in #527 - it's got some design decisions to be made in there though before you merge. No worries - happy to help! |
Explanation
Removing or editing a rule in a standard and submitting it with
--no-reviewreports success and changes nothing on the server. The rule is still there afterwards, and nothing retries it.compareStandardFieldsstamps everydeleteRuleandupdateRuleproposal with a placeholder id:createRuleIdisbrandedIdFactory(), which returns(id) => id, sotargetIdis the literal string'unresolved'. The local Markdown carries no rule ids, so there was nothing else to put there.StandardChangeProposalApplierresolves both change types by id:The placeholder matches no rule, so the filter removes nothing and
updateRule'smaprewrites nothing. On the--no-reviewpath, whereplaybookSubmitHandlersends proposals straight tobatchApply, that is the whole story:batchApplyreturns success, because the standard itself was touched.1 standard updatedfromcollectParts(response.updated).The rule survives, the operator is told it did not, and there is nothing left locally to retry. Rule additions are unaffected, which is why this reads as "some edits don't stick" rather than as a broken command.
The fix
The CLI already had the missing piece.
StandardsGateway.getRulesreturns{ id, content }[]for a standard and was called from nowhere in the codebase.buildProposalsnow takes an optional fetcher, reads a standard's rules once, and handscompareStandardFieldsa content-to-id map. No server change and no new endpoint.Two things left deliberately alone:
StandardDiffStrategykeeps the placeholder. It feedsplaybook diff, which only prints; no id is ever dereferenced there. Adding a network call to a display command seemed a poor trade.skippedpath the builder already uses for stale deployed content: the handler keeps the staged change and exits 1, so the operator can retry. Additions are exempt, since they name no existing rule — a standard that only gained rules still submits. (This replaces an earlier fallback to the placeholder, which review correctly pointed out would recreate the data loss by another route.)Type of Change
Affected Components
apps/clionly (application/utils/artifactComparison.ts,infra/commands/playbook/submit/proposalBuilder.ts,infra/commands/playbook/submitHandler.ts)Testing
Test Details:
Thirteen tests added across the two specs.
artifactComparison.spec.ts—compareStandardFields:proposalBuilder.spec.ts—buildProposals:The six that assert the new behaviour were confirmed to fail against
mainwith only the specs applied, so they pin the behaviour rather than the implementation. The fallback tests pass either way, by design.Verified against a live instance. Both CLIs were run against the same standard in a throwaway space, deleting the same rule with
--no-review, pulling afterwards to read the server's own state back:pull1 standard updated1 standard updatedEditing a rule was checked the same way and also lands on this branch, which matters because
matchUpdatedRulesturns a reworded rule into anupdateRulecarrying the same placeholder. The fixture was restored to its published state afterwards — by another--no-reviewdeletion, which is its own small proof.nx test packmind-cli— 126 suites, 3490 tests, all passing.nx typecheck packmind-cliclean.nx lint packmind-clireports the same 13 pre-existing warnings asmain, none in the changed files.prettier --checkclean.TODO List
Reviewer Notes
This is not scoped to
--no-review.buildProposalsruns before thenoReviewbranch, so the resolved ids now reachbatchCreateas well asbatchApply. That is deliberate, but it is the part I would most like a second opinion on, because I could not establish what the review path does withtargetIdtoday.What the public code shows:
StandardChangesApplier extends StandardChangeProposalApplier, so the review path ends in the samerules.filter((rule) => rule.id !== changeProposal.payload.targetId).changeProposal.payloaddirectly rather than throughgetEffectivePayload, so a reviewer'sdecisiondoes not supply the id either.StandardChangeProposalApplier.spec.tssupplies a realruleIdin itsdeleteRulefixtures.Taken alone that suggests a CLI-originated rule deletion could never have applied on either path, which does not match what we observe — deletions do land through review. So
batchCreatemost likely resolvestargetIdwhen the proposal is created. That code is in the closed edition (the OSSPlaybookChangeManagementAdapterthrowsMethod not implementedfor every method), so this is inference, not something I could read.Either way the change should be safe: if creation resolves the id, a correct id arriving pre-resolved is at worst redundant; if it does not, this repairs the review path too. One argument in favour of ids over content matching on this path specifically: a review proposal can sit pending for days, and
saveNewVersionpasses rule ids through toupdateStandard, so an id survives someone rewording that rule in the UI where a content key would not.The check I would ask of someone with access: submit a rule removal through review and look at the stored proposal's
payload.targetId. If it is the literal stringunresolved, the review path has the same defect and this fixes both.Two smaller notes:
matchUpdatedRulespairs a delete with an add into anupdateRule, so a merely reworded rule takes the same path and was equally affected.spaceId/standardIdand cached for the lifetime of onebuildProposalscall, so a standard rendered for several agents costs one request.🤖 Generated with Claude Code