Skip to content

bcli batch run: --set key=2026-08-23 is YAML-coerced to a Python date and crashes JSON encoding before send — every step fails, exit 1 #39

Description

@igor-ctrl

Summary

bcli batch run --set key=<ISO-date-string> silently runs the value through PyYAML's implicit type resolver instead of keeping it a string. 2026-08-23 becomes a Python datetime.date object, which then flows unconverted into the JSON body of the step's HTTP call. httpx's JSON encoder has no date handler and raises TypeError while building the request.

Because that TypeError lands in the same catch-all except Exception block that handles every other kind of step failure, the ledger and the run summary record the step as "failed" / not-succeeded with no distinction from "never sent." In the live Slice-0 write-contract spike (turbine repo, docs/slice0/REPORT-04-write-contracts.md §5b item 2), a two-step batch built exactly this way reported "0/2 steps succeeded" (process exit 1) — and bcli's own SQLite batch ledger correctly recorded both steps status=failed, with error_message: "Object of type date is not JSON serializable", for every one of the three --set-based runs that hit this crash (~/.config/bcli/batch/d3534f6e2e47456fa2f88e9e9462b373.db, 230b422529914437a9c0a0c588cfcef5.db, 4fb0c1d4162c4c2a96022a4a9f454264.db — runs at 00:09:30Z/00:09:54Z/00:09:54Z on 2026-08-24; intent_tsoutcome_ts deltas of ~27ms, consistent with a pre-send crash and no HTTP round-trip). Nothing was committed by any of these three runs. A same-lineNo record (GENERAL/TSPIKE4/920) that was initially attributed to this crash was traced, via bcli's own per-run ledgers, to a separate, successful run (0d48d030f36b46e0945e17d31ac81fd8 — the equivalent --params-file invocation, run without --yes, exit 0, ledger run.state=completed, step 1 status=committed) — not this bug. This is still a live instance of the "ambiguous outcome" hazard bcli's own batch ledger (src/bcli/batch/ledger.py) exists to prevent, in the narrower sense described in Root cause item 4: a pre-send encoding crash and a genuinely ambiguous post-send failure both collapse to the same "failed" ledger status.

Documented workaround: pass dates through --params <file> with the value quoted in the YAML (posting_date: "2026-08-23"), not through --set.

Steps to reproduce

A — the exact coercion + crash mechanism, reproducible offline (no BC needed):

python3 -c "
import yaml, json
v = yaml.safe_load('2026-08-23')
print(type(v), v)                 # <class 'datetime.date'> 2026-08-23
json.dumps({'postingDate': v})    # raises TypeError
"

This is exactly what bcli_cli/commands/batch_cmd.py::_smart_parse_value does to every --set key=value argument, and exactly what bcli/client/_transport.py's outgoing request encoding then does to the resulting workflow parameter — see Root cause below for the exact call chain.

B — the live, end-to-end instance (BC sandbox, SBEnvAug26/LLC), as run and recorded in the turbine repo's Slice-0 spike:

bcli --profile admin-sandbox --env SBEnvAug26 --company LLC --format json \
  batch run duplicate-design-3.batch.yaml \
  --result-out batch-<n>.json \
  --set journal_template_name=GENERAL --set journal_batch_name=TSPIKE4 \
  --set account_no=1110-000000-010 --set amount=100 \
  --set posting_date=2026-08-23 \
  --set external_document_no=TURBINE-SPIKE4-003 \
  --set idem_key=<uuid>

where duplicate-design-3.batch.yaml has two post steps against genJournalLines, each with postingDate: "${{ params.posting_date }}" in its data: block (full source below, "Root cause"). Observed: process exit 1, "0/2 steps succeeded" printed to stdout, and bcli's own ledger (~/.config/bcli/batch/<run-id>.db) correctly recording both steps status=failed with the TypeError message above — nothing was sent, nothing was committed, by this run. (A record at the same lineNo, GENERAL/TSPIKE4/920, does show up on a later read-back, but per bcli's own ledgers it was created by a separate, successful run — see Summary above, not this one.) Evidence trail (in the turbine repo, not bcli):

  • docs/slice0/REPORT-04-write-contracts.md:86 (§5b item 2, the narrative finding)
  • docs/slice0/evidence/04-write-contracts/ledger.md:22 (the reconciliation row for the orphan record, with the discovery narrative)
  • docs/slice0/evidence/04-write-contracts/session-commands.log:183-192 (the exact failing invocations, all exitCode: 1, and the later --params-based workaround runs)
  • spikes/04-bc-write-contracts/src/scenarios/batch/duplicate-design-3.batch.yaml and duplicate-design-3-params.generated.yaml (fixture + the quoted-string workaround params file)

