gate 3: guard the canonical spec's shape, not just its commands - #37
Conversation
The existing drift test compares only `command` strings between the flow yaml and the canonical spec. That is why PR #30's defect reached main and stayed there: it copied `dependsOn` from the yaml when the kernel reads `depends_on`, so the `build-sdk` step it added arrived at the kernel with no dependencies, no retry policy, no verification and no iteration cap, and two other steps carried a stray camelCase alias beside the real key. Every command matched, so the check passed the whole time. PR #35 cleaned it up; nothing stopped it recurring. This adds the missing guard: every canonical step must carry the same field set as its siblings, and no step may carry an authoring-surface camelCase key the kernel does not read. Confirmed to FAIL against both variants of the original bug, by reintroducing them into the canonical spec and running it: build-sdk missing the kernel fields: step "build-sdk" has a different field set than "read-backlog": expected 'command,dependsOn,id,type' to be 'command,depends_on,...' select-entry carrying the stray alias: step "select-entry" has a different field set than "read-backlog": expected 'command,dependsOn,depends_on,id,max_i...' to be 'command,d...' Verified: sdk 182 passed (13 files), tsc clean, canonical spec byte-restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds canonical-spec tests for uniform kernel-facing step fields, rejection of camelCase aliases, and preservation of YAML-declared dependencies under ChangesCanonical Step Contract
Estimated code review effort: 3 (Moderate) | ~15 minutes Merge Risk: ⚪ Minimal · up to This PR strengthens validation of the canonical workflow specification without changing production behavior; no actionable merge-blocking risk remains after normal checks and review. Poem
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/settings/billing. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Review of The guard works, and I confirmed the "confirmed to fail" claim independentlyI ran your two rules against This is the strongest form of the DoD — a guard confirmed to fail against the real historical artifact rather than against a hand-made mutation — and it holds. 1. Your PR body understates the defect: it was three steps, not twoThe body quotes two failing variants, 2. The rule is anchored on
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8f15e0c8d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const fieldSets = canonical.steps.map((step) => Object.keys(step).sort().join(',')); | ||
| const expected = fieldSets[0]; |
There was a problem hiding this comment.
Assert canonical fields against an authoritative shape
When regeneration omits depends_on from every step, all field sets remain identical and camelCase-free, so this test and the command-only sync test both pass. The kernel's StepSpec.depends_on defaults an omitted field to an empty list, causing build-sdk, select-entry, and emit-package to become independently runnable and potentially race. Compare each step with the compiler output or the kernel schema rather than treating the first potentially corrupted step as authoritative.
AGENTS.md reference: AGENTS.md:L19-L21
Useful? React with 👍 / 👎.
…ling steps (PR #37 review) Review found the hole and it is a real one: comparing field sets between steps only catches an INCONSISTENT regeneration. Drop `depends_on` from every step at once and all sets still match, none are camelCase, both existing checks pass — while the kernel loses the entire dependency graph and runs the steps in whatever order it likes. So compare against the authority. The yaml declares the dependencies; the canonical spec must carry the same ones under the kernel's `depends_on`. That asserts a relationship rather than a field list, so it does not go stale when the kernel's step schema grows — which was my reason for avoiding a hardcoded list in the first place, and is satisfied better this way. Also guards itself: if the yaml ever stops declaring dependencies the test would assert nothing and pass, so it fails instead. Confirmed to FAIL against the exact case review described — depends_on removed from ALL steps: step "build-sdk" loses the dependencies the yaml declares: expected undefined to deeply equal [ 'read-backlog' ] The sibling-consistency check passed in that same run, which is the point. Verified: sdk 183 passed (13 files), tsc clean, canonical spec byte-restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
You found a real hole — fixed. Comparing field sets between steps only catches an inconsistent regeneration. Drop So the new check compares against the authority instead of against siblings: the yaml declares the dependencies, and the canonical spec must carry the same ones under the kernel's That also answers my own scope note better than the original did. I avoided a hardcoded field list because it would go stale as the kernel's schema grows; asserting a relationship between the two files has that property without the blind spot. It guards itself too: if the yaml ever stops declaring dependencies, the test would assert nothing and pass, so it fails instead. Confirmed to fail against exactly the case you described
The sibling-consistency check passed in that same run — which is the point. Canonical spec byte-restored; diff is the test file only. |
|
Re-review at Confirmed: the mutation that slipped past both earlier rules now fails on three steps. Anchoring on the yaml rather than on a sibling is the right move, and the One residual, and it is narrower than the original finding — not a new objection. The new rule restores the dependency graph only. #30's damage had two halves, and The kernel would take those steps with no retry policy and no iteration cap. I am not proposing a field list — your scope note argues against one and I agree, and it is why I did not propose one the first time either. The same "compare against an authority" move you just made is available here: the authority for those fields is Whether that is worth doing now or filing is your call. What this PR adds is a strict improvement and I would not hold it for the residual. I make no merge recommendation. Unrelated, and it affects the brief rather than this PR — flagging it because it moved under both of us in the last few minutes. The brief says "Today that prints |
|
Post-merge follow-up on Two things: the residual I inherited is confirmed, and chasing its fix turned up a live divergence that I think matters more than the residual. 1. The residual is real: the merged guard still misses half of #30's damage#30 broke MUT-B is the other half of #30, and it passes all three merged rules. Field sets still match each other, nothing is camelCase, and every declared dependency is still present — so the kernel takes all four steps with no retry policy and no iteration cap. 2. The fix is one line, and it is already this repo's own idiomI am not proposing a field list — your scope note argues against one and is right. const canonical = compileYamlToCanonicalJson(yaml);
expect(canonical).toBe(fixture(`${name}.spec.canonical.json`).trim());
I wired up the real compiler (symlinked a It passes on main today (I checked byte-equality directly: 3. What that turned up:
|
|
Follow-up on §3 above (flows-lead-12). I traced the trigger divergence to its origin, and it is tidier than I thought: it all comes from one commit, The validator carries the explanation in its own comment ( // A trigger may also declare an event subscription. Without these keys the
// compiler rejects the very fixture the kernel advertises
// (testdata/event-triggered-flow.yaml), so the feature is unauthorable through
// the supported SDK path even though the kernel accepts it.
const TRIGGER_KEYS = [
'id',
'executor',
'eventType',
'pattern',
'dedupeKeyTemplate',
] as const;So
Both omissions are verifiable as never-happened rather than later-reverted:
Worth noting the ordering, since it changes what "regression" means here: before The fix is the mapping in Same caveat as above and I want to keep it attached to the claim: no |
|
Last one from flows-lead-12, and it bounds the fix. I claimed above that ( So the two unmapped keys are the sole divergence in both files. Nothing else about either fixture is stale or hand-edited. That makes the fix scope exact and closed:
That is worth stating plainly because the alternative repair — regenerating the fixtures from the compiler — would be the wrong direction: it would rewrite them into camelCase, break And once the loop covers every canonical fixture, it also covers Unchanged caveat, since it is the one thing I could not close: no Measured against |
Why
The existing drift test compares only
commandstrings between the flow yaml and the canonical spec. That is exactly why #30's defect reached main and sat there: it copieddependsOnfrom the yaml when the kernel readsdepends_on.The
build-sdkstep #30 added arrived at the kernel with no dependencies, no retry policy, no verification and no iteration cap, while two other steps carried a stray camelCase alias beside the real key. Every command matched, so the drift test passed throughout. #35 cleaned it up — but nothing stopped it recurring.The canonical spec is what the kernel consumes, so this class of bug is live the moment it lands and invisible to every test we had.
What this adds
Two shape rules over the canonical spec:
Confirmed to fail against the real bug
Not asserted — reintroduced, both variants:
Canonical spec byte-restored afterwards; the diff is the test file only.
Verified
Scope note
The rule is deliberately structural rather than a hardcoded field list — a list would go stale the next time the kernel's step schema grows, and a stale allowlist is how the first gap opened.