Skip to content

feat(check): repair a drifted rule instead of only reporting it - #230

Merged
thecodedrift merged 3 commits into
mainfrom
openspec/alignment-restore-client
Sep 1, 2026
Merged

feat(check): repair a drifted rule instead of only reporting it#230
thecodedrift merged 3 commits into
mainfrom
openspec/alignment-restore-client

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Slice 6 of generator-payload-alignment. Stacked on #229 (task 5.8), which regenerates the types this builds against.

check has been parsing reconcile's unsafe, unknown and missing verdicts and reading none of them.

The task said "unsafe / unknown", and that was wrong

Read as a set, the entry shapes settle it:

Bucket Meaning Repairable
unsafe {file, expected, got} We hold bytes that drifted from what the server blessed Yes
missing {ruleId, file} The server expected a rule we never reported Yes
unknown {file} We hold a file the service never issued No

unknown has nothing on the server to fetch, which is exactly why its entry carries no rule id. And missing — omitted from the task — is the only bucket that already carries the id restore is keyed on.

So unsafe and missing route to restore. unknown gets an explanation instead, because "on your disk, never issued by the service" is an ordinary situation (hand-written, or belonging to another organization or installation) that currently reads as an unexplained skip.

Verification is against the signature reconcile already sent

Not the one the restore response carries. That distinction is the whole guarantee.

Verifying a response against its own signature field proves the service is internally consistent — which it would also be if it returned a newer generation of the rule. That is an upgrade wearing a repair's clothes, arriving mid-check, reviewed by nobody. Because unsafe.expected is already in hand there is exactly one acceptable answer, and "the service sent something newer" is refused by the same comparison that catches a corrupted transfer.

That is what makes task 6.4's "re-fetch never returns newer bytes" a test rather than a promise we relay. The case is covered explicitly, with an internally-consistent-but-newer payload.

A missing rule has no prior expectation, so it falls back to the response's own signature. Genuinely weaker, and the best available: we are fetching a rule we do not hold, so there is nothing local to disagree with. Stated in the module rather than left as a silent inconsistency.

Nothing repaired runs in the pass that repaired it

Restore rewrites the working tree and promotes nothing into the current run. An unsafe rule stays withheld whether or not its bytes were just restored, a missing rule was never a local candidate, and an unknown rule never runs. The next check reports the repaired signature and is blessed through the ordinary path.

Fetching code and executing it in the same pass that discovered the drift would move the gate, and the gate is the only reason any of this exists.

A repair that cannot happen is a notice, never a failed check — a rule that was not repaired stays withheld, which is already the safe state.

The weak link, and the ask behind it

An unsafe entry does not carry ruleId, so it is parsed out of .taskless/rules/runtime/<id>/check.ts. Anchored on the full prefix rather than on rules/runtime/, which is common enough that a wrong id would 404 and read as "the service lost your rule".

That parse makes repair depend on a layout that has already moved twice, and a move breaks it silently: wrong id, 404, rule stays unrepaired, nobody sees an error. Raised with the generator as N6 (ruleId on unsafe), on the same "don't make the client infer what the server knows" argument that settled D2. Tracked as task 6.7; the parse goes when the field arrives.

Also

tasks.md had drifted several items behind reality — 1.5, 2.5, 3.6, 5.9 were done and unchecked. Corrected here, along with the 6.2 rewrite above.

Verification

1103/1103 tests, typecheck, lint, and pnpm cli check clean.

Built on top of #229

Task 5.8 of generator-payload-alignment. The generator's file-set tier is live (their #122 deployed), so src/generated/api.d.ts is regenerated from the published schema.

This is the bottom of a two-PR stack; slice 6 (the restore client) stacks on top.

What the regeneration brought

rules is now a discriminated union — single-content, plus one file-set variant per engine — where it used to be one shape with optional fields. The runtime variant states signature as required:

/** REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run. */
signature: string;

That is the property we asked the generator for, now expressed in our types rather than as an assumption on both sides. A runtime rule executes only against a blessed signature, so an unsigned one gets written, verifies clean, and never runs.

