Repository navigation
fix(recall): return bare operable engram IDs and enable remote forget (#1119) - #1122
Conversation
crtahlin
left a comment
There was a problem hiding this comment.
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'sreturn idfallback withreturn 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 onmainand fails here. The test seedsENG-COLD-001both locally and remotely and assertsforget()rejects with an ambiguity error. On this branch the promise resolves:bareEngramId('ENG-COLD-001')returnsENG-001becauseCOLDmatches[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-001becomesENG-001,ENG-COLD-001becomesENG-001, andENG-PACK-EM-006becomesENG-EM-006. The pack form is mandated byspec/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-existentENG-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-ERRinstead of the remote failure message, and "feedback() still works with unprefixed server ID" now throwsEngram not found: ENG-SRV-001. The second is the standing regression test for #86, the very issue_stripRemotePrefixwas 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 !== serverIdmeaning "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 throwsCannot 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--scopeflag 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:104carries 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:
bareEngramIddoes not duplicatereadIdFor, 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), andbareEngramIdis 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. AddingENG-PACK-EM-006,ENG-SRV-001andENG-COLD-001as must-not-mangle cases would have caught all three regressions before CI.packages/cli/src/commands/forget.ts:33— thetrywraps the output calls as well asforget(), 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
|
Thank you @crtahlin for the thorough, insightful review — this caught the exact structural and routing risks. Addressed all blocking and review feedback in commit \ |
|
@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
Ready for re-review whenever you have a moment! |
) (#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
left a comment
There was a problem hiding this comment.
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
--scopeonplur forgetlanded in #1135, which merged earlier today. An operator hitting the ambiguity guard can now act on the error it gives them. - The
recall.tsduplication 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.
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.
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.
…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.
…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.
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:
Solution
ecallHandler), output the operable bare ID (
aw._originalId ?? bareEngramId(e.id)) so clients receive actionable IDs.