Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,9 @@ jobs:
- name: Fetch the pinned 2.x reference CLI
run: parity/fetch-reference.sh
- name: Compare against the 2.x CLI
run: python3 parity/parity.py --verbose
run: |
python3 -m unittest discover -s parity -p 'test_*.py'
python3 parity/parity.py --verbose

target-branch-qualification:
name: Target branch qualification
Expand Down
27 changes: 27 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ dw watch workflow order-123 --run-id=01HXYZ --interval=5 --max-polls=60

# Send a signal
dw workflow:signal order-123 payment-received --input='{"amount":99.99}'
dw workflow:signal order-123 payment-received --input='{"amount":99.99}' --request-id=payment-order-123-2026-10-20
dw workflow:signal counter-1 increment --input='["not-an-int"]' --output=json
# {"error":"Server error: Signal argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_signal_arguments",...}

Expand All @@ -497,6 +498,7 @@ dw workflow:query counter-1 current-at --input='["not-an-int"]' --output=json

# Send an update
dw workflow:update order-123 approve --input='{"approver":"admin"}'
dw workflow:update order-123 approve --input='{"approver":"admin"}' --wait=completed --request-id=approve-order-123-2026-10-20

# Send an integration event through a bounded bridge adapter
dw bridge:webhook stripe --action=start_workflow --idempotency-key=stripe-event-1001 --target='{"workflow_type":"orders.fulfillment","task_queue":"external-workflows","business_key":"order-1001"}' --input='{"order_id":"order-1001"}'
Expand All @@ -514,6 +516,31 @@ dw workflow:history order-123 01HXYZ
dw workflow:history order-123 01HXYZ --follow
```

`workflow:signal` and `workflow:update` send a caller command key as the
`request_id` body field. The CLI generates `cli-signal-<hex>` or
`cli-update-<hex>` once per invocation only when `--request-id` is omitted.
Explicit empty or whitespace-only keys are refused before POST. Keys contain
1-255 Unicode code points (up to 1020 UTF-8 bytes), without normalization,
truncation or case conversion. The key is shown safely in human output and
preserved exactly in JSON/JSONL, including after a timeout or network failure.

After a lost response the admission outcome is unknown. Inspect workflow
history and command receipts before retrying; a key alone does not establish
retry safety. Rely on the complete retained-admission and canonical-intent
guarantee only when the Server advertises that published capability. Ordinary
request-key storage and partial update receipt lookup do not supply the full
signal/update guarantee. No currently qualified published Server tuple
advertises that complete capability.

When the Server reports receipt metadata, `retained: true` identifies an
original receipt and `intent_verified` reports whether its intent was
verified. Preserve its original status and exact target. A qualified
retained-admission contract may refuse a different typed intent with HTTP 409
and `reason: request_id_conflict`; the response identifies the retained
command and conflict parts. Legacy receipts with unverified intent never
authorize a new command. Deduplicated admission does not make external effects
exactly once.

Batch cancellation with `--all-matching` cancels the exact run each listed
workflow had when it was selected. If that run has continued as new, been
replaced or closed before the request reaches Server, the workflow is reported
Expand Down
4 changes: 4 additions & 0 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ CLI and once with this build, each in a fresh config directory. It compares:
- stderr as normalized text (the same normalizations), plus its category;
- the config file each case leaves behind.

The manifest case records exact reference and candidate hashes for reviewed
additive schema changes. Only those two values at the named command's `sha256`
field are normalized. Other hashes, manifest metadata and formatting still compare.

```bash
parity/fetch-reference.sh # pinned 2.x CLI, checksum-verified, into target/
cargo build
Expand Down
12 changes: 11 additions & 1 deletion rust/parity/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -1798,7 +1798,17 @@
"name": "schema:manifest",
"argv": [
"schema:manifest"
]
],
"schema_sha256_updates": {
"workflow:signal": {
"reference": "sha256:c42c02f7a54faa082982028482bc231ca29c495a2a00fabce5b0ae580a2505bf",
"candidate": "sha256:5a5fe23d6a1bead8508887b57b1dfb58448b92ed4292ce0da88bcdd1abf37e81"
},
"workflow:update": {
"reference": "sha256:a3997a4b623d84b166ea3a124b0a33e5c400acc51694332186813b96aa389eb1",
"candidate": "sha256:8888b18ff8d8ef7cac35f2aae362192b04617dc28760c25ddccf085880c3e25c"
}
}
},
{
"name": "schema:show a command's JSON schema",
Expand Down
32 changes: 31 additions & 1 deletion rust/parity/parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,41 @@ def prepare_project(config_dir, options, env):
return project


def normalize_schema_manifest(text, case, side):
"""Allow only reviewed, directional schema hash updates; retain all other bytes."""
if case.get("argv") != ["schema:manifest"] or not isinstance(text, str):
return text
try:
manifest = json.loads(text)
except ValueError:
return text
if (not isinstance(manifest, dict)
or manifest.get("schema") != "durable-workflow.cli.output-schema-manifest"
or manifest.get("version") != 3
or not isinstance(manifest.get("commands"), dict)):
return text
for command, update in case.get("schema_sha256_updates", {}).items():
digest = update[side]
entry = manifest["commands"].get(command)
if (isinstance(entry, dict) and entry.get("sha256") == digest
and text.count(digest) == 1):
text = text.replace(digest, f"<reviewed-schema-sha256:{command}>")
return text


def compare(reference, candidate, case):
fields = ["exit_code", "stderr_category", "stderr", "stdout_json", "config"]
if case.get("compare_text", True):
fields.append("stdout_text")
return [field for field in fields if reference[field] != candidate[field]]
differences = []
for field in fields:
left, right = reference[field], candidate[field]
if field == "stdout_text" and case.get("schema_sha256_updates"):
left = normalize_schema_manifest(left, case, "reference")
right = normalize_schema_manifest(right, case, "candidate")
if left != right:
differences.append(field)
return differences


def patched_cluster_info(patches):
Expand Down
85 changes: 85 additions & 0 deletions rust/parity/test_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import copy
import hashlib
import json
from pathlib import Path
import unittest

import parity


class SchemaManifestParityTest(unittest.TestCase):
def setUp(self):
with open(parity.CASES, encoding="utf-8") as handle:
self.case = next(case for case in json.load(handle)["cases"]
if case["name"] == "schema:manifest")
self.root = Path(parity.CRATE).parent
self.candidate = (self.root / "schemas/output/manifest.json").read_text(encoding="utf-8")
self.reference = self.candidate
for update in self.case["schema_sha256_updates"].values():
self.reference = self.reference.replace(update["candidate"], update["reference"])

def differences(self, reference=None, candidate=None, case=None):
result = {key: None for key in ("exit_code", "stderr_category", "stderr", "stdout_json", "config")}
return parity.compare(
dict(result, stdout_text=self.reference if reference is None else reference),
dict(result, stdout_text=self.candidate if candidate is None else candidate),
self.case if case is None else case,
)

def test_exact_reviewed_schema_hash_updates_match(self):
self.assertEqual([], self.differences())
manifest = json.loads(self.candidate)
for command, update in self.case["schema_sha256_updates"].items():
entry = manifest["commands"][command]
digest = hashlib.sha256((self.root / entry["schema"]).read_bytes()).hexdigest()
self.assertEqual(update["candidate"], "sha256:" + digest)

def test_unreviewed_hash_or_old_candidate_hash_is_a_difference(self):
for update in self.case["schema_sha256_updates"].values():
for replacement in ("sha256:" + "0" * 64, update["reference"]):
with self.subTest(replacement=replacement):
self.assertEqual(["stdout_text"], self.differences(
candidate=self.candidate.replace(update["candidate"], replacement)))
self.assertEqual(["stdout_text"], self.differences(
reference=self.reference.replace(update["reference"], update["candidate"])))

def test_other_manifest_bytes_remain_significant(self):
for old, new in (("patch-stable-additive", "changed"),
('"version": 3', '"version": 4'),
("workflow-signal.schema.json", "wrong.schema.json"),
('"output": "--json"', '"output": "--wrong"'),
(' "commands": {', ' "commands": {')):
with self.subTest(old=old):
self.assertIn(old, self.candidate)
self.assertEqual(["stdout_text"], self.differences(
candidate=self.candidate.replace(old, new, 1)))

def test_hash_in_wrong_field_or_repeated_elsewhere_does_not_match(self):
update = self.case["schema_sha256_updates"]["workflow:signal"]
for candidate in (
self.candidate.replace('"sha256": "' + update["candidate"],
'"wrong": "' + update["candidate"]),
self.candidate.replace('"stability": "patch-stable-additive"',
'"stability": "' + update["candidate"] + '"'),
):
self.assertEqual(["stdout_text"], self.differences(candidate=candidate))

def test_unrelated_hash_changes_still_fail(self):
digest = json.loads(self.candidate)["commands"]["workflow:list"]["sha256"]
self.assertEqual(["stdout_text"], self.differences(
candidate=self.candidate.replace(digest, "sha256:" + "0" * 64)))

def test_other_cases_cannot_enable_manifest_hash_normalization(self):
case = copy.deepcopy(self.case)
case["argv"] = ["schema:show", "workflow:signal"]
self.assertEqual(["stdout_text"], self.differences(case=case))
del case["schema_sha256_updates"]
self.assertEqual(["stdout_text"], self.differences(case=case))

def test_invalid_manifest_is_a_difference(self):
for candidate in (self.candidate + "invalid", "[]", "null"):
self.assertEqual(["stdout_text"], self.differences(candidate=candidate))


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions schemas/output/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@
"schema": "schemas/output/workflow-signal.schema.json",
"schema_id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-signal.schema.json",
"resolver_url": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-signal.schema.json",
"sha256": "sha256:c42c02f7a54faa082982028482bc231ca29c495a2a00fabce5b0ae580a2505bf"
"sha256": "sha256:5a5fe23d6a1bead8508887b57b1dfb58448b92ed4292ce0da88bcdd1abf37e81"
},
"workflow:start": {
"output": "--json",
Expand All @@ -571,7 +571,7 @@
"schema": "schemas/output/workflow-update.schema.json",
"schema_id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-update.schema.json",
"resolver_url": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-update.schema.json",
"sha256": "sha256:a3997a4b623d84b166ea3a124b0a33e5c400acc51694332186813b96aa389eb1"
"sha256": "sha256:8888b18ff8d8ef7cac35f2aae362192b04617dc28760c25ddccf085880c3e25c"
}
},
"jsonl_commands": {
Expand Down
5 changes: 4 additions & 1 deletion schemas/output/workflow-signal.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"signal_name": { "type": "string" },
"outcome": { "type": "string" },
"command_status": { "type": ["string", "null"] },
"command_id": { "type": ["string", "null"] }
"command_id": { "type": ["string", "null"] },
"request_id": { "type": "string" },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] }
},
"additionalProperties": true
}
4 changes: 4 additions & 0 deletions schemas/output/workflow-update.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"update_name": { "type": "string" },
"update_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] },
"outcome": { "type": "string" },
"state": { "type": ["string", "null"] },
"update_state": { "type": ["string", "null"] },
Expand Down Expand Up @@ -119,6 +121,8 @@
"update_name": { "type": ["string", "null"] },
"update_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] },
"outcome": { "type": ["string", "null"] },
"reason": { "type": ["string", "null"] },
"rejection_reason": { "type": ["string", "null"] },
Expand Down
Loading
Loading