Why the diff is not just the generated file

The union broke six sites reading .content and .tests off a rule without asking which variant they had. They are narrowed, not cast: isFileSetRule / isSingleContentRule in api/rules.ts.

Both key on files rather than on engine. engine is what the file-set variants have in common; files is what separates them from the single-content one. Narrowing on the wrong field reads as equivalent and silently admits a shape the branch cannot handle.

The carries both files and content check stays, even though the union now makes that unrepresentable. The type states what the service promises; the check defends against it breaking that promise, which is the only reason a client validates a payload at all.

One live bug closed

A payload carrying neither files nor content fell through to the single-content branch and handed yaml.stringify an undefined. That returns the string "undefined" rather than throwing, so the rule file was created and its contents were that word — a malformed rule on disk, discovered two steps from the cause.

Refused now, before the directory is created. Checked on content itself rather than on "not a file set", because a payload with neither is not a file set either, so the negative admits it. My first attempt got exactly that wrong and the new test caught it.

Verification

1087/1087 tests, typecheck, lint, and pnpm cli check clean.

The changeset is extended rather than duplicated: this is the same change that shipped slices 1 to 5, and one change gets one release note.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 6m 35s —— View job


Review of #230 — repair a drifted rule instead of only reporting it

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no threads, reviews, or top-level comments beyond the trigger, so this is the first review of this PR; everything below is [New].

I read all 6 changed files plus the full bodies of check.ts, reconcile.ts, deliver.ts, files.ts, rule-hash.ts, api/rules.ts, and layout.ts for context the diff hunks alone don't show (per the note that git isn't available here, this used gh pr diff + Read). The design reasoning in the PR body and module comments checks out — the unsafe/missing vs. unknown split, verifying against expected rather than the response's own signature, and "nothing repaired runs in this pass" are all implemented as described. Found a few concrete issues, ranked by severity.


1. [New] Repair notices never reach --json output — the primary observability channel for this feature is silently dropped in CI.
packages/cli/src/commands/check.ts:479 and :519-529

planRuntime now returns notices: repair.notices (all the "was restored" / "could not be restored" / "was not issued by the rule service" messages from repairWithheldRules). In run(), plan.notices is only ever passed to warn():

for (const notice of plan.notices) warn(notice);

and warn is a no-op under --json (if (!args.json) console.error(message)). The JSON envelope building further down only merges dispatched.notices (from runEngines) into the notices field:

...(dispatched.notices.length > 0 ? { notices: dispatched.notices } : {}),

plan.notices is never merged in. outputSchema (packages/cli/src/schemas/check.ts) even documents the reason such a field exists — "Human output for these is warn(), which is a no-op under --json. Absent from the envelope, a machine consumer... cannot tell." — but that reasoning wasn't applied to plan.notices.

Failure scenario: taskless check --json in CI on a repo where a runtime rule's check.ts was tampered with. Repair runs, either succeeds or fails, and either way nothing in stdout reflects it — no notices field, and stderr is suppressed by --json. A CI consumer gating on the JSON envelope has zero visibility into a repair event, which undercuts the PR's own design goal that repair status is surfaced as a notice rather than silently dropped.

2. [New] Restoring a drifted rule doesn't purge files that aren't part of the server's blessed set — a "restored" directory can still contain content the repair never touched.
packages/cli/src/rules/deliver.ts:247-260 (writeDeliveredFileSet), called from packages/cli/src/commands/check.ts:295

writeDeliveredFileSet only writes the files present in the delivered set; it never removes files already on disk that the delivered set doesn't mention (no rm/directory-reset before the write loop). The unsafe verdict this PR now repairs is specifically a signal that the on-disk rule directory diverged from what the server blessed — but repair only overwrites check.ts (and any same-named files the blessed set repeats), not the directory as a whole. Any extra file (e.g. under captures/, which is not part of what reconcile signs/reports — only check.ts's signature is reconciled) that was added or left behind before the repair survives it untouched, and repairWithheldRules still emits "${target.file} was restored to the bytes the service blessed", which reads as a stronger guarantee than what actually happened. Note writeRuleFile/writeDeliveredFileSet is shared with rule create/rule iterate (packages/cli/src/commands/rules.ts:243,483) and has the same non-cleanup behavior there, so this isn't a new bug in the helper — but this PR is the first caller that invokes it specifically because the directory's own trustworthiness is in question, which makes the gap materially more relevant here than in the create/iterate paths.