Expected

--set posting_date=2026-08-23 should behave the same as putting posting_date: "2026-08-23" in a --params YAML file: the value stays a string, gets sent to BC as "2026-08-23", and the step either succeeds or fails on its own merits. And a request that fails to serialize before any bytes reach BC should be distinguishable, in the ledger's own status column, from a request whose outcome is genuinely unknown after a network-level failure — today both collapse to the same "failed" status (see Root cause item 3 and Suggested fix 3).

Root cause

1. --set applies YAML implicit typing to every value, with no override for date-shaped strings.
_smart_parse_value (src/bcli_cli/commands/batch_cmd.py:44-55) runs the raw CLI string through yaml.safe_load(raw) to get --set vendor_no=V00011str, --set amount=100int, etc. (per its own docstring). PyYAML's default (YAML 1.1) resolver also recognizes YYYY-MM-DD as a timestamp and returns a datetime.date — confirmed directly: yaml.safe_load("2026-08-23")datetime.date(2026, 8, 23). Nothing in _smart_parse_value or its caller _parse_set_params (batch_cmd.py:58-69) special-cases date/datetime results the way it already special-cases a yaml.YAMLError (falls back to the raw string at line 54-55). _build_workflow_params (batch_cmd.py:97-134) then merges this dict into the workflow's params, with --set given the highest priority (line 122), so this silently overrides even a correctly-typed default or params-file value for the same key.

2. The workflow resolver is deliberately "type-preserving" for a bare ${{ }} reference, so the raw date object reaches the request body unconverted.
resolve_references (src/bcli/workflow/_resolver.py:22-48) treats a step field whose entire string is one ${{ params.<key> }}/${{ steps.<name>.<field> }} reference (matched by FULL_REFERENCE_PATTERN, line 17-19) as "type-preserving": it returns the raw Python value from context.params unchanged (line 38-40, via _resolve_param, line 64-70) rather than stringifying it — stringification only happens for embedded references (line 43-46). In the reproducing batch YAML, postingDate: "${{ params.posting_date }}" is exactly such a bare reference, so the resolved step's data["postingDate"] is the raw datetime.date, not "2026-08-23".

(This is also why the documented workaround — putting the date in a --params <file> YAML with the value quoted — actually works: _load_params_file (batch_cmd.py:72-83) parses the whole params file as one YAML document, where an explicitly quoted scalar like "2026-08-23" is honored as a string by YAML's own grammar. A --set value, by contrast, arrives from the shell with any quoting already stripped, so _smart_parse_value re-parses it as a bare, unquoted scalar and YAML's implicit resolver — not the author's intent — decides the type.)

3. That raw date object then fails at JSON-encoding time, inside the CLI's own HTTP call, not at parse time.
The mutating step calls client.post(endpoint, data or {}, ...) (batch_cmd.py:746-749) → AsyncBCClient.post (src/bcli/client/_async.py:239-260) → BCTransport.post/_request (src/bcli/client/_transport.py:519-528, 127-217), which sends the body via self._client.request(method, url, params=params, json=json_body, headers=headers) (_transport.py:199-205). httpx's own JSON encoder (httpx._content.encode_json) calls the stdlib json.dumps(json, ensure_ascii=False, separators=(",", ":"), allow_nan=False) with no default= handler, which raises TypeError: Object of type date is not JSON serializable for any dict containing the raw date. Confirmed directly against the pinned httpx==0.28.1 (also the version installed in this environment) via httpx.MockTransport: the mock handler is never invoked and the TypeError surfaces before any request reaches the transport. Contrast this with the ledger's own _body_hash helper (batch_cmd.py:164-178), which already anticipates non-JSON-native values in a step body and uses json.dumps(body, sort_keys=True, default=str) — the outgoing HTTP request body itself is the one place in this path that isn't defended the same way.

4. The resulting exception is caught by the same generic handler as every other step failure, so the ledger and the run summary can't tell "never sent" apart from "sent, but something after that blew up."
Every action branch in _execute_batch's per-step loop (batch_cmd.py:707-893) is one try, ending in except Exception as e: (batch_cmd.py:877-893), which unconditionally appends {"status": "error", ...} to results and calls ledger.write_outcome(step_id=..., status="failed", ...) (batch_cmd.py:885-891) — there is no branch that distinguishes a client-side encoding failure (which, taken alone for a single request, fails before any socket write) from a genuinely ambiguous network-level failure. The run-level rollup in run_batch (batch_cmd.py:427-436) sums results by status == "ok" to build the printed "N/M steps succeeded" line and to decide final_state — a step recorded as "error" is never counted as succeeded, and its ledger row (src/bcli/batch/ledger.py::write_outcome, ledger.py:388-419) is stamped "failed", not "committed". Per Ledger's own module docstring (ledger.py:1-40) and compute_run_state (ledger.py:450-498), status="failed" is exactly the value that keeps a run from ever being classified "partially_committed" on that step — and it is the same status a genuinely ambiguous post-send failure would also receive, so the ledger's status column alone cannot distinguish "definitely never sent" (this bug) from "sent, outcome unknown" (a real network-level failure) without an independent read-back.

Suggested fix

  1. Stop --set from producing non-string/non-JSON-native types for date-shaped input. In _smart_parse_value (batch_cmd.py:44-55), after yaml.safe_load(raw), check isinstance(parsed, (datetime.date, datetime.datetime, datetime.time)) and fall back to returning raw unchanged (mirroring the existing except yaml.YAMLError: return raw behavior) — the function's own docstring only documents int/float/bool/str as intended outcomes; a date object was never a design goal, it's PyYAML's timestamp resolver firing as a side effect. This alone makes --set posting_date=2026-08-23 behave like the quoted-string workaround.

  2. Make the outgoing request encoder defensive, independent of where a bad type comes from. In BCTransport._request (_transport.py:199-205), send the body as content=json.dumps(json_body, default=str).encode("utf-8") with an explicit application/json content-type, instead of relying on httpx's own strict json= kwarg. This is the single choke point for every mutating call bcli makes; hardening it here catches this whole class of "some Python object that isn't a JSON scalar slipped into a request body" bug regardless of which upstream code path produced it (workflow params, a future step-chaining bug, a user-authored plugin, etc.), the same way _body_hash (batch_cmd.py:175) already does for hashing.

  3. Don't let a request-encoding failure share a ledger status with a genuinely ambiguous one. A TypeError raised while building/serializing the request (i.e., before any bytes reach the wire) is categorically different from a network error or a BC-side rejection — the former means "definitely not sent," the latter means "unknown, needs a read-back." Today both are folded into the same except Exceptionstatus="failed" outcome (batch_cmd.py:877-893, ledger.py:388-419). At minimum, catch serialization/encoding failures separately (they're always raised before the await self._client.request(...)/await self._transport.post(...) calls actually touch the network) and record a distinct ledger status for them, so the ledger's status column keeps meaning what its own docstring says it means.

