Skip to content

🐛 fix(cli): resolve rule ids before submitting a standard - #1

Closed
987poiuytrewq wants to merge 4 commits into
mainfrom
fix/resolve-rule-ids-before-submit
Closed

987poiuytrewq wants to merge 4 commits into
mainfrom
fix/resolve-rule-ids-before-submit

Conversation

@987poiuytrewq

@987poiuytrewq 987poiuytrewq commented Sep 23, 2026 •

Copy link
Copy Markdown

Explanation

Removing or editing a rule in a standard and submitting it with --no-review reports success and changes nothing on the server. The rule is still there afterwards, and nothing retries it.

compareStandardFields stamps every deleteRule and updateRule proposal with a placeholder id:

for (const rule of remainingDeleted) {
  const ruleId = createRuleId('unresolved');
  changes.push({
    type: ChangeProposalType.deleteRule,
    payload: { targetId: ruleId, item: { id: ruleId, content: rule } },
  });
}

createRuleId is brandedIdFactory(), which returns (id) => id, so targetId is the literal string 'unresolved'. The local Markdown carries no rule ids, so there was nothing else to put there.

StandardChangeProposalApplier resolves both change types by id:

const filteredRules = rules.filter(
  (rule) => rule.id !== changeProposal.payload.targetId,
);

The placeholder matches no rule, so the filter removes nothing and updateRule's map rewrites nothing. On the --no-review path, where playbookSubmitHandler sends proposals straight to batchApply, that is the whole story:

  1. batchApply returns success, because the standard itself was touched.
  2. The handler prints 1 standard updated from collectParts(response.updated).
  3. It then clears the staged change, so the local playbook forgets the deletion.

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.getRules returns { id, content }[] for a standard and was called from nowhere in the codebase. buildProposals now takes an optional fetcher, reads a standard's rules once, and hands compareStandardFields a content-to-id map. No server change and no new endpoint.

Two things left deliberately alone:

  • StandardDiffStrategy keeps the placeholder. It feeds playbook diff, which only prints; no id is ever dereferenced there. Adding a network call to a display command seemed a poor trade.
  • An unmatched rule still falls back to the placeholder. A lookup that fails or comes back incomplete then behaves exactly as it does today rather than blocking a submit — but it now warns, instead of leaving a success line to speak for a change that was dropped.

Type of Change

  • Bug fix
  • New feature
  • Improvement/Enhancement
  • Refactoring
  • Documentation
  • Breaking change

Affected Components

  • Domain packages affected: apps/cli only (application/utils/artifactComparison.ts, infra/commands/playbook/submit/proposalBuilder.ts, infra/commands/playbook/submitHandler.ts)
  • Frontend / Backend / Both: neither — CLI
  • Breaking changes (if any): none. The new parameters are optional and every existing call site keeps its current behaviour.

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing completed
  • Test coverage maintained or improved

Test Details:

Ten tests added across the two specs.

artifactComparison.spec.ts — compareStandardFields:

  • targets a deleted rule by its server id
  • carries the server id on the deleted rule item
  • targets an updated rule by the id of the content it replaces
  • falls back to the placeholder when the deleted rule is absent from the supplied ids
  • falls back to the placeholder when no ids are supplied

proposalBuilder.spec.ts — buildProposals:

  • targets a deleted rule by the id fetched from the standard
  • fetches the rules with the space and artifact of the standard
  • fetches a standard's rules once when several agents render it
  • still builds the proposals when the rule lookup fails
  • falls back to the placeholder when no fetcher is provided

The six that assert the new behaviour were confirmed to fail against main with 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:

reported rule after pull
0.35.1 as published 1 standard updated still there
this branch 1 standard updated gone

Editing a rule was checked the same way and also lands on this branch, which matters because matchUpdatedRules turns a reworded rule into an updateRule carrying the same placeholder. The fixture was restored to its published state afterwards — by another --no-review deletion, which is its own small proof.

nx test packmind-cli — 126 suites, 3487 tests, all passing. nx typecheck packmind-cli clean. nx lint packmind-cli reports the same 13 pre-existing warnings as main, none in the changed files. prettier --check clean.

TODO List

  • CHANGELOG Updated
  • Documentation Updated

Reviewer Notes

This is not scoped to --no-review. buildProposals runs before the noReview branch, so the resolved ids now reach batchCreate as well as batchApply. 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 with targetId today.

What the public code shows:

  • StandardChangesApplier extends StandardChangeProposalApplier, so the review path ends in the same rules.filter((rule) => rule.id !== changeProposal.payload.targetId).
  • That branch reads changeProposal.payload directly rather than through getEffectivePayload, so a reviewer's decision does not supply the id either.
  • There is no content-based rule lookup anywhere in this repository, and StandardChangeProposalApplier.spec.ts supplies a real ruleId in its deleteRule fixtures.

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 batchCreate most likely resolves targetId when the proposal is created. That code is in the closed edition (the OSS PlaybookChangeManagementAdapter throws Method not implemented for 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 saveNewVersion passes rule ids through to updateStandard, 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 string unresolved, the review path has the same defect and this fixes both.

Two smaller notes:

  • matchUpdatedRules pairs a delete with an add into an updateRule, so a merely reworded rule takes the same path and was equally affected.
  • The fetcher is keyed on spaceId/standardId and cached for the lifetime of one buildProposals call, so a standard rendered for several agents costs one request.

🤖 Generated with Claude Code

stevdrouet and others added 4 commits September 23, 2026 16:58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… job

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
)

* ♻️ refactor(commands): give the existing error class a kind