3. [New] restoreRule calls in repairWithheldRules run sequentially, one HTTP round-trip per unsafe/missing rule.
packages/cli/src/commands/check.ts:261-306

for (const target of targets) {
  const outcome = await restoreRule(token, { ... });
  ...
}

Each target is independent (different rule ids, same token/repositoryUrl), so this could be Promise.all'd. On a repo with several drifted/missing runtime rules, check now pays N sequential network round-trips it didn't pay before. Not incorrect, just avoidable latency on every check run until the rules re-converge.

4. [New] Casts bypass the generated types instead of using them, which is exactly what STYLEGUIDE-CODE.md's "Prefer Library Types Over Custom Definitions" warns against.
packages/cli/src/rules/runtime/repair.ts:436 and packages/cli/src/commands/check.ts:295

repair.ts:

const files = (rule as { files?: { path: string; content: string }[] }).files;

RestoredRule (packages/cli/src/api/restore.ts:125) is a discriminated union of the three engine variants, and files: {path,content}[] is present on all three — a plain rule.files access should type-check without a cast. Re-declaring the shape inline means a future schema change to files (e.g. an added required field, or a rename) is silently absorbed by the cast instead of failing the build, which is precisely the failure mode the style guide's worked example (prompts.js/es-module-lexer) describes for "reconstructing information the generator already had."

check.ts:295:

await writeRuleFile(cwd, rule as unknown as GeneratedRule);

RestoredRule is structurally a subset of GeneratedRule's file-set union member, so a direct as GeneratedRule should be assignable; needing as unknown as to satisfy the compiler is usually a sign TypeScript sees the two as insufficiently overlapping, worth double-checking rather than routing around.

5. [New] repairWithheldRules — the actual integration path (network call → id match → verify → write → notice, 5+ branches) — has no test coverage.
packages/cli/src/commands/check.ts:234-309; packages/cli/test/repair.test.ts

repair.test.ts thoroughly covers the pure helpers in rules/runtime/repair.ts (repairTargets, ruleIdFromCheckPath, verifyRestoredCheck), but nothing exercises repairWithheldRules itself or restoreRule. The existing runtime-check mock server (packages/cli/test/runtime-check.test.ts) only stubs /cli/api/reconcile, never /cli/api/rule/{ruleId}/restore, so none of the branches in repairWithheldRules (restore-unauthorized, restore-unavailable, rule-id-not-found-in-response, verify-failure, write-failure, success) run under test. Given finding #1, a test asserting what ends up in the --json notices field for a repaired/failed-repair rule would likely have caught that gap directly.


Nothing else stood out — the unsafe/missing/unknown routing logic, the "verify against expected, not the response's own signature" guarantee (with its dedicated "refuses bytes that are newer" test), the "nothing repaired runs in this pass" sequencing in planRuntime, and the tasks.md checkbox corrections all look correct and match their documentation.
· Branch

Base automatically changed from openspec/alignment-slice-6 to main September 1, 2026 06:34
Slice 6. `check` parsed reconcile's `unsafe`, `unknown` and `missing`
verdicts and read none of them.

THE TASK SAID "unsafe / unknown" AND THAT WAS WRONG. Read as a set the
entry shapes settle it: `unsafe` {file, expected, got} means the server
holds bytes we drifted from, `missing` {ruleId, file} means it expected
a rule we never reported, and `unknown` {file} means we hold a file it
never issued. The first two are repairable; the third has nothing on the
server to fetch, which is exactly why its entry carries no rule id.
`missing` was omitted from the task despite being the only bucket that
already carries the id restore is keyed on.

So `unsafe` and `missing` route to restore, and `unknown` gets an
explanation, since "on your disk, never issued by the service" is
ordinary (hand-written, or another org or install) and read as an
unexplained skip.

Restored bytes are verified against the signature reconcile ALREADY
sent, not the one the restore response carries. Verifying a response
against itself proves the service is internally consistent, which it
would also be if it returned a NEWER generation of the rule: an upgrade
wearing a repair's clothes, arriving mid-check, reviewed by nobody.
Because `unsafe.expected` is in hand there is exactly one acceptable
answer, which is what makes "restore never returns newer bytes" a test
rather than a promise we relay.

Nothing repaired runs in the pass that repaired it. Restore rewrites the
working tree and promotes nothing into the current run, so an `unsafe`
rule stays withheld and the next `check` gets it blessed through the
ordinary path. Fetching code and executing it in the same pass that
discovered the drift would move the gate, and the gate is the point. A
repair that cannot happen is a notice, never a failed `check`: an
unrepaired rule stays withheld, which is already safe.

The rule id for an `unsafe` entry is parsed out of
`.taskless/rules/runtime/<id>/check.ts`, anchored on the full prefix
because `rules/runtime/` alone is common enough that a wrong id would be
a 404 reading as "the service lost your rule". That parse is the weak
link and is asked for as N6: the layout has moved twice, and a move
breaks repair silently. Delete it when the entry carries the id.

Also corrects tasks.md, which had drifted several items behind reality.
Review feedback on #229, applied here because #229 had already merged.

`content: null` reproduced the exact bug #229 says it closes. The guard
tested `content === undefined`, and `yaml` does not throw on a value it
cannot make a document of, it renders one: `undefined` becomes the
string "undefined" and `null` becomes the string "null". Either way the
rule file is created and its entire contents are that word. A string or
a number does the same.

The test is now "a usable object" rather than "not undefined", and the
three cases are covered. Confirmed by restoring the old guard and
watching all three fail.

Worth naming why two checks in this function disagree about `null`, and
why that is correct. The mutual-exclusion check asks what the payload
CLAIMS, so any present `content` — `null` included — means the service
sent both envelopes. This one asks what can be WRITTEN. Collapsing them
into one predicate would make one of the two wrong.

Also drops a dead disjunct the reviewer spotted: `!isSingleContentRule`
could never be true there, since `files` is provably absent by that
point and the helper is defined as its negation.

And a file set arriving with a stray `tests` now fails loudly rather
than dropping it. The published schema makes that unrepresentable, so
this is the same defence the rest of the path already applies to a
broken promise — a fixture that vanishes silently shows up much later as
a rule that tests nothing.
…iring

Review feedback on #230.

THE FEATURE'S ENTIRE OUTPUT WAS INVISIBLE TO CI. Repair notices reached
`warn()` only, which is a no-op under `--json`, and the envelope merged
`dispatched.notices` alone. So the one channel a CI run reads dropped
every "was restored" and "could not be restored" message — from the
feature whose whole purpose is explaining why a rule did not run.

The reviewer's other point is the reason it survived: `repair.test.ts`
covers the decisions as pure functions and cannot see the wiring.
`repair-integration.test.ts` exercises the real path against a mock
serving both endpoints, and all four cases fail against the old envelope
code, so the coverage gap and the defect were the same thing.

Writing that test found something real. A restore returning only
`check.ts` is refused, because `writeRuleFile` enforces the delivery
contract and a runtime rule needs its captures. That is correct — the
schema calls `files` "every file the rule directory must contain" — so
the fixtures now return the complete set, which is what the service
sends.

Also from review:

- `restoreRule` calls run concurrently. Each target is a different rule
  under the same token, so they do not order against each other. The
  WRITES stay sequential, because a half-applied set is the state this
  path exists to avoid.
- Two casts removed in favour of the generated types. `rule.files` and
  `rule.signature` are on the union already, and re-declaring them
  inline would absorb a schema change instead of failing the build. The
  `as unknown as GeneratedRule` was unnecessary: `RestoredRule` is
  directly assignable, which the double cast was hiding.