Fix 1 is the minimal patch that removes the crash entirely; fix 2 is cheap defense-in-depth at the one real choke point; fix 3 is the structural fix for the underlying "ambiguous outcome" class this bug happens to be one instance of (and which DESIGN.md §8 in the turbine repo already treats as a first-class risk).

Notes

  • This report packages up docs/slice0/REPORT-04-write-contracts.md §5b item 2 (live, unplanned finding from the turbine repo's Slice-0 write-contract spike, 2026-08-23/24, BC sandbox SBEnvAug26/LLC) as a standalone bcli bug, per that report's "Second candidate bcli bug report" note.
  • I independently verified, in this environment, both (a) yaml.safe_load("2026-08-23")datetime.date, and (b) that httpx.AsyncClient.request(..., json=<dict containing that date>) raises TypeError via a MockTransport before the mock handler is ever invoked (httpx==0.28.1, matching the version installed alongside bcli in this environment). I have not re-run the actual failing bcli batch run command against a live BC sandbox in this session (no BC credentials here, and the underlying record is exactly the kind of write this report is about) — the mechanism above is derived by direct code reading plus the two isolated reproductions, and cross-checked against the live spike's own written record (ledger.md:22, session-commands.log:183-192) rather than re-executed end-to-end.
  • An apparent contradiction I flagged in an earlier draft of this report — a single POST whose body contains the bad date should fail before any network I/O, yet a record at the same lineNo shows up in the spike's cleanup ledger — is resolved by attribution, not by mechanism. Checked directly against bcli's own per-run SQLite ledgers (~/.config/bcli/batch/<run-id>.db): every --set-based run that hit this crash failed both steps pre-send, exactly as predicted, and committed nothing. The lineNo-920 record traces to a different, successful run (0d48d030f36b46e0945e17d31ac81fd8) that used the --params-file workaround invoked without --yes — a separate Turbine-harness issue (a no---yes "preflight" that still writes, because the admin-sandbox profile has no disable_writes guard), not a symptom of this bug.
  • Real record IDs quoted above (GENERAL/TSPIKE4/920, systemId 42349c44-509f-f111-8072-7c1e52bd3e92) are BC sandbox records (SBEnvAug26), already deleted and re-fetch-verified gone as part of the spike's cleanup (docs/slice0/REPORT-04-write-contracts.md §7).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions