Skip to content

fix: take an unsafe entry's rule id from the entry, not its path - #242

Merged
thecodedrift merged 2 commits into
mainfrom
fix/240-unsafe-entry-carries-rule-id
Sep 2, 2026
Merged

fix: take an unsafe entry's rule id from the entry, not its path#242
thecodedrift merged 2 commits into
mainfrom
fix/240-unsafe-entry-carries-rule-id

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Rule repair no longer reads a rule id out of a filesystem path.

The fallback

repairTargets in packages/cli/src/rules/runtime/repair.ts parsed the id out of the reported check.ts path with ruleIdFromCheckPath, matching .taskless/rules/runtime/<id>/check.ts from the right. It existed only because a reconcile unsafe entry did not carry ruleId, and it tied repair to a filesystem layout that has already moved twice (0004, 0005). Its own comment named the failure and the exit: a further move yields a wrong id, restore 404s, and the rule stays unrepaired and unexecuted — with nothing on stdout, nothing on stderr, and nothing on the --json envelope. A rule that never fires looks exactly like a clean pass.

The evidence that retires it

GET https://app.taskless.io/cli/api/__schema (public, no auth), fetched from the deployed service:

unsafe   required = [expected, file, got, ruleId]
missing  required = [file, ruleId]
unknown  required = [file]

Our hand-written UnsafeEntry in packages/cli/src/api/reconcile.ts declared only { file, expected, got }, so ruleId was on the wire and invisible to our code.

What changed

  • UnsafeEntry gains ruleId.
  • repairTargets reads it off the entry for both repairable buckets, exactly as missing already did.
  • Deleted: ruleIdFromCheckPath, the TASKLESS_DIRECTORY constant and RULES_DIRECTORY import it needed, the unidentifiable bucket it fed, and the check.ts notice that reported it. An entry can no longer fail to identify itself, so repairTargets returns a plain RepairTarget[] instead of { targets, unidentifiable }.
  • Deleted from test/repair.test.ts: the describe("reading a rule id out of a check path") block and the unidentifiable-entry case.

Deliberately untouched:

  • verifyRestoredCheck still compares restored bytes against unsafe.expected, the signature reconcile sent, not the one the restore response carries. That is what makes "restore never returns newer bytes" testable, and the test asserting it still passes.
  • unknown still routes nowhere. There is nothing on the server to fetch for a file it never issued, which is why that entry alone carries no rule id.
  • Nothing repaired runs in the pass that repaired it.

The disagreeing-id test

test/repair-integration.test.ts gains a case whose reconcile fixture reports ruleId: "logs-abc12345" on a file at .taskless/rules/runtime/demo/check.ts — the entry and the path name different rules. It asserts restore is called with logs-abc12345 and that the blessed bytes land under that id's directory. A path-parsing implementation asks for demo and fails it. test/repair.test.ts covers the same disagreement as a pure function, over both unsafe and missing, plus a new case for an unsafe entry whose file sits outside the rules tree entirely — repairable now, dropped as unidentifiable before.

Proven to bite. Reinstating the parse (ruleId: entry.file.split("/").at(-2) ?? entry.ruleId) fails exactly the three new assertions and nothing else: 3 failed / 14 passed. Restored, all 17 pass.

Checks

pnpm build, pnpm typecheck, pnpm lint, pnpm --filter @taskless/cli exec vitest run (1169 passed, 73 files), and pnpm cli check against a fresh build — 4 warnings, the known pre-existing no-hedging set (3 in route.txt, 1 in onboard.txt).

Fixes #240

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 27s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no threads, reviews, or comments, so this is the first review of this PR — nothing to treat as already addressed.

Summary

This is a clean, well-scoped fix. repairTargets now reads ruleId straight off the reconcile entry for both unsafe and missing buckets instead of parsing it out of the reported check.ts path, matching how missing already worked. The path-parsing fallback (ruleIdFromCheckPath), the TASKLESS_DIRECTORY constant, the unidentifiable bucket, and its notice are all cleanly removed with no dangling references left behind (verified via grep — nothing else imports or reads any of the deleted symbols). RepairTarget[]'s new flat return shape is simpler than the old { targets, unidentifiable } pair and check.ts is updated accordingly.

Test coverage is solid: test/repair.test.ts covers the id-from-entry behavior as pure functions for both buckets (including an entry outside the rules tree, now repairable), and test/repair-integration.test.ts adds an end-to-end case where the entry's ruleId disagrees with its path's directory name, asserting both the exact restoreCalls value and that the blessed bytes land under the entry's id. The PR body's claim that reinstating the parse fails exactly these three new assertions is a good sanity check of test adequacy.

Deliberately-untouched behavior (verifyRestoredCheck comparing against reconcile's expected rather than the restore response's own signature; unknown routing nowhere; nothing repaired executing in the same pass) is unaffected by this diff and still correct.

One finding posted inline (packages/cli/src/rules/runtime/repair.ts:48-57, marked [New], minor/plausible): neither unsafe nor missing entries get any runtime validation that ruleId is actually present/non-empty — reconcile.ts decodes the response with a bare cast, not a schema. If a server response ever omitted ruleId on an unsafe entry (rollback, canary, future regression), the old code degraded gracefully to a clear "could not identify" notice; now restoreRule would be called with ruleId: undefined, producing a literal /cli/api/request/undefined/restore request instead. This gap already existed for missing before this PR, so it's not a new class of bug, but this PR does widen it to the (likely more common) unsafe bucket. Flagging as worth a defensive check, not a blocker — it contradicts the server's own published schema, which this PR's evidence establishes is solid ground to stand on.

No other correctness, security, or performance issues found in the diff.

Comment thread packages/cli/src/rules/runtime/repair.ts Outdated
`repairTargets` parsed the rule id out of a reported `check.ts` path
because reconcile's `unsafe` entries did not carry `ruleId`. They do:
the deployed `GET /cli/api/__schema` requires `[expected, file, got,
ruleId]` on `unsafe`. Our hand-written `UnsafeEntry` never declared the
field, so it arrived on the wire and stayed invisible.

The parse tied repair to a filesystem layout that has moved twice. A
further move breaks it with nothing anyone sees: a wrong id, a 404 from
restore, and a rule left unrepaired and unexecuted, which reads exactly
like a pass.

Deletes `ruleIdFromCheckPath`, the `unidentifiable` bucket it fed, and
the notice that reported it. `repairTargets` now returns a plain array,
since an entry can no longer fail to identify itself. `unknown` still
routes nowhere, and `verifyRestoredCheck` is untouched.

Fixes #240
`repairTargets` read `ruleId` off the entry unconditionally, and
`reconcile` decodes the response with a cast rather than a schema. The
deployed schema requires the field on both repairable verdicts, so the
`string` type is the service's promise and not a fact about the bytes
that arrived. A rollback, a canary, or a schema regression put
`undefined` into it and `restoreRule` interpolated it straight into
`/cli/api/request/undefined/restore` — a call that cannot succeed, made
for a rule nobody named, failing where nobody reads it.

Entries without a usable id are now partitioned out and reported as a
notice, the way the deleted `unidentifiable` path did. Covers `missing`
as well as `unsafe`, since the gap predates the entry-id change on that
bucket. Whitespace-only ids are rejected for the same reason an absent
one is: they encode into an equally meaningless request.

Nothing is guessed. Path parsing stays deleted; the answer to a missing
id is to report it. The rule stays withheld, which is already the safe
state, and the notice reaches the `--json` envelope so a CI run sees it
rather than reading a clean pass.

This echoes `writeRuleFile`, which keeps a runtime check for a payload
carrying both `files` and `content` even though the published union
makes it unrepresentable.
@thecodedrift
thecodedrift force-pushed the fix/240-unsafe-entry-carries-rule-id branch from 794ed01 to dd2a991 Compare September 2, 2026 02:44
@thecodedrift

Copy link
Copy Markdown
Member Author

Addressed in dd2a991 (rebased onto main @ effee6d). Refs #240.

The one finding was real. I reproduced it before fixing it: with the guard reverted, an integration test serving an unsafe entry with no ruleId records the mock receiving the literal string undefined as the rule id —

AssertionError: expected [ 'undefined' ] to deeply equal []

— which is /cli/api/request/undefined/restore exactly as you predicted.

What changed. repairTargets partitions instead of trusting entry.ruleId. Entries whose id is absent, empty, or whitespace-only go to an unidentified bucket, never become a request, and are reported as a notice on the --json envelope. Four properties held deliberately:

  • Both buckets. You flagged that missing had the same gap before this PR; it is covered too, so the pre-existing hole closes alongside the one this PR widened.
  • No path parsing. The fallback stays deleted — a test asserts the entry is not rescued by the directory name sitting in its own path. The answer to a missing id is to report it, not to guess one.
  • A notice, never a failed check. A rule that could not be repaired stays withheld, which is already the safe state.
  • Consistent with existing precedent. writeRuleFile keeps a runtime check for a payload carrying both files and content even though the published union makes it unrepresentable. Same reasoning: the type states what the service promises; the check defends against it breaking that promise. Your point that the schema is solid ground but reconcile decodes with a cast — so the type is not a fact about the bytes — is the crux, and it is right.

Proven to bite. Reverting repairTargets to the pre-fix mapping fails 10 assertions (9 unit, 1 integration) and nothing else. Restored, all pass.

Checks, re-run after the rebase rather than assuming the pre-rebase run held: pnpm build, pnpm typecheck, pnpm lint, vitest run (1180 passed, 73 files — up from 1169, +10 new and +1 inherited from #244), and pnpm cli check against a fresh build: 4 warnings, the known pre-existing no-hedging set (3 in route.txt, 1 in onboard.txt).

— AI Coding Agent

@thecodedrift
thecodedrift merged commit c6e84a7 into main Sep 2, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the fix/240-unsafe-entry-carries-rule-id branch September 2, 2026 03:10
thecodedrift added a commit that referenced this pull request Sep 2, 2026
The last open task was waiting on the generator sending `ruleId` on a reconcile
entry, which landed in #242: `unsafe`, `unknown` and `missing` all carry it, and
`ruleIdFromCheckPath` is deleted. N4 is confirmed both sides — the engine tier
stays defined and unused, since G2 means a Vale rule cannot be delivered without
its `.vale.ini` and so the middle rung has no future occupant.

The directory move is the smaller half. Archiving promotes the change's spec
deltas into the standing specs, which is what makes the contract official:
`cli-generated-rule-delivery` and `cli-layout-export` become capabilities, and
`cli-rule-reconciliation`, `cli-rules` and `cli-runtime-rule-execution` take the
requirements the slices implemented. Eight requirements added, two modified.

No code changes. The changeset was grown as the stack drained and already
carries every slice.
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.

Drop ruleIdFromCheckPath now that reconcile's unsafe entries carry ruleId

1 participant