Skip to content

test(workflows): guard against workflow-level schema fields being silently dropped at parse - #2459

Merged
Wirasm merged 2 commits into
devfrom
fix/2457-workflow-field-parity
Aug 5, 2026
Merged

test(workflows): guard against workflow-level schema fields being silently dropped at parse#2459
Wirasm merged 2 commits into
devfrom
fix/2457-workflow-field-parity

Conversation

@Wirasm

@Wirasm Wirasm commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Problem: parseWorkflow hand-assembles its result field by field, so a field added to workflowDefinitionSchema but not to that object literal is silently discarded — the YAML parses, the workflow loads, and the feature is simply inert. Nothing catches it.
  • Why it matters: it already happened. requires: was added to workflowBaseSchema in ab81248d (2026-06-01) without touching the loader; the assembly block only landed in 2d7bf587 (2026-07-16). Six weeks in which the GitHub capability gate could never fire for any discovered workflow — fixed incidentally inside an unrelated PR, not by anything noticing.
  • What changed: a parity test whose field list is derived from workflowDefinitionSchema.shape, plus a per-field round-trip fixture. A new schema field fails the test until it is given a fixture.
  • What did NOT change: zero production code. No behaviour change, no semantic change, no new dependency. git diff --stat is one test file.

This is the third instance of one pattern

Parallel enumerations that must agree, with nothing enforcing agreement:

Enumerations that must agree Enforced? Outcome
The three ref-surfaces (loader.ts scan / rewriteNodeOutputRefs / substituteNodeOutputRefs sites) No — a comment at loader.ts:226-231 Already broken#2450 found the when: shorthand escapes ref validation
workflowDefinitionSchema.shape vs parseWorkflow's literal No — nothing Already broken oncerequires:, six weeks
Node/workflow key sets vs their schemas Yes#2455 derives from .shape Cannot drift

The only difference in the third row is that it is derived rather than described. This PR applies that form to the second row.

Why a parity test and not deriving the assembly

Deriving the assembly (workflowDefinitionSchema.parse(raw)) would eliminate the class outright, and it is the wrong trade here. The hand assembly exists because of warn-and-drop: a present-but-invalid field is logged and dropped rather than rejecting the whole workflow, so one typo cannot abort a discovery pass. .parse() rejects instead. Restoring per-field warn-and-drop underneath a derived parse needs .catch() plus a logging hook on all 20 fields — a real refactor with behaviour-change risk on the discovery path, for the same protection.

Note what #2455 actually derives: a set of names. Names are cheap to derive and carry no semantics. This PR derives the same thing.

UX Journey

Before

Contributor            Schema                    parseWorkflow            Runtime
───────────            ──────                    ─────────────            ───────
adds `foo:` ────────▶  foo declared
                       (tests pass)
                                                 literal has no `foo`
                                                 → foo DROPPED silently
                                                                          feature inert
                                                                          no error, no warning
                                                                          no failing test

After

Contributor            Schema                    parseWorkflow            Test suite
───────────            ──────                    ─────────────            ──────────
adds `foo:` ────────▶  foo declared
                                                 literal has no `foo`
                                                                     ***  RATCHET FAILS  ***
                                                                     "schema keys with no
                                                                      parity fixture: foo"
adds fixture ─────────────────────────────────▶
                                                                     *** ROUND-TRIP FAILS ***
                                                                     "declared, accepted without
                                                                      a warning, and still did
                                                                      NOT survive parseWorkflow"
wires the literal ────────────────────────────▶  literal carries foo  green

Architecture Diagram

Before

  schemas/workflow.ts                     loader.ts
  ┌────────────────────────┐              ┌──────────────────────────┐
  │ workflowDefinitionSchema│              │ parseWorkflow            │
  │   .shape (20 keys)     │   ...no...   │   per-field warn-and-drop │
  │                        │  - - - - - ▶ │   object literal (20 keys)│
  └────────────────────────┘   linkage    └──────────────────────────┘

