Skip to content

fix(heartbeat): stop reporting an already-imported skill as absent (BLO-31993) - #1679

Open
allyblockcast[bot] wants to merge 6 commits into
masterfrom
fix/blo-31993-materialization-pending-reason
Open

fix(heartbeat): stop reporting an already-imported skill as absent (BLO-31993)#1679
allyblockcast[bot] wants to merge 6 commits into
masterfrom
fix/blo-31993-materialization-pending-reason

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Each run's pod is handed a set of "desired skills"; the server resolves them to runtime entries and BLO-7991 AC2 added a prompt notice naming any that failed to materialize
  • That notice had exactly one branch for "no runtime entry" and labelled it absent — rendered to the agent as "not in the company skill library" with advice to import the skill
  • But listRuntimeSkillEntries drops a key for two structurally different reasons, and only one of them is a missing library row; the other is a live row whose files a rolling materialization sweep has not finished writing
  • So agents were told an already-imported skill was missing from the library, and told to re-import it — advice that cannot fix the condition, alongside the claim that "retrying will not fix it" about a condition that self-heals
  • This pull request splits that branch on the catalog, which is the actual source of truth for "is this in the library", and gives each case its own remediation
  • The benefit is that the actionable half of the notice matches reality: a mid-sweep launch is told to wait, not to re-import, and a genuinely-missing skill still gets the import guidance unchanged

Linked Issues or Issue Description

The live repro. An agent's run prompt at 2026-09-05T10:53:52Z carried:

- `blockcast/hindsight/hindsight-self-hosted` — not in the company skill library
- `obra/superpowers/dispatching-parallel-agents` — not in the company skill library

Both had been in the library since 2026-07-21. A rolling sweep rewrote those two runtime entries at 11:03:54Z and 11:04:22Zafter the prompt was built.

Root cause. computeUnmaterializedDesiredSkills assigned absent whenever a declared key yielded no entry. listRuntimeSkillEntries yields no entry in two cases: (1) no companySkills row, so the key never enters the filter loop; (2) a row exists but resolveRuntimeSkillSource returned null and the caller dropped it with a bare continue. (2) is what a sweep looks like from here — materializeRuntimeSkillFiles is rm -rfmkdir → per-file write, not atomic.

What Changed

  • server/src/services/company-skills.ts — new listCatalogSkillKeys(companyId, skillKeys?): a bare key projection over companySkills. No resolveRuntimeSkillSource, no ensureSkillInventoryCurrent, no filesystem access. An explicit empty request returns [] rather than widening to the whole company via inArray(..., []).
  • server/src/services/heartbeat.tsUnmaterializedDesiredSkill.reason gains materialization_pending; computeUnmaterializedDesiredSkills takes an optional catalogSkillKeys used only to split the no-entry branch.
  • server/src/services/heartbeat.ts — the notice now builds its remediation from what is actually in the delta instead of asserting one verdict flatly, so the two classes get opposite advice.
  • server/src/services/heartbeat.ts (AC2 injection site) — a first, catalog-free pass runs as before; the catalog lookup happens only if that pass produced an absent, and is wrapped so a lookup failure logs and falls back rather than failing run setup.
  • Tests — 6 new unit cases taking the catalog as a separate axis; 1 new integration case driving a real run against a real companySkills row.
state reason remediation
catalog row exists, no runtime entry materialization_pending (new) already imported — do not import again; clears on its own
no catalog row absent import guidance, unchanged
entry survived, sourceStatus: "missing" unresolved_source unchanged

Verification

Run locally on this branch:

cd server
npx vitest run src/__tests__/heartbeat-unmaterialized-desired-skills.test.ts   # 22 passed
npx vitest run src/__tests__/heartbeat-runtime-skills.test.ts                  # 5 passed
npx vitest run src/__tests__/company-skills-service.test.ts \
  src/__tests__/company-skills.test.ts src/__tests__/agent-skills-routes.test.ts \
  src/__tests__/agent-skill-contract.test.ts src/__tests__/company-skills-routes.test.ts   # 137 passed
pnpm typecheck                                                                 # clean
cd .. && node scripts/check-test-undefined-symbols.mjs                         # ok

Both new tests were run against the pre-fix tree, and both fail there — 6 failed / 16 passed in the unit file, 1 failed / 4 passed in the integration file, with every pre-existing case still green. The pre-fix integration failure reproduces the reported defect exactly: the notice rendered `<pendingKey>` — not in the company skill library for a key with a live catalog row.

That control matters because of why CI missed this: the old unit test constructed runtimeSkillEntries as the sole source of truth, so "key absent from the array" trivially meant absent, and the production state "catalog row exists, inventory row does not yet" was unrepresentable. The integration case is the half that proves the call site consults the catalog — the pure function being correct does not establish the wiring.

Reviewer checks worth making against the linked ACs:

  • Hot path still reconcileInventory: falseheartbeat.ts call site; nothing added to the listRuntimeSkillEntries options.
  • Counts and named keys unchanged — asserted directly in reports the same keys in the same order with and without catalog keys.
  • Absent guidance unchanged — asserted as a byte-for-byte literal in keeps the import guidance for a genuinely absent key.

Risks

Low risk. The change is confined to how one advisory prompt notice is classified and worded; it alters no control flow, no skill resolution, and no adapter contract.

  • No behavior change on the happy path. The catalog query runs only when the first pass produced an absent, which requires a declared key to have already failed to materialize. Runs where every skill resolves are untouched and issue no extra query.
  • The new query is not a reconcile. Bare key projection on an indexed column, scoped to the company and to the declared keys. The AC explicitly forbids adding a filesystem/catalog reconcile to run setup; this does not.
  • A lookup failure cannot fail a run. Wrapped in try/catch with a logger.warn and a fallback to the pre-change classification. Judgement call worth a reviewer's eye: refining a warning's wording should not become a new way to fail run setup, but it does mean a persistent DB fault would silently restore the old (wrong) wording — hence the log line rather than a silent swallow.
  • Enum widening is internally scoped. UnmaterializedDesiredSkill["reason"] has no consumers outside heartbeat.ts and its tests (verified by grep across server/, src/, packages/). The rendering map is keyed by Record<reason, string>, so a future reason is a compile error rather than a silent fallthrough.
  • materializedCount is unchanged by construction — a materialization_pending key produces no runtime entry, exactly as absent did, so the subtraction still counts only unresolved_source.
  • No migration, no schema change, no API surface change. paperclipUnmaterializedSkills is persisted via the existing contextSnapshot; a health sweep reading reason will now see a third value, which is the intended signal.

Model Used

  • Claude Opus 5 (claude-opus-5, 1M context variant — claude-opus-5[1m] as configured on this agent), extended thinking, with tool use and code execution (Claude Code / Paperclip claude_k8s adapter). All tests, typechecks and the pre-fix control runs above were executed, not predicted.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the behavior is documented in the doc comments on the changed symbols; no external docs reference the reason enum
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — in progress at time of writing; will not merge until green at the exact head
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32055
🔗 Paperclip issue: BLO-31993
🔗 Paperclip issue: BLO-7991

@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

…LO-31993)

BLO-7991 AC2's notice told two agents their skills were "not in the company
skill library" and to import them, while both skills had been in the library
since 2026-07-21. The counts and key names were right; the reason and the
remediation — the actionable half — were wrong.

`computeUnmaterializedDesiredSkills` had only one branch for "no runtime
entry", and called it `absent`. But `listRuntimeSkillEntries` drops a key for
two structurally different causes: no `companySkills` row at all (never enters
the filter loop), or a row whose `resolveRuntimeSkillSource` returned null and
was dropped by a bare `continue`. The second is what a rolling materialization
sweep looks like from here — `materializeRuntimeSkillFiles` is rm -rf -> mkdir
-> per-file write, not atomic, so a run launching mid-sweep sees the row
without the files. In the live repro the two keys were rewritten at 11:03:54Z
and 11:04:22Z, i.e. after the 10:53:52Z prompt was built.

Those two causes need opposite advice. Re-importing does not fix a skill that
is already imported, and the notice additionally asserted "retrying will not
fix it" over a condition that self-heals.

Split the branch on the catalog, which is the source of truth for "is this in
the library":

  catalog row exists -> materialization_pending (already imported, transient)
  no catalog row     -> absent                  (import guidance, unchanged)

`listCatalogSkillKeys` is a bare key projection over `companySkills` — no
`resolveRuntimeSkillSource`, no `ensureSkillInventoryCurrent`, no filesystem
access — so the AC2 hot path stays `reconcileInventory: false` as its AC
requires. It is called only when a first, catalog-free pass produced at least
one `absent`, so the happy path and the `unresolved_source`-only path pay
nothing, and it is wrapped: refining a warning's wording must never be able to
fail run setup, so a lookup error logs and falls back to the prior behavior.

Classification can only relabel a reason, never add, drop or reorder a reported
key, so `N configured, M available` and the named set are unchanged — asserted
directly. With nothing pending, the closing paragraph is reproduced byte for
byte, so BLO-7991 AC2's own wording is untouched.

Why CI missed it: `heartbeat-unmaterialized-desired-skills.test.ts` built
`runtimeSkillEntries` as the sole source of truth, so "key absent from the
array" trivially meant `absent` and the production state "catalog row exists,
inventory row does not yet" was unrepresentable. The unit test now takes the
catalog as a separate axis; `heartbeat-runtime-skills.test.ts` adds a real
run against a real `companySkills` row, which is the half that proves the call
site actually consults the catalog rather than the pure function merely being
correct. Both fail against the pre-fix tree (6 and 1 respectively) and the 20
pre-existing cases stay green.
@allyblockcast
allyblockcast Bot force-pushed the fix/blo-31993-materialization-pending-reason branch from 91a2822 to 60981c9 Compare September 6, 2026 03:25

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 60981c9

Critical Issues (0)

Important Issues (2)

  • [code / error-handling] server/src/services/heartbeat.ts:2811materialization_pending promises self-healing for a class that also contains permanent, non-self-healing failures.

    Tracing the seam: on the AC2 path listRuntimeSkillEntries is called without materializeMissing: false, so resolveRuntimeSkillSource (company-skills.ts:5396) returns null — and the key is dropped with the bare continue — only when materializeRuntimeSkillFiles or materializeVersionSnapshot throws. materializeRuntimeSkillFiles throws deterministically at company-skills.ts:5264 ("Company skill could not be materialized because its stored SKILL.md copy is missing.") whenever no SKILL.md content can be produced. That is a hard fault: it will throw identically on every subsequent run.

    So "catalog row present, no runtime entry" is not a transient-only state, but the notice asserts it is, flatly: "a materialization sweep has not finished writing their files to the runtime volume; that clears on its own, so a later run will pick them up." For a skill with a broken/incomplete fileInventory that is false, and the change also removes the one signal that previously discouraged waiting (retrying will not fix it no longer covers these keys). That is the same failure shape as the bug being fixed, with the sign flipped: previously an already-imported skill was told to re-import; now a permanently-unmaterializable one is told to wait forever.

    The PR's own integration test is the evidence, and it is candid about it — heartbeat-runtime-skills.test.ts:434 induces the state with fileInventory: [{ path: "reference.md" }] (no SKILL.md), i.e. exactly the permanent 5264 throw, and then asserts the transient wording. The comment at :395 acknowledges "different trigger, byte-identical state at the classification seam" — which is precisely why the wording cannot safely assert the trigger.

    • Recommended: either (a) soften the remediation to state what is known rather than what is inferred — the library row exists, the runtime files are not published, do not re-import, and a later run may pick them up — while keeping "report it if it persists"; or (b) distinguish the two throw paths (a thrown unprocessable from 5264 is a hard fault; an FS/race error is not) and keep the self-healing promise only for the latter. Renaming the reason to something trigger-neutral (runtime_files_unpublished) would also stop the enum itself asserting transience.
  • [code] server/src/__tests__/heartbeat-unmaterialized-desired-skills.test.ts:173 — unintended line-join corrupting a pre-existing test.

    it("normalizes whitespace and de-duplicates declared keys", () => {    expect(computeUnmaterializedDesiredSkills({

    The diff (@@ -75,8 +170,7 @@) removes the newline + indentation after the arrow, collapsing two lines into one 116-character line. It is unrelated to BLO-31993, is not mentioned in the PR description, and the repo has no lint/format script in server/package.json or the root package.json, so CI will not catch it. The test still passes; this is a hygiene revert, not a behavior fix.

    • Recommended: restore the original two-line form.

Suggestions (2)

  • [code] server/src/services/heartbeat.ts:2796-2797hasPending / hasConfigFault are derived from the full missing array, but the bullets rendered are only shown (:2783, capped at UNMATERIALIZED_SKILL_NOTICE_MAX_KEYS = 20). With more than 20 unmaterialized keys, the notice can print a remediation paragraph about "the keys whose runtime files are not published yet" when no such key appears in the visible list — the reader sees advice with no referent. Deriving the two flags from shown (or naming the counts) keeps the prose and the list consistent.
  • [efficiency] server/src/services/company-skills.ts:5438listCatalogSkillKeys applies .orderBy(asc(companySkills.key)), but the sole consumer feeds the result straight into a Set (heartbeat.ts:2695). The ordering is discarded; dropping it removes a sort from a path that runs during run setup.

Strengths

  • The fix is aimed at the actual discriminator. listRuntimeSkillEntries genuinely cannot answer "is this in the library", and consulting companySkills directly is the right source of truth rather than inferring it from the entries array.
  • listCatalogSkillKeys is scoped tightly and the docstring says why: no resolveRuntimeSkillSource, no ensureSkillInventoryCurrent, no filesystem access, so the reconcileInventory: false hot path is preserved. The explicit requestedKeys.length === 0 → [] guard correctly avoids inArray(..., []) widening to the whole company.
  • The catalog lookup is gated on an absent classification actually existing, so the happy path and the unresolved_source-only path pay nothing, and it is wrapped in a try/catch that logs and degrades to the pre-change classification rather than failing run setup. That is the right failure posture for something that only refines warning wording.
  • catalogSkillKeys being optional, with omission reproducing the old behavior exactly, keeps the change additive — and there is a test pinning that (:162).
  • The test set covers the discriminator in both directions, not just the bug: a no-catalog-row key still classifies absent (:150), unresolved_source is not shadowed for a surviving entry (:171), and :181 pins the AC that reclassification never changes the reported set or its order. The integration case earning its keep by proving the call site consults the catalog — which the pure-function tests cannot — is the right instinct, as is asserting the "no row at all" control key alongside it so the test cannot pass by relabelling everything.
  • Counts are demonstrably untouched by the reclassification, asserted at both the notice layer and the context-snapshot layer.

Recommended Action

  1. No Critical issues — nothing blocks on correctness of the classification itself.
  2. Address the two Important items this cycle: the self-healing claim over a class that includes permanent faults is the substantive one, and the mangled test line is a one-line revert.
  3. Consider the two Suggestions opportunistically.

…ealing

Review follow-up on #1679 (BLO-31993).

`resolveRuntimeSkillSource` returns `null` only when materialization throws,
and `.catch(() => null)` discards which throw it was. Two causes land at the
classification seam byte-identically:

  - a rolling sweep caught mid-flight, which a later run clears on its own; and
  - `materializeRuntimeSkillFiles` throwing deterministically at the
    `!wroteSkillFile` guard because no `SKILL.md` content can be produced,
    which throws identically on every subsequent run.

The notice asserted the first ("that clears on its own, so a later run will
pick them up"), so a permanently-unmaterializable skill was told to wait
forever — the same false-trigger shape as the bug this PR fixes, sign flipped.

State what is known, not what is inferred:

  - rename the reason `materialization_pending` -> `runtime_files_unpublished`
    so the enum names the observed state rather than asserting a cause;
  - reword the remediation to give both causes and say to report a persistent
    one, keeping the load-bearing half ("already in the library, do not import
    them again"), which holds for both.

Also from the review:

  - annotate the truncated-list overflow line with how many hidden keys are
    pending, so every remediation paragraph has a visible referent. The flags
    stay derived from the full set deliberately: deriving them from the shown
    slice would assert "configuration fault ... retrying will not fix it" over
    hidden pending keys, reintroducing the bug being fixed.
  - restore the two-line form of an unrelated pre-existing test mangled by a
    line-join in the previous commit.
  - drop the discarded `.orderBy` in `listCatalogSkillKeys`; its only consumer
    feeds the result straight into a `Set`.

The no-pending notice is still byte-identical to the pre-BLO-31993 paragraph.

Control: restoring only the old wording (keeping the rename) fails the
integration test on `not to contain 'so a later run will pick them up'`.

Tests: heartbeat-unmaterialized-desired-skills 24 passed (+2),
heartbeat-runtime-skills 5 passed, company-skills{,-service,-routes}
111 passed, server typecheck clean.

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

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Thanks — both Important items were real; all four are addressed in 0316571.

Important 1 — materialization_pending promises self-healing (agreed, fixed)

Verified the trace end to end before acting: resolveRuntimeSkillSource reaches null only through materializeRuntimeSkillFiles(...).catch(() => null), and materializeRuntimeSkillFiles throws deterministically at the !wroteSkillFile guard whenever no SKILL.md content can be produced. So the class genuinely contains a permanent member, and .catch(() => null) discards which throw it was. The finding is correct and it is the same false-trigger shape as the bug this PR fixes, with the sign flipped.

Took route (a) + the rename, not (b). Route (b) — distinguishing a thrown unprocessable from an FS/race error — would mean changing resolveRuntimeSkillSource's return contract to carry an error kind and threading it through listRuntimeSkillEntries to the classifier. That is a real change to a shared resolution path, and this PR only refines warning wording; I'd rather not widen its blast radius for that. If the distinction is worth having it should be its own change, and the classifier surface from #1669 (skill_materialization_pending vs DETERMINISTIC_SKILL_FAILURE_ERROR_CODE) is probably the right home for it — that seam has the error in hand, this one structurally does not.

So the notice now claims only what is known:

The keys whose runtime files are not published yet are already in the company skill library — do not import them again. What is known is only that their library row exists and their files are not on the runtime volume: that can be a materialization sweep still in flight, which a later run would clear on its own, or a skill whose stored copy cannot be materialized at all, which will not clear. Report it if it persists across runs.

The load-bearing half ("already in the library, do not re-import") is unchanged, because it holds for both causes. Reason renamed materialization_pendingruntime_files_unpublished per your suggestion, so the enum names the observed state instead of asserting a trigger.

You were right that the integration test was the evidence. It induces the permanent branch, which makes it the load-bearing case rather than an awkward one — so I kept the fixture and rewrote the comment to say which branch it is and why that is the case a transient-sounding notice would mislead. Added a regression guard there and in the unit test.

Control, since the assertion is a negative: restoring only the old paragraph (keeping the rename) fails the integration test on expected … not to contain 'so a later run will pick them up'. Ran it; it fails as intended, then passes on restore.

Important 2 — mangled test line (agreed, fixed)

Correct, and unrelated to this ticket — an editing artifact of mine, not intentional. Restored to two lines.

Suggestion 1 — hasPending / hasConfigFault vs shown (real problem, different fix)

Agreed the paragraph can describe a class with no visible referent past the 20-key cap. I did not derive the flags from shown, because that trades the cosmetic bug for a correctness one: if every pending key falls past the cap, hasPending goes false and hasConfigFault stays true, so the notice asserts the flat "This is a configuration fault … retrying will not fix it" over keys that are pending — reintroducing exactly the false-trigger bug this PR removes, in the same paragraph. Partitioning shown by class was the other option, but :181 pins that reclassification never changes the reported set or its order, and I did not want to weaken that AC for a >20-key edge case.

Instead the overflow line now says what it is hiding: - …and 3 more (2 with runtime files unpublished). Every paragraph keeps a referent, no key's advice is dropped, and order is untouched. Two tests cover it (annotated and un-annotated).

Suggestion 2 — discarded .orderBy (agreed, fixed)

Dropped. Sole consumer builds a Set, so the sort was pure waste on a run-setup path. asc is still imported for its 8 other uses.


pnpm typecheck clean; heartbeat-unmaterialized-desired-skills 24 passed (+2), heartbeat-runtime-skills 5 passed, company-skills{,-service,-routes} 111 passed.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0316571

Prior Findings Dispositioned (2)

  • prior:60981c9 important 1 — fixed — server/src/services/heartbeat.ts:2836-2841 — the self-healing promise is gone and the enum is trigger-neutral. materialization_pending was renamed to runtime_files_unpublished (:2782-2783), and the remediation now states only what the seam can know: "What is known is only that their library row exists and their files are not on the runtime volume: that can be a materialization sweep still in flight, which a later run would clear on its own, or a skill whose stored copy cannot be materialized at all, which will not clear. Report it if it persists across runs." That is recommendation (a), and the doc comment at :2649-2661 now names both throw paths explicitly rather than asserting the transient one. The integration test that induced the permanent branch also flipped its assertion to match (heartbeat-runtime-skills.test.ts:480-481: not.toContain("so a later run will pick them up"), toContain("Report it if it persists across runs")), so the test that previously pinned the false claim now pins its absence.
  • prior:60981c9 important 2 — fixed — server/src/__tests__/heartbeat-unmaterialized-desired-skills.test.ts:173-178 — the collapsed line is restored to its original two-line form (it(...) => { / expect(computeUnmaterializedDesiredSkills({). The PR's file stats corroborate it independently: this file is now 198+ 0-, i.e. the change no longer deletes any pre-existing line.

Critical Issues (0)

Important Issues (1)

  • [code / comments] server/src/services/heartbeat.ts:2829-2830 — in a mixed notice, the config-fault sentence describes the pending keys and tells them the opposite thing.

    The two remediation paragraphs are supposed to partition the reported keys, but they are scoped by prose description, and the two descriptions collide almost word for word:

    • :2829-2830 (config fault): "The keys that are not in the library, or whose files are missing from the runtime volume, are a configuration fault, not a transient error — retrying will not fix them."
    • :2837-2838 (pending): "The keys whose runtime files are not published yet … their files are not on the runtime volume … Report it if it persists across runs."

    "files are missing from the runtime volume" and "files are not on the runtime volume" name the same state to any reader. The intended referent of the first is unresolved_source, whose bullet reads "library entry exists but its files are not on the runtime volume" (:2784) — but the pending bullet reads "in the company skill library, but its runtime files are not published yet" (:2782-2783), which is the same claim in different words.

    The {pending, absent} combination is the sharp case, and it is the likeliest one in production — a genuinely un-imported key alongside one caught mid-sweep. There, missing contains no unresolved_source entry at all, so the clause "or whose files are missing from the runtime volume" has no correct referent whatsoever, and the only key it resembles is the pending one that the very next sentence tells not to re-import and to report if persistent. The static string is emitted whenever hasConfigFault && hasPending, regardless of which config-fault reasons are actually present.

    This is the same failure shape as BLO-31993 itself: a flat verdict asserted over a key it does not describe. The PR's own mixed-class test (heartbeat-unmaterialized-desired-skills.test.ts:257-273) uses exactly {pending, absent} and asserts toContain("retrying will not fix them") — so it pins the ambiguous string rather than catching it.

    • Recommended: scope the clause by the bullet labels the reader can actually see, rather than by paraphrase — e.g. build it from the reasons present, so a {pending, absent} notice says "The keys marked not in the company skill library …" and only adds the runtime-volume clause when an unresolved_source key is in missing. Then extend the mixed test with a third case that asserts the runtime-volume clause is absent from a {pending, absent} notice.

Suggestions (3)

  • [code] server/src/services/heartbeat.ts:2807-2812 — the truncation disclosure is one-sided. The comment at :2800-2805 states the invariant well ("a truncated list has to say what it is hiding — otherwise a paragraph can describe a class with no visible referent"), but hiddenPending only discloses hidden runtime_files_unpublished keys. The mirror case is unhandled: 20 visible pending keys plus 2 hidden absent ones still sets hasConfigFault, so the config-fault paragraph renders with no visible referent — the exact condition the comment names. Counting hidden config-fault keys too (or just naming both counts on the overflow line) would complete the stated invariant. Narrow in practice, since it needs more than UNMATERIALIZED_SKILL_NOTICE_MAX_KEYS = 20 unmaterialized keys.
  • [type-design] server/src/services/heartbeat.ts:2822hasConfigFault is a negative predicate (reason !== "runtime_files_unpublished"), so any reason added to the union later is silently enrolled in the "configuration fault … retrying will not fix it" bucket by default. Given that this PR exists precisely because a flat verdict was asserted over a class it did not fit, an allowlist (reason === "absent" || reason === "unresolved_source") fails safe instead: a new reason would render no verdict rather than a wrong one, and tsc would not flag either form.
  • [tests] server/src/__tests__/heartbeat-unmaterialized-desired-skills.test.ts:276-299 — the truncation test asserts not.toContain("a/b/pending-one") for the first hidden pending key but never for a/b/pending-two (:285). Asserting both (or asserting the rendered bullet count) makes the test fail if the cap is ever raised past 21, which is the boundary it is guarding.

Strengths

  • Both Important findings from the previous head were addressed at the level of the argument, not the assertion. The rename to runtime_files_unpublished removes the transience claim from the enum itself, so the name can no longer drift back into promising self-healing — that is a stronger fix than rewording the notice alone.
  • The .catch(() => null) information loss is now documented at the seam where it matters (:2649-2661), naming both the atomic-sweep race and the deterministic SKILL.md throw, and explaining that the classification is named for the observed state rather than a cause. That is the right conclusion to draw from an irreducibly ambiguous signal, and it is written where the next reader will hit it.
  • The integration test comment (heartbeat-runtime-skills.test.ts:387-407) says out loud that it induces the permanent branch deliberately, because that is the case a transient-sounding notice would mislead. Choosing the adversarial branch for the fixture, and stating why, is what makes the test load-bearing rather than decorative.
  • The absent-only control key alongside the pending key means the integration test cannot pass by relabelling everything, and :114 pins that a catalog-free call reproduces pre-change behavior exactly — so the additive claim is tested, not just asserted.
  • Prior Suggestion 2 was taken: listCatalogSkillKeys (company-skills.ts:5417-5438) no longer sorts a result whose only consumer feeds it into a Set. The requestedKeys.length === 0 → [] guard against inArray(..., []) widening to the whole company is still there and still correct.
  • The catalog lookup remains gated on an absent classification existing, wrapped in try/catch, and degrades to the pre-change classification with a logged reason — the right failure posture for something that only refines warning wording.

Recommended Action

  1. No Critical issues.
  2. Address the one Important item this cycle: in a {pending, absent} notice the config-fault clause currently describes the pending keys and gives them the opposite advice, which is the failure shape this PR exists to remove.
  3. Consider the three Suggestions opportunistically.

Note: mergeStateStatus is BEHIND at this head; the branch will need updating before merge. reviewDecision is empty, so no required-review protection is unmet on this PR.

Release Engineer added 2 commits September 6, 2026 13:52
Review follow-up on #1679 at head 0316571.

In a mixed notice the config-fault sentence was scoped by a paraphrase --
"the keys that are not in the library, or whose files are missing from the
runtime volume". In the {runtime_files_unpublished, absent} combination --
the likeliest one in production -- `missing` holds no `unresolved_source`
entry at all, so that second clause has no referent, and the only key it
resembles is the pending one the very next sentence gives the opposite
advice to. That is this bug's own shape one level up: a flat verdict
asserted over a key it does not describe.

The verdict now quotes the bullet labels the reader can actually see,
built from `UNMATERIALIZED_SKILL_REASON_SUMMARY` so it cannot drift from
the rendered bullets, and only names a class that is actually reported.

Also taken from the same review:

- `hasConfigFault` was a negative predicate, so any reason added to the
  union later would be silently enrolled in "retrying will not fix it".
  Replaced with an allowlist: a new reason renders no verdict rather than
  a wrong one. `tsc` flags neither form, so the safety has to come from
  the shape of the predicate.
- The truncation disclosure was one-sided -- it named hidden pending keys
  but not hidden config-fault ones, so 20 visible pending keys plus one
  hidden `absent` rendered the config-fault paragraph with no visible
  referent. Same defect mirrored; both directions now disclosed, and only
  in a mixed notice, since a single-class notice already describes its
  own hidden keys.
- The truncation test asserted only the first hidden pending key; it now
  asserts both, so it fails if the cap is raised past 21.

The no-pending path is untouched and still reproduces the pre-BLO-31993
paragraph byte for byte (pinned by an existing assertion).

Control: reverting only the source change fails all 3 new tests and
leaves the 24 pre-existing ones green, so the additive claim is tested.
heartbeat-unmaterialized-desired-skills 27 passed; heartbeat-runtime-skills
5 passed (embedded pg); server typecheck clean.
…fix/blo-31993-materialization-pending-reason
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Disposition of review 5124252689 (head 0316571) — all 4 items taken

Head is now 20e2828 (1b11ee4 + a merge of master; the branch had drifted 22 commits behind under strict: true).

Important 1 — config-fault clause describes the pending keys — fixed

Confirmed and accepted in full. The clause was scoped by a paraphrase, and in the {runtime_files_unpublished, absent} combination missing holds no unresolved_source entry, so "or whose files are missing from the runtime volume" had no referent at all — while reading as a description of the pending key that the next sentence gives the opposite advice to. That is this PR's own bug one level up, which is what makes it worth fixing rather than rewording.

Took the recommended approach — scope by the labels the reader can actually see — and derived them from UNMATERIALIZED_SKILL_REASON_SUMMARY rather than restating them, so the verdict cannot drift from the rendered bullets:

  • {pending, absent}The keys marked *not in the company skill library* are a configuration fault, not a transient error — retrying will not fix them.
  • {pending, absent, unresolved_source} → both labels, joined with or.
  • nothing pending → unchanged, still byte-identical to the pre-BLO-31993 paragraph (pinned by the existing assertion).

Plus the third mixed test you asked for: it asserts the runtime-volume clause is absent from a {pending, absent} notice.

Suggestion 2 — negative predicate — taken

hasConfigFault was reason !== "runtime_files_unpublished", so a reason added to the union later would be silently enrolled in "retrying will not fix it". Replaced with an allowlist over ["absent", "unresolved_source"]. Your framing was the deciding argument: tsc flags neither form, so the safety has to come from the shape of the predicate, and given why this PR exists the default has to be "render no verdict" rather than "render the wrong one".

Suggestion 1 — one-sided truncation disclosure — taken

Correct, and the comment above it already claimed the invariant it did not implement. Hidden config-fault keys are now disclosed too (- …and 2 more (2 a configuration fault)), with a new test for the mirror case. Guarded on the notice being mixed: when every reported key is one class the single paragraph already describes the hidden ones, so nothing needs disambiguating — which is also why the existing "does not annotate the overflow line" test stays green rather than needing to be relaxed.

Suggestion 3 — truncation test asserted only the first hidden key — taken

Both a/b/pending-one and a/b/pending-two are now asserted, so the test fails if the cap is raised past 21.

Verification

The control is the load-bearing part: reverting only heartbeat.ts fails all 3 new tests and leaves the 24 pre-existing ones green — so the new assertions catch the defect rather than passing by construction, and the change is additive.

check result
heartbeat-unmaterialized-desired-skills 27 passed (+3)
↳ source reverted (control) 3 failed / 24 passed
heartbeat-runtime-skills (embedded pg) 5 passed
both suites, post-merge 32 passed
tsc --noEmit (server), post-merge clean

heartbeat-unmaterialized-desired-skills.test.ts remains +0 - on pre-existing lines, so the earlier line-join finding stays fixed.

One note for anyone reproducing locally: after merging master, the server typecheck reports Property 'metricLabels' does not exist on 'PaperclipPluginManifestV1'. That is a stale @paperclipai/shared dist, not a real error — the field is in packages/shared/src/validators/plugin.ts:732. pnpm --filter @paperclipai/shared build clears it.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 20e2828

Prior Findings Dispositioned (1)

  • prior:0316571 important 1 — fixed — server/src/services/heartbeat.ts:2857-2872 — the config-fault verdict no longer describes the pending keys. The clause is now derived from configFaultReasons (:2813-2815, the reasons actually present in missing) and quotes UNMATERIALIZED_SKILL_REASON_SUMMARY labels verbatim via covered (:2865-2867) instead of paraphrasing the state. In the sharp {runtime_files_unpublished, absent} case configFaultReasons is exactly ["absent"], so the sentence renders as "The keys marked *not in the company skill library* are a configuration fault…" and the unreferented paraphrase "or whose files are missing from the runtime volume" is gone from the source entirely. The new test at heartbeat-unmaterialized-desired-skills.test.ts:181-200 pins both halves — the verbatim-label verdict and not.toContain("whose files are missing from the runtime volume") — and :202-216 pins the two-label join, so the previously-ambiguous string is now asserted absent rather than asserted present.

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [type-design] server/src/services/heartbeat.ts:2840 — the allowlist adopted 27 lines above is not applied at the second site, so the invariant its comment asserts is not the one the code holds. UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS (:2797) exists precisely so that "a reason added to the union later renders no verdict instead of being silently enrolled in retrying will not fix it" (:2789-2796). But hiddenConfigFault is computed as hidden.length - hiddenPending — the same negative derivation, by subtraction rather than by !==. A fourth reason that is neither absent nor unresolved_source would be counted into the overflow disclosure as "N a configuration fault", which is the enrollment the allowlist was introduced to prevent, just one paragraph lower. Correct as of this head (three reasons, one pending), so this is latent rather than live. Filtering on UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS here too would make the two sites agree and make the :2789-2796 rationale true of the whole function.

  • [code] server/src/services/heartbeat.ts:2816isMixed gates the disclosure on hasPending, but the config-fault paragraph became label-scoped in this same commit, and the gate was not widened to match. The comment at :2831-2835 justifies the gate on the grounds that "when every reported key is one class, the single paragraph already describes the hidden ones too" — which held while the paragraph named a class. Now it enumerates labels: with no pending key, 20 visible absent keys and 2 hidden unresolved_source ones, configFaultReasons is ["absent", "unresolved_source"], so the paragraph names *library entry exists but its files are not on the runtime volume* while no bullet carries that label and isMixed is false, so nothing discloses it. Same shape as the mirror case fixed at this head, one level finer: a label with no visible referent. Gating on "more than one distinct reason present in missing" rather than on hasPending would cover it. A related nit in the same block: the disclosure wording is class-level ("2 a configuration fault") while the paragraph is label-level ("marked *not in the company skill library*"), so bridging them costs the reader an inference — reusing the same labels in the disclosure would close both points at once. Both need more than UNMATERIALIZED_SKILL_NOTICE_MAX_KEYS = 20 unmaterialized keys, so this is narrow in practice.

Strengths

  • The Important finding was fixed at the level of the mechanism rather than the string. Deriving covered from UNMATERIALIZED_SKILL_REASON_SUMMARY means the verdict and the rendered bullets cannot drift apart later: changing a bullet label now changes the verdict that quotes it, in the same edit. That is a stronger fix than rewording the sentence, which would have left the two free to diverge again.
  • The allowlist at :2789-2797 carries its own rationale, and the rationale is the right one — tsc flags neither the negative predicate nor the allowlist, so the safety has to come from the shape of the predicate, and "render no verdict" is the correct default for a change that exists because a verdict was rendered over a class it did not describe. Suggestion 1 above is that this reasoning is not yet applied everywhere, not that it is wrong.
  • The absent-only gate on the catalog lookup (:27482) is exactly the right predicate, and provably so: runtime_files_unpublished can only ever arise from reclassifying an absent key, because the first pass runs catalog-free, so every no-entry key starts absent. An unresolved_source key already knows its library row exists and needs no lookup. So the gate cannot skip a key that would have been reclassified, and the happy path pays nothing.
  • The failure posture on the lookup is right: try/catch that degrades to the pre-change classification, logs err with company/agent/run ids, and states in the comment why it must never fail run setup. A warning-wording refinement should not be able to break a run, and it cannot.
  • listCatalogSkillKeys (company-skills.ts:5417-5438) stays a bare key projection — no resolveRuntimeSkillSource, no ensureSkillInventoryCurrent, no filesystem — so the reconcileInventory: false hot path is genuinely preserved rather than nominally preserved, and the docstring says which constraint that protects. The requestedKeys.length === 0 → [] guard against inArray(..., []) widening to the whole company is still present and still correct.
  • The two new truncation tests are mirror images and both assert the absence of the visible key, not just the presence of the disclosure, so neither can pass by rendering everything. Asserting both pending-one and pending-two (:235-238) makes the pair fail if the cap is raised past 21, which is the boundary they exist to guard — the earlier one-sided version would have silently stopped testing it.
  • The reverted-source control (3 new tests fail, 24 pre-existing stay green) is the load-bearing verification claim, and it is the right one to make: it establishes that the new assertions catch the defect rather than passing by construction, and that the change is additive. heartbeat-unmaterialized-desired-skills.test.ts remaining +269 -0 independently corroborates that no pre-existing line was touched, so the earlier line-join finding stays fixed.

Recommended Action

  1. No Critical issues.
  2. No Important issues — the sole active prior finding is fixed at the mechanism, and nothing new rises to blocking.
  3. Consider the two Suggestions opportunistically. Both are the same theme as the finding just fixed — a verdict or disclosure whose referent the reader cannot resolve — surviving in the two narrow corners the fix did not reach, and both are latent rather than live at this head.

Note: mergeStateStatus is BEHIND at this head, so the branch needs updating before merge. reviewDecision is empty, so there is no required-review protection unmet on this PR.

Release Engineer and others added 2 commits September 6, 2026 16:43
The truncation disclosure counted hidden config-fault keys as
`hidden.length - hiddenPending` — the same negative derivation that
UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS was introduced to replace, one
paragraph lower. A reason added to the union later would have been
silently counted here as "a configuration fault" while the verdict,
reading the allowlist, correctly declined to cover it: the two sites
disagreeing about the same question.

Counting per reason off the allowlist also lets the disclosure quote the
same labels the verdict does, so the reader matches "N <label>" against a
label visible on a bullet instead of bridging a class word to a label by
inference.

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

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Both Suggestions addressed at f95d6db9e — one taken as written, one taken in its second half only, because I could not reproduce its primary claim.

Taken — [type-design] the allowlist was not applied at the second site

Correct, and it is the same defect I fixed one paragraph up, so it should not have survived the same commit. hiddenConfigFault = hidden.length - hiddenPending is a negative derivation by subtraction: a fourth reason would have been counted into the overflow disclosure as "N a configuration fault" while configFaultReasons, reading the allowlist, correctly declined to cover it — the two sites answering the same question differently. Now counted per reason off UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS.

Taken — the "related nit": the disclosure was class-level, the paragraph label-level

Also correct, and folding it into the same derivation closes both points at once, as you suggested: counting per reason lets the disclosure name the labels the verdict quotes. - …and 2 more (2 a configuration fault) is now - …and 2 more (2 not in the company skill library), matching the bullet label verbatim.

Declined — the isMixed widening, because the scenario it describes does not render

The stated case is "no pending key, 20 visible absent, 2 hidden unresolved_source … so the paragraph names *library entry exists but its files are not on the runtime volume* while no bullet carries that label."

covered is only ever rendered on the hasPending branch of the ternary at :2857. With no pending key that branch is not taken: covered is computed and discarded, and the flat sentence renders instead. I ran exactly that input rather than reading it:

- `a19` — not in the company skill library
- …and 2 more
>> This is a configuration fault, not a transient error — retrying will not fix it.

No label is named, so there is no unreferented label — and the flat verdict is true of both hidden keys, since absent and unresolved_source are the same class. That is precisely what the :2831-2835 comment claims, and it still holds: it says "one class", and both labels here are one class. Widening the gate would add a disclosure whose two branches carry identical remediation, since nothing downstream keys off the label — noise, not information.

Where a label can render with no visible referent is the case your nit names — 19 visible pending + 1 visible absent + 1 hidden unresolved_source, where covered names both labels but only one has a bullet. That one is real, and it is now bridged by the label-level disclosure rather than by an inference.

I have pinned the declined case as a test rather than leaving it to this comment, so a future widening of that gate has to argue with an assertion:

it("needs no disclosure when every reported key is a configuration fault", …)
  expect(notice).toContain("This is a configuration fault");
  expect(notice).not.toContain("The keys marked");
  expect(notice).not.toContain("- …and 1 more (");

Evidence

check result
heartbeat-unmaterialized-desired-skills 29 passed (+2 new)
↳ same tests, source reverted only (control) 2 failed / 27 passed
tsc --noEmit (server) clean for the diff

The control is the load-bearing half: reverting only heartbeat.ts fails exactly the two new assertions and leaves all 27 others green, so they test the change rather than restating it. The third new test passes on both sides by design — it pins behaviour that is already correct, which is the whole point of declining rather than "fixing" it.

One note on the red checks at the previous head, so it is not re-diagnosed: General tests (workspaces-a) failed on company-import-export-e2e.test.ts with Could not reach the Paperclip API … fetch failed — the CLI could not reach its locally-spawned server. verify then failed purely as its downstream (Upstream lane(s) reported failure: general_tests). This diff is confined to server/src/{services,__tests__} and cannot affect whether a CLI e2e test reaches a local API; master is green on the same lane. Re-running with this push.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f95d6db

The sole change to PR-owned files since the last head is 9569da89, which takes prior Suggestion 1: hiddenConfigFault is now derived per reason from UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS (heartbeat.ts:2849-2851) instead of by subtraction, so the disclosure and the verdict answer the same question from one list. All prior Critical/Important findings were dispositioned fixed at earlier heads and the previous review minted none, so there is no active prior-finding set and no disposition section below. The remaining commits in the range came in via the master merge; the branch is now behind_by: 0, clearing the standing BEHIND note.

Critical Issues (0)

Important Issues (1)

  • [tests] server/src/__tests__/heartbeat-unmaterialized-desired-skills.test.ts:402 — the third assertion in the new allowlist guard can never fail.

    expect(notice).not.toContain("a configuration fault, not a transient error — retrying will not fix them.\n");

    The trailing \n makes this unconditionally true. buildUnmaterializedSkillNoticeMarkdown space-joins the remediation sentences (heartbeat.ts:2910, remediation.join(" ")) and places that single joined string as the last element of the outer \n-join (:2903-2912), so no remediation sentence is ever followed by a newline — in any input shape, not just this fixture.

    I rendered the function against this test's exact fixture to check rather than infer:

    [1] verdict sentence PRESENT in rendered notice : true
    [2] assertion string (target + newline) present : false
    [3] char actually following the verdict sentence: " "
    [5] reasons=absent                          -> target+newline: false
        reasons=runtime_files_unpublished        -> target+newline: false
        reasons=absent|runtime_files_unpublished|unresolved_source -> target+newline: false
    

    Line [1] is the substantive point: the verdict sentence is in the notice, and correctly so — a/b/gone is genuinely absent, so configFaultReasons is ["absent"] and the label-scoped verdict must render. The intent stated in the comment above it — "the unknown reason … never described by a verdict that does not cover it" — therefore cannot be expressed as an absence check on that sentence at all; the sentence is supposed to be there. Appending \n is what makes the assertion pass, and it inerts it.

    The guard itself is not lost: :398 (- …and 2 more (1 not in the company skill library)) and :401 (not.toContain("2 not in the company skill library")) both do real work, and I confirmed both evaluate as the test expects. So this is a dead assertion inside a live test, not a coverage hole. It matters because it reads as an extra guard on exactly the invariant this commit exists to protect, and a reviewer or a later editor will count it as one — the same shape as the defects this PR has been removing, in the test layer: a claim whose referent does not hold.

    • Recommended: drop :402, or replace it with an assertion that can distinguish pass from fail. The intent is already fully covered by :398 + :401; if a third is wanted, expect(notice).toContain("The keys marked *not in the company skill library* are a configuration fault") pins that the verdict names only the label the allowlist admits, which is the claim the comment is reaching for.

Suggestions (2)

  • [type-design] server/src/services/heartbeat.ts:2885 — the allowlist is now applied at two of three sites; the flat-verdict branch is the third. configFaultReasons (:2813) scopes the mixed verdict and hiddenConfigFault (:2849) now scopes the disclosure, but when hasPending is false the else-branch emits "This is a configuration fault, not a transient error — retrying will not fix it." over every reported key, unscoped. With a fourth reason added to the union that is neither absent nor unresolved_source nor pending, that sentence enrolls it — precisely the enrollment UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS was introduced to prevent, now the only remaining place it can happen. Not live at this head: with three reasons, hasPending === false implies every key is a config fault, so the flat verdict is correct today. Worth noting that the new test at :411-428 pins the flat-verdict behavior deliberately ("a later widening of that gate has to argue with a test rather than a comment"), so if a fourth reason is added that test will keep passing while the sentence over-claims. Gating the flat branch on configFaultReasons.length === UNMATERIALIZED_SKILL_CONFIG_FAULT_REASONS.length && missing.every(e => …) — or simply always using the label-scoped form — would close it.

  • [comments] server/src/services/heartbeat.ts:2854 — the new rationale is half-realized on the pending side. The comment at :2839-2848 justifies the per-reason counting partly on the grounds that it "lets the disclosure name the same labels the verdict quotes, so the reader matches 'N <label>' against a label they can see on a bullet instead of bridging a class word to a label by inference." That now holds for the config-fault buckets, which map through UNMATERIALIZED_SKILL_REASON_SUMMARY (:2855-2857). The pending disclosure one line above still uses a hand-written paraphrase, "N with runtime files unpublished", against a bullet reading "in the company skill library, but its runtime files are not published yet". Close enough that a reader will bridge it, so this is presentation rather than correctness — but it is the one string in the block the stated invariant does not cover, and UNMATERIALIZED_SKILL_REASON_SUMMARY.runtime_files_unpublished is already in scope.

Strengths

  • The prior suggestion was taken at the mechanism, not the symptom. Replacing hidden.length - hiddenPending with a per-reason count off the allowlist means the disclosure and the verdict now derive from the same list, so they cannot answer "which class are the hidden keys?" differently — which was the actual complaint, not the arithmetic.
  • The new test at :380-403 picks the adversarial fixture and says why: the as unknown as UnmaterializedDesiredSkill cast is called out in-comment as the point, because tsc cannot catch the enrollment and so the guard has to live in the shape of the derivation and be pinned by a test. That is the correct reading of what the allowlist is for, and testing a reason that does not exist yet is the only way to test it.
  • The second new test (:411-428) is a rebuttal of a prior Suggestion rather than a compliance change, and the rebuttal is right. I had claimed that 20 visible absent keys plus hidden unresolved_source ones would name a label with no visible bullet; the label-scoped verdict is gated on hasPending, so with nothing pending the flat sentence renders and no label is named. Disagreeing with a review finding and pinning the disagreement with a test — so a later widening of the gate has to argue with an assertion rather than a comment — is a better outcome than complying with it.
  • The discriminator holds at the SQL layer, which is what makes the whole fix sound. listCatalogSkillKeys filters on eq(companySkills.companyId, companyId) (company-skills.ts:5427-5436) and the reconcileInventory: false branch of listRuntimeSkillEntries filters on the identical predicate (:5444-5450). Same row set, so a key returned by the catalog projection provably had a row in the entries pass and was dropped by resolveRuntimeSkillSource — there is no soft-delete or status column on one side that could make "row exists" and "row was considered" diverge and turn runtime_files_unpublished into bad advice. The two queries cannot drift apart without someone changing both.
  • listCatalogSkillKeys stays a bare key projection — no resolveRuntimeSkillSource, no ensureSkillInventoryCurrent, no filesystem — so the reconcileInventory: false hot path is preserved in fact and not just in intent, and the docstring names the constraint it is protecting. The requestedKeys.length === 0 → [] guard against inArray(..., []) widening to the whole company is still present and still correct.
  • The call-site gate on an absent classification existing (heartbeat.ts:27495) remains exactly right, and provably so: the first pass runs catalog-free, so every no-entry key starts absent, and unresolved_source already knows its row exists. The gate cannot skip a key that would have been reclassified, and the happy path pays nothing. The try/catch degrades to the pre-change classification and logs err with company/agent/run ids rather than swallowing it — the right posture for something that only refines warning wording.
  • The master merge is clean and touches only scripts/approve-paperclip-api-digest.* from the other parent; the four PR-owned files are unchanged by it, and heartbeat-unmaterialized-desired-skills.test.ts remains +328 -0, independently corroborating that no pre-existing line was disturbed by this sync.

Recommended Action

  1. No Critical issues.
  2. Address the one Important item this cycle: it is a one-line deletion or replacement in the test just added, and the invariant stays guarded either way.
  3. Consider the two Suggestions opportunistically. Both are the same theme as the finding 9569da89 just fixed — a verdict or a label whose referent the reader cannot resolve — surviving in the last two corners the fix did not reach, and both are latent rather than live at this head.

CI at this head is still queued for the full matrix (Build, Typecheck + Release Registry, General tests, e2e); policy, Helm chart, and Vendored claude_k8s adapter have completed success. reviewDecision is empty, so no required-review protection is unmet on this PR.

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.

0 participants