Skip to content

fix(recall): return bare operable engram IDs and enable remote forget (#1119) - #1122

Merged
plur9 merged 2 commits into
plur-ai:mainfrom
amasen02:fix/recall-bare-operable-id-1119
Sep 8, 2026
Merged

plur9 merged 2 commits into
plur-ai:mainfrom
amasen02:fix/recall-bare-operable-id-1119

Conversation

@amasen02

@amasen02 amasen02 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #1119

Problem

When engrams from remote stores are recalled via MCP or the CLI, their IDs are displayed with namespaced store prefixes (e.g., \ENG-GPL-2026-08-13-025). However, these prefixed IDs cannot be used with \plur forget\ because:

  1. The remote store expects the original bare ID (\ENG-2026-08-13-025).
  2. The CLI \ orget\ command previously checked only the local store (\plur.getById(target)) and aborted immediately if not found locally, preventing remote store deletion requests from ever being dispatched.

Solution

  • Core: Exported \�areEngramId(id: string)\ helper in @plur-ai/core\ to strip store namespace prefixes (e.g. \ENG-GPL-...\ -> \ENG-...). Updated \Plur._stripRemotePrefix()\ to utilize this fallback.
  • MCP: In \packages/mcp/src/tools.ts\ (
    ecallHandler), output the operable bare ID (
    aw._originalId ?? bareEngramId(e.id)) so clients receive actionable IDs.
  • CLI: In \packages/cli/src/commands/recall.ts, surface bare IDs in both structured and formatted text output. In \packages/cli/src/commands/forget.ts, allow \plur.forget()\ to attempt remote forget even if the engram is not present in the local store.
  • Tests:
    • Added unit tests for \�areEngramId()\ across all store prefixes (\packages/core/test/store-prefix.test.ts).
    • Added integration test verifying bare engram IDs returned in \plur_recall\ (\packages/mcp/test/remote-recall-surfacing.test.ts).

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

ℹ️ Could not auto-assign #1119 to @amasen02 — GitHub only allows assigning users with repo access or prior interaction on the issue. Maintainer: please assign it manually so the claim is recorded. This is not a problem with the PR.

@crtahlin crtahlin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes — the display half genuinely fixes #1119, but the core half introduces three verified test regressions, one of them in the guard that exists specifically to stop forget retiring the wrong engram.

I ran the full suite on both references. main fails 4 tests, all pre-existing and environment-related in the CLI package. This branch fails 7 — three new regressions, all in the remote routing paths.

The display work is right, and the new MCP test is a real regression test: I confirmed it fails on main and passes here. Preferring raw._originalId over the derived form is the correct precedence, and patching both the keyword and hybrid branches was thorough.

Blocking

  • packages/core/src/index.ts:1650 — replacing _stripRemotePrefix's return id fallback with return bareEngramId(id) deletes the routing safety the function was written for. Its own docstring states the contract: if the identifier does not match this store's prefix, return it unchanged, because it may belong to a different store. The forget walk calls _stripRemotePrefix(id, entry.scope) per store; under the old contract only the matching store received the bare identifier and the others correctly missed. Now every store receives it, so the walk degenerates to first-match-wins and a delete can be dispatched to whichever remote answers first. That is the wrong-engram retirement described in #1119 and #1109. Thirteen call sites share this helper — forget, feedback, update, rescope, receipt — so the mis-targeting is not confined to forget.
  • packages/core/test/remote-routing.test.ts — verified regression: "ambiguous ids across stores (#831) — falls back to a live lookup when the remote cache is cold" passes on main and fails here. The test seeds ENG-COLD-001 both locally and remotely and asserts forget() rejects with an ambiguity error. On this branch the promise resolves: bareEngramId('ENG-COLD-001') returns ENG-001 because COLD matches [A-Z]{2,4}, the remote probe misses, the collision guard never fires, and forget silently retires a row without disambiguating. The guard that prevents destructive wrong-target retires is disabled by identifier mangling.
  • packages/core/src/engrams.ts:631 — the regex /^(ENG|ABS|META)-[A-Z]{2,4}-(?=\d{4}-|[A-Za-z0-9])/ is too greedy. The lookahead's [A-Za-z0-9] alternative matches almost anything, making the date guard inert. Verified against the branch's own built output: ENG-SRV-001 becomes ENG-001, ENG-COLD-001 becomes ENG-001, and ENG-PACK-EM-006 becomes ENG-EM-006. The pack form is mandated by spec/ENGRAM-STANDARD-v1.md §3.3.2 and twelve such identifiers ship in the effective-memory pack; pack engrams are merged into the recall corpus and carry no _originalId, so recall would print a non-existent ENG-EM-006 — reintroducing the exact defect #1119 reports, for pack content. Dropping the |[A-Za-z0-9] alternative fixes all three shapes, but does not fix the routing problem above.
  • Two further verified regressions from the same cause: "feedback surfaces server error clearly" now throws Engram not found: ENG-SRV-ERR instead of the remote failure message, and "feedback() still works with unprefixed server ID" now throws Engram not found: ENG-SRV-001. The second is the standing regression test for #86, the very issue _stripRemotePrefix was written to close.

Should fix

  • Direct semantic conflict with open #1114, which edits the same forget walk. Its new hard throw is gated on id !== serverId meaning "the prefix was stripped, therefore this store is the intended target". This change makes that true for every store whenever the identifier is namespaced, so with both landed the walk throws Cannot reach <scope> to retire … naming an arbitrary store that never held the engram. Tracked as #1126. The two must be sequenced, not merged independently — and the preferred fix is on this side.
  • packages/cli/src/commands/forget.ts:39 — the success path can now report a retirement with no evidence of what was retired. With the local pre-check removed, an engram that lives only remotely yields a null lookup, so the CLI prints the identifier back with the statement omitted, and never says which store it acted on. The operator typed that identifier; echoing it proves nothing. Core's own comments call retirement irreversible.
  • packages/cli/src/commands/forget.ts:26 — no --scope flag exists, and this does not add one. Now that recall surfaces bare identifiers, an operator hitting the ambiguity guard gets an error telling them to pass a scope, with no way to do so from the terminal.
  • packages/cli/src/commands/recall.ts:38 — the stripping regex is inlined verbatim twice instead of importing the new helper, which is the whole point of exporting it. index.ts:104 carries a comment warning against re-deriving the prefix rule a fourth time; this makes it the fifth and sixth, and the correction this regex needs will silently miss the CLI.

Notes

  • On the duplication question: bareEngramId does not duplicate readIdFor, which is the scope-aware write-side function that adds the namespace. But the correct scope-aware inverse already exists as the private _stripRemotePrefix(id, scope), and bareEngramId is a scope-unaware variant of it. Introducing a scope-unaware inverse and then using it to weaken the scope-aware one is the root of every blocking finding above. As a pure display helper for the namespaced read path it is defensible.
  • packages/core/test/store-prefix.test.ts:9 — the new unit tests use only identifier shapes that happen to survive the regex, all beginning with a four-digit year. Adding ENG-PACK-EM-006, ENG-SRV-001 and ENG-COLD-001 as must-not-mangle cases would have caught all three regressions before CI.
  • packages/cli/src/commands/forget.ts:33 — the try wraps the output calls as well as forget(), so a failure in output after a successful retire is reported as "Engram not found".
  • Only the issue-guard check had reported at review time, so CI has not yet exercised the core suite. It would catch these.

Tests: FAIL — full npx vitest run on the head: 7 failed, 4684 passed; on origin/main: 4 failed, 4682 passed. Verified: ran both references in an isolated clone; exercised bareEngramId against real identifier shapes through the branch's own built module; read #1114's diff to confirm the interaction.

…d to date lookahead, and reuse helper in recall cli
@amasen02

amasen02 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @crtahlin for the thorough, insightful review — this caught the exact structural and routing risks.

Addressed all blocking and review feedback in commit \

@amasen02

amasen02 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@crtahlin Thank you again for the thorough and meticulous review — this caught the exact structural and routing risks.

All blocking and review feedback have been resolved in commit 0ebafc199:

  1. Preserved _stripRemotePrefix routing contract (packages/core/src/index.ts):

    • Reverted the change to _stripRemotePrefix: restored return id when the store prefix does not match, keeping the multi-store routing boundary and collision checks completely intact.
  2. Tightened bareEngramId regex lookahead (packages/core/src/engrams.ts):

    • Dropped the greedy |[A-Za-z0-9] alternative. The regex now strictly matches ^(ENG|ABS|META)-[A-Z]{2,4}-(?=\d{4}-).
    • Verified that ENG-PACK-EM-006, ENG-SRV-001, and ENG-COLD-001 remain untouched and are not mangled.
  3. Restored standing test suites & collision guards:

  4. CLI Helper Reuse (packages/cli/src/commands/recall.ts):

    • Replaced duplicate inlined regexes in the recall CLI with the canonical bareEngramId import.
  5. PR fix(mcp,core): apply readIdFor in learn catch-block; throw on namespaced-id remote miss (#1109) #1114 Compatibility:

Ready for re-review whenever you have a moment!

plur9 added a commit that referenced this pull request Sep 7, 2026
) (#1135)

`plur forget ENG-GPL-…` exited "Engram not found" for an engram the
remote store demonstrably held, and did the same for a bare id that lived
only remotely. The command's pre-check, `plur.getById(target)`, resolves
against `_loadAllEngrams`, whose remote leg is a synchronous cache peek
(`_loadRemoteCached`) that nothing warms in a one-shot CLI process — so
every remote engram missed, and the command exited before `plur.forget()`
ever ran. Core's forget() already does the right thing: it routes a
namespaced id to the store its prefix declares (#86), walks the remotes
for a bare id (#84), refuses an ambiguous id (#831) and an unreachable
declared store (#1114). The MCP plur_forget handler delegates to it on a
local miss; the CLI now does too.

- direct-id branch: a local hit keeps the existing path (retired check,
  statement echoed); a local miss, or an explicit `--scope`, delegates to
  forget() and reports what was asked for and where. Nothing is caught,
  so a retire that happened is never reported as a failure and a failure
  is never dressed up as a retire.
- `--scope <scope>`: the operator's disambiguator, mirroring MCP's `scope`.
  Core's own refusals tell the caller to "pass scope"; the CLI had no way
  to. A dangling `--scope` is a usage error, not a silently dropped flag.
  Search mode accepts only `--scope primary` — a search hit is local by
  construction, and a remote scope would route the local match's id to a
  server that may hold an unrelated row under it (#831).

No id regex changed. `_stripRemotePrefix` stays scope-aware; the test
file pins with decoy rows that a 2–4-letter non-prefix segment, a
lowercase prefix, an extra segment and a prefix matching no store all
reach the server untouched — the shapes PR #1122's bareEngramId mangled.

Co-authored-by: plur9 <[email protected]>

@plur9 plur9 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. I re-verified each blocking finding from the previous review against the current head (0ebafc19), in an isolated worktree — all three are resolved, and the review that found them was right on every count.

1. _stripRemotePrefix routing restored. index.ts:1650 is back to return id. The per-store forget walk once again misses on non-matching stores instead of degenerating to first-match-wins, so the wrong-engram retirement path described in #1119/#1109 is closed. This was the serious one — thirteen call sites share that helper.

2. bareEngramId regex tightened. Now /^(ENG|ABS|META)-[A-Z]{2,4}-(?=\d{4}-)/; the |[A-Za-z0-9] alternative that made the date guard inert is gone. Checked against the exact shapes named in the review:

ENG-SRV-001            -> ENG-SRV-001        unchanged
ENG-COLD-001           -> ENG-COLD-001       unchanged
ENG-PACK-EM-006        -> ENG-PACK-EM-006    unchanged
ENG-GPL-2026-09-07-046 -> ENG-2026-09-07-046  stripped

The §3.3.2 pack form survives, so pack engrams in the recall corpus no longer render a non-existent id.

3. The named test regressions are gone. remote-routing.test.ts and store-prefix.test.ts pass (49/49), including the #831 cold-cache ambiguity guard that was resolving instead of rejecting. Full core suite: 2,939 passed, 34 skipped, 0 failed.

Two "should fix" items are now resolved elsewhere:

  • The missing --scope on plur forget landed in #1135, which merged earlier today. An operator hitting the ambiguity guard can now act on the error it gives them.
  • The recall.ts duplication is addressed — the head commit reuses the exported helper rather than inlining the regex a fifth and sixth time.

Still open, and worth confirming before merge: the sequencing against #1114 tracked in #1126. The previous review's judgement was that the preferred fix is on this side; with routing restored here, that interaction should be re-checked rather than assumed resolved — #1114's throw is gated on id !== serverId, and this branch changes when that is true.

Note on the four pre-existing CLI failures mentioned in the earlier review: those were a stale dist, not environment leakage. The suites spawn the built binary and nothing tied it to its source. Fixed on #1138 — builtCliPath() now raises a named STALE BUILD error, and pretest rebuilds. Worth rebasing onto that before merge so a future reviewer of this PR does not chase the same ghost.

@plur9
plur9 merged commit c036d75 into plur-ai:main Sep 8, 2026
12 of 13 checks passed
plur9 pushed a commit that referenced this pull request Sep 8, 2026
Records what this release changes, under the theme the work shares: nothing is
silently dropped. Covers the injection reordering and the pinned quota alongside
#946, #981, #1120 and #1122, so the notes are written while the reasoning is
still to hand rather than reconstructed at tag time.

Credits @amasen02's first contribution (#1122) by name. The changelog had no
convention for crediting contributors; this establishes one.
plur9 pushed a commit that referenced this pull request Sep 8, 2026
Records what this release changes, under the theme the work shares: nothing is
silently dropped. Covers the injection reordering and the pinned quota alongside
#946, #981, #1120 and #1122, so the notes are written while the reasoning is
still to hand rather than reconstructed at tag time.

Credits @amasen02's first contribution (#1122) by name. The changelog had no
convention for crediting contributors; this establishes one.
crtahlin added a commit that referenced this pull request Sep 9, 2026
…census

Review fixes for #1164.

The guard reported success when it could not find the evidence. On no
`review_dismissed` timeline event it called `core.info` and returned, so the
job passed — and that branch is reachable: #1122 carries review 5113096768 in
state DISMISSED with a 1,113-character message and its timeline has no
`review_dismissed` event of any kind. Verified directly, including a full
event-type census of that pull request. It is a fork pull request, which is the
current suspicion but not a confirmed cause; the design flaw does not depend on
the cause. An empty dismissal and an unverifiable one are the same thing from
the reviewer's side — no record — and failing open on fork pull requests is
precisely backwards, since that is where external review discipline matters
most.

It now matches on the triggering review's id from `context.payload.review.id`
rather than taking the newest dismissal on the pull request. That fixes a
second defect in the same edit: a message-less dismissal used to be cleared by
any later dismissal that happened to carry a message, even on an unrelated
review.

Because the timeline can lag the webhook, it polls five times over roughly
twelve seconds before concluding. Failing closed only helps if it does not also
fail loudly on a timing artefact.

`issues: read` added. `listEventsForTimeline` is an issues endpoint even when
read for a pull request, and the workflow granted only `pull-requests` and
`contents`. A 403 would have thrown and failed the job on every dismissal,
well-documented ones included, which is how a guard becomes noise and then gets
removed. A 403 now reports what to fix rather than surfacing as a raw throw.

The header comment's numbers were wrong and are corrected. I re-ran the census
independently rather than take the correction on trust: a full paginated
`REVIEW_DISMISSED_EVENT` count since 2026-08-18 gives thirteen dismissals, eight
with a message and five empty — not nine of twelve. The reviewer's reading of
the shape is the one the record supports, and it is a better argument for this
workflow than the claim it replaces: of the seven dismissals before 2026-09-08,
five were empty and two carried a message, while all six on 2026-09-08 carried
one, after the problem had been raised. The comment no longer says the work may
have been done in the empty cases; the record does not support that.

Verified: nine branches of the script exercised against stubs (message present,
empty, whitespace-only, null, no event, event for a different review, a later
messaged dismissal not clearing an unmessaged one, absent review id, and a 403),
all behaving as intended; the two that were the findings both fail now and
passed before. `dismissed_review.review_id` confirmed against a real timeline
event on #946, where it matches that pull request's actual dismissed review id.
crtahlin added a commit that referenced this pull request Sep 11, 2026
…the record (#1164 review)

Second round of review fixes, following the maintainer's re-review.

The guard now enforces only when the dismissed review had requested changes.
The `review_dismissed` timeline event carries the review's state before
dismissal in `dismissed_review.state`. An approved or commented review passes,
and an unknown state is enforced rather than waved through. This matters
because the `dismiss_stale_reviews` rule removes an approval on every push,
recording the pusher as actor with no message. At the previous head that would
have failed on #1177 with "@amasen02 dismissed a review without a message" — a
public red check against an external contributor for something nobody chose to
do. Removing an approval never bypasses review, so nothing is lost by not
enforcing there.

A missing timeline event still fails closed. Every observed case (#1122, #1138,
and one of the two on #1017) was a manual dismissal of a changes-requested
review, which is exactly the case this guard exists for, so passing on a
missing event would reopen the hole. The earlier suggestion to treat "no event,
and the review's commit is not the head" as an automatic dismissal was wrong
for the same reason: it would have passed all three.

The header no longer says the empty dismissals were people skipping a reason.
A census by previous review state, 2026-08-18 to 2026-09-11, finds fourteen
dismissals: six empty and eight with a message. All six empty ones were of
approvals, and no changes-requested review was dismissed empty. The guard is
preventive, and the header now says so. The unverified fork explanation is
gone.

The header no longer suggests adding this as a required status check. The job
runs only on a dismissal, so a pull request without one never reports it, and
its result attaches to the head at dismissal time, so the next push leaves it
behind. A gate would need a `pull_request` trigger that re-evaluates every
dismissal.

The remediation text now names what actually clears a failure — ask the
reviewer to re-review, or push a commit — because a dismissed review cannot be
dismissed again. A dismissal made only of invisible characters no longer
passes: `trim()` leaves zero-width spaces, byte-order marks and directional
controls in place. The class that strips them is written with escapes, because
a literal U+2028 or U+2029 breaks both the script and the YAML.

Verified: 24 cases against stubs, all passing, four of them replayed from the
live timeline. #1177 and #929 (approvals dismissed with no message) pass, #946
(changes requested, with a message) passes, and #1138 (changes requested, no
event) fails closed. Removing the prior-state gate turns five cases red,
including both live approval replays; replacing the strip with plain `trim()`
turns three red.
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.

Recall shows a decorated, non-operable engram ID (ENG-GPL-…) for group:plur scopes; the real ID (server/GUI) is the bare ENG-…

3 participants