Skip to content

Five backlog cards, and a published package that was shipping four high advisories - #30

Merged
illodev merged 5 commits into
mainfrom
fix/t-0221-consumer-audit-and-backlog-sweep
Aug 7, 2026
Merged

Five backlog cards, and a published package that was shipping four high advisories#30
illodev merged 5 commits into
mainfrom
fix/t-0221-consumer-audit-and-backlog-sweep

Conversation

@illodev

@illodev illodev commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Five backlog cards. They are independent, one commit each, and the order is the order they were worked — the last two exist because the fourth found something.

Card Commit Status
T-0213 Doctor stops trusting what a repository's healthCheck hands it done
T-0206 Two claims are one process only when one session, never when one actor done
T-0182 Check what callers send to the CLI, not only what each subcommand reads done
T-0148 / T-0220 Supply-chain gates run on pull requests, and audit the tree a consumer resolves T-0220 done, T-0148 in review
T-0221 Drop transformers.js from search-local, clearing four advisories it shipped done

The one that matters most

Working T-0148 turned up a gate measuring the wrong tree. pnpm audit audits this workspace, and this workspace had four pnpm.overrides. Overrides are a workspace-install mechanism and do not travel inside a published package. Two of the four — sharp and adm-zip — sat under @huggingface/transformers, a dependencies entry of the published @illodev/workfile-search-local.

So the audit had read clean since T-0023, and anybody who ran npm i @illodev/workfile-search-local@0.8.1 resolved four high-severity advisories with no upstream fix: the libvips CVEs in sharp <0.35.0 (GHSA-f88m-g3jw-g9cj) and GHSA-xcpc-8h2w-3j85 in adm-zip <0.6.0.

T-0148's own body reads the workspace/tarball distinction as reassuring — "nothing vulnerable was ever going to reach a user" — and for two of the four entries it was the opposite.

Fixed by changing what is shipped rather than by annotating it: search-local now reaches the same ONNX weights through onnxruntime-web and @huggingface/tokenizers. Measured against the implementation it replaces before committing to the route — per-vector cosine 0.9978 on the same q8 weights, unit norms, identical ranking order.

Gate policy, one decision for both cards

ADR-0021. pnpm audit --audit-level=high becomes its own blocking job on pull requests, and scripts/audit-consumer.ts resolves what the publishable manifests declare, with no overrides, failing at high or above. Blocking with no allowlist — a doctor --accept-baseline-style list was refused deliberately, and the refusal is what forced the real fix.

Local pnpm run check gains neither gate: it runs constantly, both need the network, and posture is a property of what gets published. That asymmetry is stated in the ADR as a trade rather than hidden.

Two premises I filed wrong and corrected on the record

  • T-0220 claimed smoke:package only runs on a tag. It does not — ci.yml has had a smoke job on pull_request since before T-0182, so criterion 1 was already satisfied. Body rewritten.
  • T-0221 listed optionalDependencies as a route. It would not have worked: npm installs them by default. The mechanism that removes a dependency from a consumer's tree is peerDependenciesMeta.optional, verified empirically both ways.

Reviewing

  • pnpm run audit:consumer — clean at high and above, and below it too.
  • pnpm why sharp / pnpm why adm-zip — neither is in the graph, which is why those two overrides are gone.
  • Semantic search really runs: cold 45.8s including the 135 MB download, warm 2.2–3.1s, ranking byte-identical across runs.
  • Guards were mutation-proven rather than assumed. Flipping separatesFromMe to return true fails 4 of 6 pinned pairings; dropping --scope from the card claim row fails the caller check naming 10 sites; removing the attention-mask guard fails both new pooling tests.

Note the intermediate commits: at cc8d738 the consumer audit is red by design, because the exposure it names is fixed by 10c1e38. The branch tip is green.

What is left for you

T-0148 stays in review, not done. Its remaining criterion asks that a pull request prove the audit job fails in CI, and a local run is not that. This PR is the run — check the audit job, then it can close.

One user-visible cost, in CHG-0149: the model cache moved to ~/.cache/workfile/models, so the first search after upgrading re-downloads about 135 MB once. Embedding caches stay valid. In exchange, pointing model at a directory now skips the download entirely, so this runs with no network — which it could not before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU

illodev and others added 5 commits August 7, 2026 20:21
T-0213 asked whether `doctor` should call a `healthCheck` declared by
`project.config.mjs` at all. It should: `loadWorkspace` already `import()`s
that module in every command, so the module body runs earlier and more
quietly than any hook, and removing the call closes nothing while costing
the feature. LRN-0029 lists all three channels that execute
repository-declared code, with every caller and where it runs.

What was actually broken is that `doctor` trusted the hook's answer:

- a throw propagated out of `runDoctor` and took every caller down with it,
  so the report that would have named the broken integration never printed;
- a hook that never settled had no bound at all;
- `{ severity: "catastrophe" }` wrote NaN into the counts, landed in no
  bucket, left `ok` true and made the comparator sort on NaN — an
  integration could decide whether the repository passed, by typo.

Each call is now isolated, bounded at 10s and validated, attributed to its
integration by id, and all three failures are errors because `doctor` is a
gate. Well-formed entries in a rejected batch still land. A project that
declares no integrations sees none of it, proven by a test.

The bound is partial on purpose and the code says so: a hook runs on
`doctor`'s own event loop, so it catches an awaited hang and not a
synchronous spin. The timer is deliberately not `unref`ed — that was the
first draft, and it let Node exit before the bound fired, printing no
report at all.

The three generated CI templates now state both hops rather than the first
one on GitHub only. "It imports a config file" reads as loading settings,
and somebody pricing this from that sentence prices it wrongly.

Cards: T-0213
Filed: T-0218

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
The skip that decided a conflict compared `claimed_by`, which reads as a
session check only because `resolveActor` writes a session discriminator
into the actor's tail. So it was right often enough to look correct and
wrong exactly where it matters: two plain terminals both resolve to
`user@host`, and two agents handed the same `--actor` both resolve to that.
Each is two processes about to overwrite each other, and each was dropped
as one person.

`claimSeparation` now answers "provably the same process" and names its
evidence — `sessions-differ`, `actors-differ`, or `unproven`. A session is
recovered from either place that carries it, the session file or the actor's
tail, both normalised through `sessionDiscriminator`, now exported so there
is one definition instead of two that can drift.

A second defect fed the same rule: `claimState` resolved a claim's session
with one `find` over `cardId === card.id || actor === claimed_by`, so two
cards held by one actor string could both be attributed to whichever
session came first — erasing the exact evidence the comparison needs.
Attribution now prefers the session that names the card.

ADR-0020 records the sessionless case: `unproven` is reported, not dropped
and not prompted on. Dropping it is the bug that let two terminals collide
with no trace; prompting on it interrupts somebody about a card they
claimed themselves, which is the guard people switch off.

The scope guard needed no behaviour change, and that is the finding rather
than a shortcut — because the session lives in the actor's tail, its string
comparison *is* the session comparison for every pairing it can see. Rather
than argue that, `separatesFromMe` names it and a new pin drives the real
hook over six pairings against `claimSeparation`.

One asymmetry stays, recorded rather than half-fixed: the snapshot can read
a session from a session file and the guard cannot, because `board.json`
carries none and the hook imports nothing from the package on a p95-under-
30ms budget. LRN-0030 and T-0219.

Cards: T-0206
Filed: T-0219

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
`COMMAND_FLAGS` is a contract with two sides and only one was checked.
`cli.test.ts` pins the table against the flags each subcommand reads, in
both directions, and it caught a stale `card archive --actor` in the branch
that removed `init --language`. That branch still failed in CI, because
nothing checked the flags a *caller* sends — and the callers were the
generated agent instructions, the docs and the package smoke test.

The new check covers exactly the sources `pnpm run test` does not execute:
text under `.project/agents`, `.claude`, `plugins/workfile/commands`, the
docs and the README, plus `scripts/` and `test/package-smoke.ts`.

The unit tests are deliberately not scanned. They are full of flags that are
meant not to exist — `--bogus`, `--nonsense`, `--statuss` — because
asserting the refusal path is their job, and scanning them would mean an
allowlist of intentional nonsense with a real stale flag able to hide in it.
They need no scanning: a test that sends a removed flag fails when the suite
runs.

Command-word resolution mirrors `commandKey` and reads `USAGE_ALIASES` and
`DEFAULT_SUBCOMMAND` out of the source, which the survey forced — without
aliases, `workfile docs create --kind` in SPEC.md looked stale and it is a
valid alias for `doc`. A word that resolves to no row is reported rather
than dropped.

It runs in 52ms, finds 116 invocations, and asserts a floor of 90 so a
regex that stops matching fails loudly instead of passing forever. Proven by
mutation on both halves: dropping `--scope` from `card claim` names 10
caller sites, dropping `--yes` from `init` names 3 including
`package-smoke.ts:215` — the exact command that failed in CI.

What it does not cover is named in the test itself, including the route not
taken: this reads text, so `check` still proves nothing about the packaged
artifact.

Cards: T-0182

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
…r resolves

T-0148 and T-0220 asked the same question about two gates living in
`check:release`, which runs on a tag push — so a failure landed on a release
rather than on a pull request. v0.5.4 is what that cost: a published tag
whose release did nothing, `Publish to npm` never reached, and the tag moved
onto the fix.

Two things turned up while answering it.

T-0220 was filed on a false premise, mine. `ci.yml` has had a `smoke` job on
`pull_request` since before T-0182, so a change that breaks the packaged bin
already fails on a pull request; the T-0158 failure that prompted this was
that job working as designed. Its body is corrected rather than left
standing.

And the audit gate was measuring the wrong tree. `pnpm audit` audits this
workspace, where four `pnpm.overrides` had already rewritten the graph.
Overrides are a workspace-install mechanism and do not travel inside a
published package. Two of the four — `sharp` and `adm-zip` — sit under
`@huggingface/transformers`, a `dependencies` entry of the published
`@illodev/workfile-search-local`, so they made this gate green and fixed
those advisories for nobody who installed it. T-0148's own body reads that
distinction as reassuring; for two of the four entries it is the opposite.

So `pnpm audit --audit-level=high` is now its own blocking job on pull
requests, and `scripts/audit-consumer.ts` resolves what the publishable
manifests declare, in a scratch root with no overrides, and fails on
anything at high or above. Blocking with no allowlist is a deliberate
posture, not an omission: a baseline had precedent in
`doctor --accept-baseline` and was refused, because the alternative is a
list that grows and a package that ships with the list as its answer.

Local `pnpm run check` gains neither gate. It runs constantly, both need the
network, and posture is a property of what gets published rather than of an
edit. That asymmetry is stated in ADR-0021 as a trade rather than hidden.

T-0148 stays in `review`: its remaining criterion asks a pull request to
prove the job fails in CI, and a local run is not that.

Cards: T-0220
Decisions: ADR-0021

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
…hipped

`@illodev/workfile-search-local` declared `@huggingface/transformers`, which
hard-depends on `sharp` and `onnxruntime-node` — image processing and an
archive extractor this package never touched. Both carry high-severity
advisories with no upstream fix: the libvips CVEs in `sharp <0.35.0`
(GHSA-f88m-g3jw-g9cj) and GHSA-xcpc-8h2w-3j85 in `adm-zip <0.6.0`. Anybody
installing 0.8.1 or earlier got all of them, while this repository's audit
read clean because overrides do not travel.

The pipeline was doing four things: fetch `tokenizer.json` and the ONNX
weights, tokenize, run the session, mean-pool over the attention mask and
L2-normalize. Done directly over `onnxruntime-web` and
`@huggingface/tokenizers` that is about 130 lines, and it audits clean with
no override and no allowlist.

Measured against the implementation it replaces before committing to the
route, on the same q8 weights and the same texts: per-vector cosine 0.9978,
unit norms, identical ranking order. The residual is the WASM and native
kernels disagreeing at quantized precision, not a difference in method.

The `sharp` and `adm-zip` overrides go with it — `pnpm why` finds neither
package in the graph now, so those entries had become pins on nothing.
`fast-uri` and `js-yaml` stay, and they are the honest kind: nothing ships.

One cost, in CHG-0149: the model cache moved to `~/.cache/workfile/models`,
so the first search after upgrading re-downloads about 135 MB once. Existing
embedding caches stay valid, being keyed by model and content. In exchange,
pointing `model` at a directory now skips the download entirely, so this
runs with no network — which it could not before.

Two corrections to what the card was filed with. `optionalDependencies`
would not have worked: npm installs them by default. The mechanism that
removes a dependency from a consumer's tree is `peerDependenciesMeta`
`.optional`, verified empirically both ways. And transformers 4.2.0 declares
all three heavy dependencies with no flag that omits them, so no
configuration could have avoided this.

All seven pre-existing tests inject `embedder` and touch none of the new
code; three were added for the parts that need no model. The pooling one was
mutation-checked, and it taught something worth keeping: after L2
normalization the divisor cannot matter, because dividing by the padded
width is a positive scalar that normalization cancels. The first version of
that test claimed otherwise in a comment, the mutation passed, and the
comment was what was wrong. It pins the mask now. LRN-0032.

Cards: T-0221
Changelog: CHG-0149

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workfile Ready Ready Preview Aug 7, 2026 6:24pm
workfile-site Ready Ready Preview Aug 7, 2026 6:24pm

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.

1 participant