CommandSlugAlreadyExistsError lived in @packmind/types as a bare Error, so
DomainExceptionFilter had nothing to map it by and only the controller's
instanceof check kept it from answering 500. It now extends CommandsError
and carries kind conflict, reason command_slug_already_exists and a
{ commandSlug, spaceId } context. The slug, which the caller typed, stays
in the message; the space id moves to the context. It keeps its public
slug and spaceId properties, drops the captureStackTrace boilerplate and
names itself correctly instead of RecipeSlugAlreadyExistsError.

The class moves into packages/commands/src/domain/errors, next to the new
CommandsError and CommandsInternalError bases, so the family lives
together as it does for standards and skills. The types commands/errors
directory is gone, and the barrel is exported from @packmind/commands.
The controller keeps its ConflictException mapping for now; only its
import changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ♻️ refactor(commands): type the use case failures

The bare `throw new Error(...)` across the commands use cases answered 500
with a stack logged at error, when every one was a 404 the caller could act
on. They now throw CommandSpaceNotAccessibleError or CommandNotFoundError,
both not_found on CommandsError. No use case failure turned out to be ours,
so none lands on CommandsInternalError.

Each "missing" and "wrong tenant" pair collapses into a single branch with
one message: a space that is not there or belongs to another organization,
in capture, capture with packages, update from UI, delete, get by id and
list by space; a command that is not there or sits in another space, in
update from UI and delete. Neither message names an id any more; the ids
move to the context.

GetCommandById already answered a missing command with `{ recipe: null }`;
a command in another space now takes that same path instead of throwing.
Its only checked callers, the api service's getCommandById and
getLatestVersionNumber, already turn null into a NotFoundException, so
that request now answers 404 where it answered 500. The internal lookup
the deployments and playbook packages use is untouched.

ListCommandsBySpace only ever checked the space, never each command: the
repository query is scoped by space. It keeps that one check, typed.

The space class carries the Command prefix because @packmind/deployments,
@packmind/editions and @packmind/standards already export space-not-found
classes.

Drops the log-and-rethrow catch blocks — capture, delete, batch delete,
get by id, list by space, find by slug, get version and list versions —
and the logs sitting right before the converted throws: the filter records
every thrown failure once, with its level, stack and typed context. The
catch in capture with packages, which swallows a failed package link once
the command is created, stays.

Specs assert the errors by instance rather than by message literal, and
DeleteCommand gains cases for a command in another space and a space in
another organization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ♻️ refactor(commands): type the service, repository, adapter and job failures

The last bare errors in the package. CommandService's four "Recipe with
id … not found" — in update, delete, duplicate to space and mark as moved —
are reached from other domains through the adapter with an id they were
handed, so they are the caller's: CommandNotFoundError, not_found.

The adapter and job factory wiring failures are ours, all on
CommandsInternalError: CommandsAdapterPortsMissingError names the ports or
delayed jobs initialize() came back without, and the deploy-commands queue
handle's lifecycle guards become DeployCommandsQueueNotInitializedError and
DeployCommandsDelayedJobNotCreatedError, as the git fetch-file-content
factory does. getDelayedJob() guards the same missing createQueue() call as
the queue's initialize(), so both throw the latter.

CommandsHexa throws nothing of its own: registry.getService and getAdapter
already raise on a missing entry, and the hexa's logging stays — it runs at
boot, where the filter never sees what is thrown. The placeholder
hexa_dependency_missing reason goes with the dependency context it came
with; the internal reasons are now the three above.

Drops the log-and-rethrow catch blocks across CommandService,
CommandVersionService, CommandRepository and CommandVersionRepository, and
the logs sitting right before the converted throws: the filter records
every thrown failure once. No repository miss needed a type of its own.

Specs assert CommandNotFoundError by instance rather than by message
literal. No bare `throw new Error(...)` is left in packages/commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ♻️ refactor(api): drop the commands hand-mapping the filter replaces

The commands controller caught CommandSlugAlreadyExistsError on creation
and rethrew it as a ConflictException. The error now carries kind
conflict, so DomainExceptionFilter answers the same 409 on its own and the
branch was dead; it goes.

The three not-found branches — a command looked up by id, its latest
version, its versions list — each raised their own NotFoundException
naming the command id. They now throw CommandNotFoundError, so a missing
command answers the same message as the use cases rather than one naming
its id, still a 404. GetCommandById already answers a command in another
space with `{ recipe: null }`, so there was no second, wrong-space branch
to collapse.

The log-and-rethrow catch blocks across every route go too, along with
the warn logs sitting right before the not-found throws: the filter
records every thrown failure once, and each try/catch then reduced to
`throw error`, so the wrapper goes with it.

The request-shape BadRequestExceptions on batch delete stay: "recipeIds
must be an array" and "recipeIds array cannot be empty".
DeleteCommandsBatchUseCase does not validate its ids, so nothing else
would refuse the body.

The frontend reads only the status — isPackmindConflictError checks 409
on creation — so the new messages change nothing there.

Specs assert CommandNotFoundError reaches the caller rather than the
NotFoundException the controller used to raise, replace the bare "does
not belong to space" errors with it, and add one for the slug conflict
propagating as CommandSlugAlreadyExistsError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
@987poiuytrewq
987poiuytrewq force-pushed the fix/resolve-rule-ids-before-submit branch from 2748890 to a41d51a Compare September 23, 2026 15:35
@987poiuytrewq

Copy link
Copy Markdown
Author

Superseded by PackmindHub#515, which carries the same branch to upstream for review. Closing so there is a single place to review; the branch stays on this fork as the PR head.

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