- The success notice said the rule "was restored", which overstated it.
  The delivered set is written over the directory without removing files
  it does not mention, and only `check.ts` is signed, so nothing here
  can vouch for the rest. It now says the check was rewritten with the
  blessed bytes. The gap itself is #233, since it is shared with
  `rule create` and `rule iterate` and wants one answer for all three.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 6m 35s … Review of #230 — repair a drifted rule instead of only reporting it"
#230 (comment)

All five addressed in 11df67d. Finding 1 was the important one and it was worse than "low".

1 — repair notices never reached --json. Confirmed at check.ts:479 and :527: plan.notices went to warn() alone, which is a no-op under --json, and the envelope merged only dispatched.notices. The entire output of a feature that exists to explain why a rule did not run was invisible to CI. Both sources are merged now.

5 — and your reason it survived is right. repair.test.ts covers the decisions as pure functions and cannot see the wiring. repair-integration.test.ts now drives the real path against a mock serving both endpoints, and all four cases fail against the old envelope code — the coverage gap and the defect were the same thing.

Writing that test found something the review could not: a restore returning only check.ts is refused, because writeRuleFile enforces the delivery contract and a runtime rule needs its captures. That is correct behaviour — the schema calls files "every file the rule directory must contain" — so the fixtures now send the complete set, which is what the service does.

4 — both casts were hiding something. rule.files and rule.signature are on the union already. And you were right to flag as unknown as GeneratedRule as suspicious: RestoredRule is directly assignable, so the double cast was unnecessary and was masking that fact. Removed.

3 — restores now run concurrently. The writes stay sequential, since a half-applied set is the state this path exists to avoid.

2 — the notice was overclaiming, and that part is fixed here. It now says the check was rewritten with the blessed bytes rather than that the rule was restored, because the delivered set is written over the directory without removing files it does not mention, and only check.ts is signed. The underlying gap is filed as #233 rather than fixed here: writeDeliveredFileSet is shared with rule create and rule iterate, and deleting files inside a repair deserves one deliberate answer for all three callers rather than a special case.

1110/1110 tests, typecheck, lint, and pnpm cli check clean.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit 48f4e92 into main Sep 1, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the openspec/alignment-restore-client branch September 1, 2026 07:00
thecodedrift added a commit that referenced this pull request Sep 1, 2026
#230 merged while this was in flight, and its integration fixture writes
a current-layout tree with no `taskless.json`. A tree without a manifest
reads as version 0, so `check` refuses it, and every repair notice the
test asserts on went missing.

The refusal is right and stays. Nothing can tell a current tree with no
manifest from a pre-`0004` project by looking, and the safe answer to
"I cannot confirm this is current" is to say so rather than to read it
and report whatever falls out — which in that fixture's case was an
empty envelope.

So the fixture completes its scaffold, the way every other suite here
now does.
thecodedrift added a commit that referenced this pull request Sep 1, 2026
#230 merged while this was in flight, and its integration fixture writes
a current-layout tree with no `taskless.json`. A tree without a manifest
reads as version 0, so `check` refuses it, and every repair notice the
test asserts on went missing.

The refusal is right and stays. Nothing can tell a current tree with no
manifest from a pre-`0004` project by looking, and the safe answer to
"I cannot confirm this is current" is to say so rather than to read it
and report whatever falls out — which in that fixture's case was an
empty envelope.

So the fixture completes its scaffold, the way every other suite here
now does.
thecodedrift added a commit that referenced this pull request Sep 1, 2026
#230 merged while this was in flight, and its integration fixture writes
a current-layout tree with no `taskless.json`. A tree without a manifest
reads as version 0, so `check` refuses it, and every repair notice the
test asserts on went missing.

The refusal is right and stays. Nothing can tell a current tree with no
manifest from a pre-`0004` project by looking, and the safe answer to
"I cannot confirm this is current" is to say so rather than to read it
and report whatever falls out — which in that fixture's case was an
empty envelope.

So the fixture completes its scaffold, the way every other suite here
now does.
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