After

  schemas/workflow.ts                     loader.ts
  ┌────────────────────────┐              ┌──────────────────────────┐
  │ workflowDefinitionSchema│              │ parseWorkflow            │
  │   .shape (20 keys)     │              │   object literal (20 keys)│
  └───────────┬────────────┘              └────────────┬─────────────┘
              │                                        │
              │ Object.keys(...)  [+]                  │ round-trip  [+]
              ▼                                        ▼
        ┌──────────────────────────────────────────────────────┐
        │ [+] loader.test.ts — field-parity ratchet (#2457)     │
        │     derived key list === fixture list === survives    │
        └──────────────────────────────────────────────────────┘

Connection inventory:

From To Status Notes
schemas/workflow.ts loader.test.ts new Key list derived via Object.keys(workflowDefinitionSchema.shape)
loader.ts (parseWorkflow) loader.test.ts new Per-field round-trip assertion
schemas/workflow.ts loader.ts unchanged Still no enforced linkage — that is what the test now covers

Label Snapshot

  • Risk: risk: low
  • Size: size: S
  • Scope: tests
  • Module: workflows:loader

Change Metadata

  • Change type: chore
  • Primary scope: workflows

Linked Issue

Validation Evidence (required)

bun run validate   # wrapper exit 0

Grepped the log rather than trusting the wrapper's exit code:

$ grep -oE "[0-9]+ fail" /tmp/validate-2457.log | sort | uniq -c
 132   0 fail
$ grep -cE "[0-9]+ pass" /tmp/validate-2457.log
 132

132 test batches reported, every one 0 fail, and the pass-line count matches the fail-line count so no batch silently failed to report.

The guard was verified by breaking it, in both directions — a test that has never been red proves nothing:

  1. Reproduce the historical bug. Removed ...(requires !== undefined ? { requires } : {}) from parseWorkflow's literal:
    (fail) workflow-level field parity (#2457) > round-trips 'requires' through parseWorkflow
    error: Field 'requires' is declared on workflowDefinitionSchema, was accepted without a
    warning, and still did NOT survive parseWorkflow — so it is missing from the object
    literal parseWorkflow returns. That is the #2457 bug: add the field to that literal.
    
  2. Add a new schema field the way a future contributor would. Added brand_new_field: z.string().optional() to workflowBaseSchema, touching nothing else:
    (fail) workflow-level field parity (#2457) > has a fixture for every workflow-level schema key (the ratchet)
    error: Workflow-level schema keys with no parity fixture: brand_new_field. Add a fixture in
    FIELD_FIXTURES AND make sure parseWorkflow actually carries the field into its returned
    object literal — a schema field missing from that literal is silently discarded at parse.
    

Both breaks reverted; the diff is the test file alone.

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No

Test-only change.

Compatibility / Migration

  • Backward compatible? Yes — no production code touched
  • Config/env changes? No
  • Database migration needed? No

Human Verification (required)

  • Verified scenarios: the guard is green on unmodified dev before anything else (all 20 keys already present in the literal, so this lands as a ratchet rather than an unscoped bug hunt — worth knowing, since a red first run would have been a different-sized PR); both deliberate breaks above.
  • Edge cases checked: an invalid fixture value and a genuinely dropped field originally produced identical failures with a misleading message. Caught during development — my first thinking: fixture used thinking: true, which is not a valid thinkingConfigSchema value, so warn-and-drop correctly discarded it while the test blamed the loader. Fixed by clearing the mock logger before each parse and branching the message on whether a warning fired, so the two causes are told apart.
  • What was not verified: nothing runs this outside bun test; there is no runtime component to exercise.

Side Effects / Blast Radius (required)

  • Affected subsystems: @archon/workflows test suite only.
  • Potential unintended effects: the fixture map is hand-maintained, so adding a workflow-level field now requires a fixture — that is the intended cost, and the failure message says exactly what to do.
  • Guardrails: the test lives in loader.test.ts, which packages/workflows/package.json already runs in its own bun test invocation.

Rollback Plan (required)

  • Fast rollback: git revert <sha> — one test file, no production code.
  • Feature flags: none.
  • Observable failure symptoms: a red parity test where the field genuinely is wired, which would mean a bad fixture rather than a bug.

Risks and Mitigations

  • Risk: the fixture map drifts from reality — someone adds a fixture that passes without wiring the field.
    • Mitigation: not possible by construction. The fixture asserts the field survives parseWorkflow, so a fixture cannot be satisfied without the literal carrying the field.
  • Risk: a future field is genuinely hard to fixture (preprocess/union/passthrough shapes).
    • Mitigation: present is a survival predicate, not deep equality, so !== undefined suffices — which is exactly how thinking and sandbox are handled today.

Summary by CodeRabbit

  • Tests
    • Added coverage to ensure workflow fixtures stay aligned with the workflow schema.
    • Added validation that valid workflow fields are preserved during parsing without unexpected warnings.
    • Added checks to detect missing or outdated fixture fields.
    • Added safeguards to identify changes in workflow-level schema fields.

…ently dropped at parse

parseWorkflow hand-assembles its result field by field, so a field added to
workflowDefinitionSchema but not to that object literal is silently discarded:
the YAML parses, the workflow loads, and the feature is inert.

That already happened. `requires:` landed in workflowBaseSchema in ab81248
(2026-06-01) without touching the loader, and the assembly block only arrived in
2d7bf58 (2026-07-16) — six weeks in which the GitHub capability gate could never
fire for a discovered workflow, fixed incidentally inside an unrelated PR.

This is the third instance of one pattern: parallel enumerations that must agree
with nothing enforcing agreement. The ref-surface enumerations carry a KEEP IN
SYNC comment and were found broken anyway (#2450); the nested key sets are
derived from each schema's .shape and cannot drift (#2455). This applies the
derived form to the second case.

The field list comes from workflowDefinitionSchema.shape, so a new schema field
fails the test until it is given a fixture. Deliberately not solved by deriving
the assembly itself — the hand assembly exists because of warn-and-drop, and
schema.parse() would reject a bad field instead of logging and dropping it.

The per-field assertion clears the mock logger first so it can tell the two
failure causes apart: a warning means the fixture value is invalid (warn-and-drop
working as designed), silence means a valid field was dropped (the actual bug).

Verified by breaking it both ways: removing `requires` from the object literal
reproduces the historical bug and fails with the right diagnosis, and adding a
new schema key fails the ratchet until a fixture exists.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 066c311f-5302-41c2-9bdf-d07a6901b3a2

📥 Commits

Reviewing files that changed from the base of the PR and between abaca0e and c078e60.

📒 Files selected for processing (1)
  • packages/workflows/src/loader.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workflows/src/loader.test.ts

📝 Walkthrough

Walkthrough

The change adds schema-parity tests for workflow-level fields. The tests derive fields from workflowDefinitionSchema, validate fixtures, and confirm that parseWorkflow preserves valid values without warnings.

Changes

Workflow schema parity

Layer / File(s) Summary
Workflow field round-trip tests
packages/workflows/src/loader.test.ts
Adds schema and type imports. Tests detect missing fixtures, stale fixtures, invalid values, and valid fields dropped by parseWorkflow.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the added workflow schema-parity test and its purpose.
Description check ✅ Passed The description fully covers the template sections, scope, validation evidence, risks, compatibility, and rollback plan.
Linked Issues check ✅ Passed The test derives schema fields, verifies current fields survive parsing, and fails when future fields lack fixtures or loader wiring.
Out of Scope Changes check ✅ Passed The changes are limited to one workflow loader test file and directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/2457-workflow-field-parity

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Wirasm

Wirasm commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

PR Review Summary

PR: #2459test(workflows): guard against workflow-level schema fields being silently dropped at parse
Diff: packages/workflows/src/loader.test.ts (+144/−0), test-only
Mode: multi-agent (code-reviewer, code-simplifier, pr-test-analyzer, comment-analyzer) + independent verification

Headline: the guard actually guards. Verified empirically by two independent agents via mutation testing — deleting a field from parseWorkflow's returned literal turns the matching round-trip test red with a correctly-attributed message, for every shape tested (model scalar, persist_sessions boolean, tags array, worktree object, thinking preprocessed). The ratchet fires in both directions (new schema key with no fixture; stale fixture for a removed key). No defects found in what the PR claims to do.

Everything below is polish: 2 cheap edits worth making, 4 optional.


Critical Issues (0 found)

None.


Important Issues (2 found)

Agent Issue Location
comment-analyzer Doc comment states warn-and-drop as universal, but 3 of the fields in scope hard-reject packages/workflows/src/loader.test.ts:4102
pr-test-analyzer 3 of 20 present predicates are presence-only, so a wrong-but-valid value passes packages/workflows/src/loader.test.ts:4129,4130,4136

I1 — the warn-and-drop justification is factually overstated. The comment reads: "the hand assembly exists BECAUSE of warn-and-drop — a present-but-invalid field is logged and dropped rather than aborting the whole discovery pass." That is false for at least three fields, one of which (evidence_policy) is among the 20 under test:

  • evidence_policy — hard-rejects the whole workflow (loader.ts:610-625), with its own comment explaining why: "silently dropping a declared terminal-success gate would let a run complete ungated — not fail-safe."
  • unknown provider — hard-rejects (loader.ts:432-443)
  • persist_session capability mismatch — hard-rejects (loader.ts:483-496)

Verified directly, not taken from the agent. The conclusion still holds — one warn-and-drop field is enough to make a blanket .parse() wrong — but as written a future reader would conclude the loader never rejects a field, which is materially untrue and is exactly the kind of authoritative-sounding comment that gets acted on.

Suggested rewrite:

...the hand assembly exists because MOST fields use warn-and-drop (a few — evidence_policy, unknown provider, persist_session capability — deliberately hard-reject), and a blanket .parse() would hard-reject on all of them.

I2 — effort / thinking / sandbox use !== undefined instead of a value check. Confirmed by mutation: hardcoding the loader to return { type: 'disabled' } for thinking (fixture asks for adaptive) and 'low' for effort (fixture asks for high) leaves both tests green. The PR's own comment defends survival-over-equality on the grounds that fields are normalised in flight — but that excuse doesn't apply to these:

  • effortLevelSchema is z.enum(['low','medium','high','max']) (dag-node.ts:40) — zero transformation. w.effort === 'high' is exact.
  • thinkingConfigSchema (dag-node.ts:56) preprocesses 'adaptive'{ type: 'adaptive' } deterministically. w.thinking?.type === 'adaptive' is exact.
  • sandboxSettingsSchema.enabled is z.boolean().optional() (dag-node.ts:80) and the fixture sets enabled: true. w.sandbox?.enabled === true is exact.

All three schema shapes verified independently. Note the other 17 fixtures all do real equality or .includes() checks — these 3 are the outliers, not the house style. This does not break the headline claim (whole-field omission is still caught for all 20); it means a transposition among these three specifically would ship silently. Three one-line edits.


Suggestions (4 found)

Agent Suggestion Location
comment-analyzer "the assembly block only landed in 2d7bf58" is fragile phrasing loader.test.ts:4092
comment-analyzer The "warned ⇒ blame the fixture" diagnosis rests on an undocumented invariant loader.test.ts:4206-4213
code-simplifier Extract the assertion ternary into a named message loader.test.ts:4209
code-reviewer nodes predicate throws rather than returning false loader.test.ts:4120

S1 — the hand-assembly literal (with tags, worktree, betas, …) predates 2d7bf587; only the requires entry was added there. Context makes the intent clear, but a skim reads as "the whole mechanism didn't exist until then." Suggested: and requires wasn't added to that literal until 2d7bf587.

S2 — "a warning fired ⇒ your fixture value is wrong" is correct today: every field-specific getLog().warn in parseWorkflow is gated on its own field being present-and-invalid, and the fixed base skeleton can't trigger the ungated ones. Both the reviewer (20 call sites) and comment-analyzer (21) enumerated this independently. But it's an unstated invariant — add an inter-field warning later (e.g. "container.enabled together with worktree") and the message misdirects. Worth one line near FIELD_FIXTURES. Note the assertion itself is unaffected: warned only picks which failure string to print, so the worst case is a confusing message on an already-failing test, never a false pass.

S3 — the two long diagnostic strings inline in the ternary bury the assertion. Same behaviour, easier to scan:

const message = warned ? `…fix the fixture, not the loader.` : `…add the field to that literal.`;
expect(fixture.present(result.workflow as WorkflowDefinition), message).toBe(true);

S4nodes: { present: w => w.nodes.length === 1 }. nodes is an unconditional property, so deleting it from the literal yields undefined.lengthTypeError rather than a clean false. Bun still reports a failing test, so the ratchet holds; the diagnostic is just uglier. w.nodes?.length === 1 if you care.

Explicitly considered and NOT recommended (raised, then argued down):

  • Deleting the ~15 round-trip fixtures that overlap existing discoverWorkflows-based tests. The loop is the ratchet's enforcement leg — without it, the fixture-existence test could be satisfied by a bogus fixture. It's also pure (no file I/O) where the existing tests aren't.
  • Replacing the 20 present closures with a declarative {path, expect} table. Needs a branch per shape anyway; trades 20 self-evident one-liners for a small interpreter. Regression against KISS.
  • Swapping the yaml: '' sentinel for an optional key. A wash that churns all 20 literals for no readability gain.
  • Trimming the 30-line preamble. Checked against the precedent it cites (sqlite.test.ts parity block) — same style, in-family, not bloated. Only the inline git archaeology is rot-prone, and even that is describing a closed historical incident, so it won't silently drift.

Strengths

  • The guard was verified by breaking it, in both directions — by the author in the PR body, and reproduced independently here by two agents on five different field shapes. A ratchet that has never been red proves nothing; this one has.
  • The derivation is genuinely sound, not decorative. workflowDefinitionSchema = workflowBaseSchema.extend({ nodes }) is a plain ZodObject with no .refine/.transform/.passthrough/union wrapper, so .shape really does enumerate all 20 keys — the failure mode where .shape silently returns a subset doesn't apply.
  • Correctly scoped, and the scope is complete. Node-level fields go through dagNodeSchema.safeParse() and are returned verbatim (loader.ts:109, 764) — no hand-assembly, so the bug class is structurally impossible there. The web write path uses Bun.YAML.stringify(definition) and the server schema is re-derived from the engine one. Workflow-level really is the only exposed surface.
  • The historical claim holds up under independent check. ab81248d (2026-06-01) adds requires to the schema and leaves loader.ts untouched; 2d7bf587 (2026-07-16, the include: primitive PR) adds it to the literal. Both the enforcement path and the schema field landed in ab81248d, so the gate was genuinely dead for the full ~6.4 weeks — the claim is not inflated. Nice restraint on a story that would have been easy to overstate.
  • The two-branch failure message is a real piece of craft. Distinguishing "your fixture value is invalid" from "the loader dropped a valid field" is the difference between a 2-minute fix and an hour of blaming the wrong file — and per the PR body it was found the hard way during development.
  • Rejecting the tempting refactor was the right call. schema.parse(raw) would kill the class outright but trades warn-and-drop for whole-workflow rejection on the discovery path. Test-shaped guard, zero production risk, revert is one file.
  • Test isolation is clean. The new block is a sibling of describe('Workflow Loader') and so misses its beforeEach — harmless, because parseWorkflow is pure (no fs, no process.env, no ARCHON_HOME; grep-confirmed). mockLogger.warn.mockClear() runs inside each it(), so no cross-test bleed. Verified deterministic three ways: standalone, under bun --filter @archon/workflows test, and isolated via -t.
  • loader.test.ts already runs in its own bun test invocation in packages/workflows/package.json — no new mock-pollution batch needed.

Documentation Issues


Verdict

NEEDS FIXES (minor) — no defects in the guard; 4 one-line edits worth making before merge.

Nothing found blocks correctness, and no agent requested changes to the mechanism. Both Important items are cheap and both make a precision-tool more precise, which is the whole point of the artifact.


Recommended Actions

  1. I1 — rewrite the warn-and-drop sentence at loader.test.ts:4102 to say most fields, naming evidence_policy as the counterexample. (1 line)
  2. I2 — tighten three predicates: w.effort === 'high', w.thinking?.type === 'adaptive', w.sandbox?.enabled === true. (3 lines)
  3. Optionally take S1–S4 in the same commit — all one-liners.
  4. No re-review needed; re-run bun test packages/workflows/src/loader.test.ts and merge.

Reviewed by Claude — 4 specialist agents, findings independently re-verified against the code before inclusion.
Report: /Users/rasmus/.prp/archon-75601ef6/reviews/pr-2459-review.md

@Wirasm

Wirasm commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Addendum to the review above

The comment-analysis pass finished after I posted, with a field-by-field audit that sharpens I1 and promotes one suggestion to I3. Both re-verified against loader.ts directly.

I1 is wider than I first reported

I said three fields contradict the "warn-and-drop" claim. The full audit of all 20 says only 13 actually warn-and-drop:

Behaviour Count Fields
Hard-reject (whole workflow fails) 4 name (loader.ts:340-346), description (:347-357), nodes (:382-392, :400-419), evidence_policy (:615-630)
Warn-and-drop (matches the comment) 13 modelReasoningEffort, webSearchMode, interactive, effort, thinking, sandbox, fallbackModel, betas, worktree, container, mutates_checkout, tags, requires
Silent coerce, no log at all 3 provider (:424-425), model (:426), persist_sessions (:474)

Verified there is no invalid_provider / invalid_model / invalid_persist_sessions warn event anywhere in the file. So the docblock overstates in both directions — 4 fields never get dropped, and 3 get dropped more quietly than it describes.

New — I3: the warn/no-warn discriminator is blind for exactly those 3 fields

The two-branch failure message is the best thing in this diff: a warn means "your fixture value is invalid", silence means "the loader dropped a valid field — that's the #2457 bug." Sound for 17 fields. Backwards for provider, model, persist_sessions, because those coerce with no warn.

Write an invalid fixture for one of them — provider: 123, persist_sessions: "true" — and the field is discarded, nothing warns, and the test confidently prints "add the field to that literal," sending the next contributor into loader.ts when the fixture is what's wrong.

That is precisely the failure the PR body describes hitting during development (the bad thinking: true fixture that blamed the loader) and that this message exists to prevent — it just doesn't cover the three fields where the loader stays silent. No false pass, and no issue with the fixtures as written; it's a trap for whoever edits the table next. One line near FIELD_FIXTURES naming the three exceptions closes it, and covers S2 too.

Revised actions

  1. I1 — rewrite loader.test.ts:4102: most fields warn-and-drop, evidence_policy/name/description/nodes hard-reject, provider/model/persist_sessions coerce silently.
  2. I2 — tighten effort / thinking / sandbox predicates to value checks. (unchanged)
  3. I3 — one line noting the discriminator's three exceptions.
  4. Optionally S1, S3, S4.

Still no defect in the guard itself — all three items are comment/predicate precision on an artifact whose entire job is precision. Verdict unchanged: NEEDS FIXES (minor).

Addresses I1, I2, I3 and S1, S3, S4 from the review on #2459. No change to what
the guard catches; all six make a precision tool more precise.

I1 — the docblock claimed warn-and-drop universally. Re-verified the field audit
against loader.ts rather than taking it on faith: 4 of the 20 hard-reject
(name, description, nodes, evidence_policy at :619-629), 13 warn-and-drop, and 3
coerce silently with no log at all (provider :423, model :425, persist_sessions
:473 — there is no invalid_provider/invalid_model/invalid_persist_sessions warn
event anywhere in the file). Rewritten to say most rather than all, and to point
at loader.ts as the authority instead of restating a per-field table that would
rot the moment a field changes category.

I3 — the two-branch failure message was backwards for exactly those 3 silent
fields: a bad `provider: 123` fixture is discarded with no warning, so the
message confidently blamed the loader and sent the reader into parseWorkflow when
the fixture was at fault. That is the same failure the message exists to prevent,
and the one I hit during development with a bad `thinking: true` fixture. Fixed
by ranking rather than verdict: a warning is still strong evidence the fixture is
wrong, but silence now names both causes and points at the fixture first. Chosen
over listing the three exceptions in a comment, which would duplicate loader.ts
and rot. This subsumes S2's unstated-invariant concern.

I2 — effort, thinking and sandbox used presence checks where the other 17
fixtures check values, and their schemas transform deterministically, so exact
checks are available. Verified by mutation: returning effort:'low' and
thinking:{type:'disabled'} from the loader now fails both round-trips, where
before it left them green.

S1 — the hand-assembly literal predates 2d7bf58; only the requires entry landed
there. Reworded so it cannot be skimmed as "the mechanism didn't exist until then".

S3 — the two diagnostic strings moved out of the assertion into a named message.
S4 — nodes?.length, so a dropped nodes yields a clean false instead of a TypeError.

Verified: full validate green (132 batches, 0 fail); the I3 message re-checked by
running a deliberately invalid provider fixture; I2 re-checked by mutation.
@Wirasm

Wirasm commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c078e604all six items taken, none declined. I re-verified the field audit against loader.ts rather than accepting it, and it holds: 4 hard-reject, 13 warn-and-drop, 3 coerce silently. Two items are implemented differently from the suggestion; reasoning below.

Item Call Note
I1 fix now Rewritten, but not as a per-field table — see below
I2 fix now Three predicates tightened; verified by mutation
I3 fix now Fixed the message, not by listing the three exceptions — see below
S1 fix now One-line rewording
S2 subsumed by I3 The message no longer rests on the invariant, so there is nothing left to document
S3 fix now Named message — matters more now that I3 lengthened the strings
S4 fix now w.nodes?.length

I1 — accurate, but deliberately not a table

The suggested rewrite enumerated the categories with field names. I didn't take that shape: a three-way per-field table in a test docblock duplicates loader.ts and rots the moment one field changes category — the same class of problem as the counts this PR's sibling work is removing from CLAUDE.md. It now says most fields warn-and-drop, notes that a few hard-reject and a few coerce silently, keeps the load-bearing conclusion (one warn-and-drop field is enough to make a blanket .parse() wrong), and ends with "loader.ts is the authority on which field does what; do not restate it here."

I3 — the real fix was the message, not a list of exceptions

You're right that this is the sharpest finding, and it is the same failure the message exists to prevent — I hit it during development with a bad thinking: true fixture.

Rather than name the three silent fields in a comment (which rots identically), I removed the false verdict. A warning is still strong evidence the fixture is wrong. Silence is no longer treated as proof of the opposite — it now names both causes, ranked, and sends the reader to the cheaper one first:

Field 'provider' is declared on workflowDefinitionSchema and did NOT survive parseWorkflow, with no warning logged. Two possible causes, likeliest first: (1) the field is missing from the object literal parseWorkflow returns — that's the #2457 bug, add it there; or (2) the fixture value is invalid for a field that coerces silently without logging, in which case fix the fixture. Check the fixture value against the schema first — it is the cheaper of the two to rule out.

That is the actual output, captured by running a deliberately invalid provider: 123 fixture — the exact trap in the addendum. It no longer misdirects, and it stays correct if a fourth field joins the silent set.

I2 — verified by mutation, not by inspection

Making the loader return effort: 'low' and thinking: { type: 'disabled' } now fails both round-trips; before the change both stayed green. Your point that 17 of 20 already do value checks was the deciding argument — these three were outliers, not house style.

Verification

bun run validate green — 132 batches, all 0 fail, grepped from the log rather than trusting the wrapper exit code. Plus the two targeted checks above.

One thing from your review I'm acting on elsewhere

The Documentation IssueCLAUDE.md claiming the sqlite.test.ts parity test "compares table names only". Confirmed stale: that file now runs three tests with POSTGRES_ONLY_COLUMNS and SQLITE_ONLY_COLUMNS allowlists (sqlite.test.ts:495,500). It's going into #2463, the CLAUDE.md accuracy PR, where it belongs rather than into this test-only diff. Good catch — and you're right that it makes this PR's analogy more accurate than CLAUDE.md's own description of the thing it points at.

Wirasm added a commit that referenced this pull request Aug 5, 2026
Second pass on the accuracy audit. The first fixed false claims; this removes the
blocks that generate them — 1008 lines to 690, and 456 fenced lines to 128.

The test applied to each block: does a reader learn something here they could not
get faster by running the thing? Five failed it, and each was checked against the
docs site and the code before being cut.

  CLI command dump (136 lines) -> the three rules --help omits
      `archon --help` and reference/cli.md (640 lines) are both more current than
      this block already was: it never mentioned chat, setup, workflow
      search/install, continue, or doctor --full. What survives is what neither
      tells you — the git-repo requirement, isolation-by-default, and the --json
      approve/reject/resume semantics.

  packages/ tree (99 lines) -> package roots in dependency order
      Every dependency constraint it encoded is already stated, more precisely,
      in Package Split a hundred lines below. Order verified against each
      package.json: every package depends only on those above it. Inside a
      package, ls and the file docblocks outlive any tree drawn here.

  assistant defaults yaml (38) -> a paragraph
      reference/configuration.md carries the full key set. Kept the two keys
      worth knowing before you look: the binary paths, and settingSources.

  ~/.archon tree (20) -> a paragraph
      reference/archon-directories.md carries the layout. Kept the rule that
      matters: artifacts and logs live outside the repo and are never committed.
      The repo-level .archon/ tree stays — eight lines, referenced constantly.

  SDK type patterns, import patterns (40) -> rule lines
      Paired correct/wrong examples where the rule is one sentence.

Also corrects a claim the #2459 review surfaced: the sqlite.test.ts parity test
no longer compares table names only. It checks columns in both directions against
tracked allowlists, so a column added to one dialect and forgotten in the other
now fails CI.

Kept deliberately: the logging idiom and the error-handling patterns, which
encode exact shapes meant to be copied, and every behavioural section — Core
Principles, Engineering Principles and Product Direction are byte-identical.
Verified no section was lost: the heading list is unchanged.
@Wirasm
Wirasm merged commit 1d2fcc0 into dev Aug 5, 2026
4 checks passed
Wirasm added a commit that referenced this pull request Aug 5, 2026
Second pass on the accuracy audit. The first fixed false claims; this removes the
blocks that generate them — 1008 lines to 690, and 456 fenced lines to 128.

The test applied to each block: does a reader learn something here they could not
get faster by running the thing? Five failed it, and each was checked against the
docs site and the code before being cut.

  CLI command dump (136 lines) -> the three rules --help omits
      `archon --help` and reference/cli.md (640 lines) are both more current than
      this block already was: it never mentioned chat, setup, workflow
      search/install, continue, or doctor --full. What survives is what neither
      tells you — the git-repo requirement, isolation-by-default, and the --json
      approve/reject/resume semantics.

  packages/ tree (99 lines) -> package roots in dependency order
      Every dependency constraint it encoded is already stated, more precisely,
      in Package Split a hundred lines below. Order verified against each
      package.json: every package depends only on those above it. Inside a
      package, ls and the file docblocks outlive any tree drawn here.

  assistant defaults yaml (38) -> a paragraph
      reference/configuration.md carries the full key set. Kept the two keys
      worth knowing before you look: the binary paths, and settingSources.

  ~/.archon tree (20) -> a paragraph
      reference/archon-directories.md carries the layout. Kept the rule that
      matters: artifacts and logs live outside the repo and are never committed.
      The repo-level .archon/ tree stays — eight lines, referenced constantly.

  SDK type patterns, import patterns (40) -> rule lines
      Paired correct/wrong examples where the rule is one sentence.

Also corrects a claim the #2459 review surfaced: the sqlite.test.ts parity test
no longer compares table names only. It checks columns in both directions against
tracked allowlists, so a column added to one dialect and forgotten in the other
now fails CI.

Kept deliberately: the logging idiom and the error-handling patterns, which
encode exact shapes meant to be copied, and every behavioural section — Core
Principles, Engineering Principles and Product Direction are byte-identical.
Verified no section was lost: the heading list is unchanged.
Wirasm added a commit that referenced this pull request Aug 5, 2026
…report better (#2464)

* docs: cut CLAUDE.md blocks that transcribe what the tools already report

Second pass on the accuracy audit. The first fixed false claims; this removes the
blocks that generate them — 1008 lines to 690, and 456 fenced lines to 128.

The test applied to each block: does a reader learn something here they could not
get faster by running the thing? Five failed it, and each was checked against the
docs site and the code before being cut.

  CLI command dump (136 lines) -> the three rules --help omits
      `archon --help` and reference/cli.md (640 lines) are both more current than
      this block already was: it never mentioned chat, setup, workflow
      search/install, continue, or doctor --full. What survives is what neither
      tells you — the git-repo requirement, isolation-by-default, and the --json
      approve/reject/resume semantics.

  packages/ tree (99 lines) -> package roots in dependency order
      Every dependency constraint it encoded is already stated, more precisely,
      in Package Split a hundred lines below. Order verified against each
      package.json: every package depends only on those above it. Inside a
      package, ls and the file docblocks outlive any tree drawn here.

  assistant defaults yaml (38) -> a paragraph
      reference/configuration.md carries the full key set. Kept the two keys
      worth knowing before you look: the binary paths, and settingSources.

  ~/.archon tree (20) -> a paragraph
      reference/archon-directories.md carries the layout. Kept the rule that
      matters: artifacts and logs live outside the repo and are never committed.
      The repo-level .archon/ tree stays — eight lines, referenced constantly.

  SDK type patterns, import patterns (40) -> rule lines
      Paired correct/wrong examples where the rule is one sentence.

Also corrects a claim the #2459 review surfaced: the sqlite.test.ts parity test
no longer compares table names only. It checks columns in both directions against
tracked allowlists, so a column added to one dialect and forgotten in the other
now fails CI.

Kept deliberately: the logging idiom and the error-handling patterns, which
encode exact shapes meant to be copied, and every behavioural section — Core
Principles, Engineering Principles and Product Direction are byte-identical.
Verified no section was lost: the heading list is unchanged.

* docs: cut the remaining CLAUDE.md blocks that restate their own prose

Second half of the shrink pass, under the inverted default: cut when in doubt,
and justify what stays rather than what goes.

  logging (24 lines) -> two sentences
      The "Event naming rules" bullets immediately below already state the
      convention the block illustrated. What survives is the call shape —
      structured object first, event name second — and the three fields an
      error log must carry.

  error handling (29) -> two sentences
      Both blocks were generic try/catch. The git one's actual content was
      classifyIsolationError, and the sentence under it already said so. Kept
      the rule that matters: log the raw error AND send the classified message,
      because doing one of the two is the bug the pattern exists to prevent.

  running in worktrees (22) -> one sentence
      A transcript of `bun dev` plus three curl calls. The port rule is the
      content and it is stated in prose right below; the API calls are in the
      API Endpoints section.

  dev / testing / type-check / validate command blocks (35) -> four lines
      Script-name lists that package.json reports. Kept the ports (3090, 5173)
      and the generate:types ordering constraint, neither of which a script name
      tells you.

Retained blocks and why each survives:

  packages/ root list (12) — dependency ORDER is the rule, and `ls` cannot tell
      you which package may depend on which. Verified against every package.json.
  repo-level .archon/ tree (6) — the canonical statement of what Archon reads
      from a repository, with no docs-site equivalent that is more current.

CLAUDE.md is now 574 lines from 1008, and 22 fenced lines from 456. Section
headings unchanged; Core Principles, Engineering Principles and Product
Direction remain byte-identical.
Wirasm added a commit that referenced this pull request Aug 12, 2026
…2523)

* feat(workflows): structural workflow signature — inputs/returns/with on workflow: nodes (#2470)

Adds a declarative signature surface to the workflow language (Signature Phase 2):
a workflow declares `inputs:` (what it takes) and `returns:` (which node's output is
its result); callers supply values via `with:` — now accepted on `workflow:` sub-run
nodes, not just `include:`. This coordinates, it does not compute (Workflow Language
Constitution — cited).

Changes:
- Schema: `inputs:`/`returns:` on workflowBaseSchema; `with:` on workflow nodes with
  include-style shape validation; reject `with`+`input`; `fan_out.as`↔`with` collision
  check; delete the dead `fan_out.as` placeholder rejection (#2224 merged); `inputEnvKey`
- Loader: parse/validate inputs (warn-and-drop, reject required+default), returns
  top-level-id existence check, env-key mangling collision reject, scan `workflow:` `with:`
  values for dangling refs, four-surface KEEP-IN-SYNC comment
- Include-expander: `returns:` drives primarySink only (sinks/depends_on unchanged, may be
  a non-sink); validate `with:` against declared `inputs:` (defaults/missing-required/
  undeclared-key) only when inputs declared — undeclared blocks byte-for-byte unchanged
- Runtime: strict `$INPUTS.<name>` substitution in the `!shellSafe` branch (did-you-mean
  hint, throws on unknown); `INPUTS_<UPPER_SNAKE>` env for bash/script sub-run nodes;
  persist `metadata.inputs` at spawn so `$INPUTS` reconstitutes on cold resume; fan-out
  static `with:` + per-item `$INPUTS.<as>` channel
- `returns:` rebinds a child run's terminal output (parent_run_id-gated; blank → '' + WARN,
  no sink fallthrough); loop_group per-iteration scan deliberately unchanged
- Bare-run guard: a required-input block still lists/loads but a top-level run fails before
  any worktree/clone/AI cost (CLI + orchestrator)
- Validator: bundled-set-only `workflow:` target check via the runtime fuzzy resolver
- Docs: authoring-guide Workflow Signature section + binding-time table + `INPUTS_*` env
  mangling; constitution admits inputs/returns and records cross-file schema checking rejected
- Tests: signature parse/validate, returns→primarySink, with vs inputs, $INPUTS runtime,
  bundled-target check, bare-run guard, env-key mangling; #2459 parity ratchet fixtures added

Closes #2470

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

* simplify: reduce complexity in changed files

Hoist resolveRunInputs(parentRun) out of the per-entry with: resolution
loops in executeWorkflowNode and executeFanOutWorkflowNode — it re-parsed
run metadata on every iteration. Computed once into a named local now.

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

* chore: remove out-of-scope hyperframes default workflow

Was accidentally included in this PR and caused check:bundled to fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4KmwzY3iYy4ZBUw7RkwGY

* fix(workflows): address review — with: expansion, runtime input contract, docs

Addresses the changes requested on #2523.

- include-expander: rewriteNodeOutputRefs() and applyInputsMacro() now walk every
  value in a workflow: node's with: map, so an included block that calls a child
  workflow can forward $INPUTS and reference its own nodes. Previously an included
  block's with: value referencing a child-local node failed with
  "references unknown node '$local.output'".
- Extract the declared-input contract into workflow-inputs.ts and enforce it on the
  runtime workflow: path as well: declared defaults applied, unsupplied required
  inputs and undeclared keys rejected, resolved after child discovery and before
  isolation/worktree or run-row creation. Both call surfaces now go through one
  implementation, so include: and workflow: accept identical maps by construction.
- Apply declared defaults to bare top-level runs, which have no parent to stamp
  metadata.inputs.
- CLAUDE.md: with: is no longer rejected on workflow: nodes and fan_out.as is no
  longer reserved; document the workflow-level inputs:/returns: signature.

Regression coverage: included block with a workflow: node whose with: values use
both $INPUTS and a child-local node ref; sub-run defaults, missing-required and
undeclared inputs; runtime $INPUTS delivery into AI surfaces; returns: rebinding
on a sub-run; cold-resume metadata reconstitution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4KmwzY3iYy4ZBUw7RkwGY

---------

Co-authored-by: Archon <archon@archon.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Archon Maintainer Bot <rasmus.widing@gmail.com>
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.

parseWorkflow assembles field-by-field, so a workflow-level field in the schema but absent from the literal is silently discarded

1 participant