diff --git a/CHANGELOG.md b/CHANGELOG.md index ff49ad3..61a94a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ here. duplicate targets and no selection; profile negotiation, Dialect outputs and delivery receipts remain separate work. +- RFC 0020's accepted issue #228 amendment: workflow output envelopes name their contributing + rule outside Core's unchanged disposition; every stage records a closed disposition summary + and a reproducible seeded sample; the workflow verifier recomputes both and refuses disagreement; + and drift is a comparison over two verified bundles of the same workflow, without a verdict. + The closed workflow schema and hand-written two-stage contract fixture carry the new records, + digest recomputation, drift rows and refusal vectors. No workflow implementation, recorded + executor run or conformance claim is added. + - Official-draft Policy promotion preparation: versioned closed format `1.0.0` schemas, retained-byte examples and 124 staged agreement vectors, explicit migration/provenance and a scoped independent draft release route through the diff --git a/conformance/README.md b/conformance/README.md index 2ece9d9..b6fbfb8 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -20,6 +20,12 @@ the default branch remains Review until then. These vectors become a released corpus only under their exact Policy tag and a maintainer-published GitHub release. Passing fixtures alone establishes no Core or Policy conformance claim. +The hand-written +[`conformance/workflow`](https://github.com/Judgment-Pack/judgment-pack-spec/tree/main/conformance/workflow) +contract fixture exercises RFC 0020's workflow document, output, summary, sample, +verifier-refusal and drift forms. It is schema and byte-rule test data, not a recorded executor +run or a workflow conformance corpus. + [`manifest.json`](manifest.json) is the machine-readable case index. Its shape is defined by [`manifest.schema.json`](manifest.schema.json). diff --git a/conformance/workflow/README.md b/conformance/workflow/README.md new file mode 100644 index 0000000..d34e7cb --- /dev/null +++ b/conformance/workflow/README.md @@ -0,0 +1,15 @@ +# Workflow contract fixture + +These hand-written records exercise the version-1 workflow schema and RFC 0020 companion forms. +`left-bundle.json` and `right-bundle.json` each carry both stages' output envelopes, retained Core +dispositions and recorded summaries. `sample.json` is redrawn from its seed; `drift.json` is +recomputed from the two bundles. The `reports/` files are the closed verifier events expected for +the corresponding refusal vectors. + +Each summary's `recordsDigest` was fixed independently as SHA-256 of the canonical JSON array of +its stage's dispositions in recorded order. The repository test reconstructs those exact bytes, +recomputes every summary counter and digest, redraws the sample, and reconstructs the complete +drift result. The vectors are deliberately authored inputs, not output captured from an executor. + +This fixture establishes no implementation, execution, authentication, verdict or conformance +claim. The full research recording described by companion §11 remains future work. diff --git a/conformance/workflow/drift.json b/conformance/workflow/drift.json new file mode 100644 index 0000000..a2ad09c --- /dev/null +++ b/conformance/workflow/drift.json @@ -0,0 +1,53 @@ +{ + "result": "drift", + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "leftExecutionDigest": "sha256:2121212121212121212121212121212121212121212121212121212121212121", + "rightExecutionDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "stages": [ + { + "stage": "organisations", + "records": 0, + "byKind": { "outcome": 0, "not-applicable": 0, "unresolved": 0 }, + "byOutcome": { "low": 1, "review": -1 }, + "byReason": {}, + "byRule": { + "low": { "contributors": {}, "fallback": 1 }, + "review": { "contributors": { "r-review": -1 }, "fallback": 0 } + } + }, + { + "stage": "people", + "records": 0, + "byKind": { "outcome": 0, "not-applicable": 1, "unresolved": -1 }, + "byOutcome": {}, + "byReason": { "unknown": -1 }, + "byRule": {} + } + ], + "records": [ + { + "change": "changed", + "stage": "organisations", + "payloadDigest": "sha256:1010101010101010101010101010101010101010101010101010101010101010", + "occurrence": 1, + "left": { "kind": "outcome", "outcomeId": "review", "reasons": [], "handoff": { "state": "none" } }, + "right": { "kind": "outcome", "outcomeId": "low", "reasons": [], "handoff": { "state": "none" } } + }, + { + "change": "removed", + "stage": "people", + "payloadDigest": "sha256:5050505050505050505050505050505050505050505050505050505050505050", + "occurrence": 1, + "left": { "kind": "unresolved", "reasons": ["unknown"], "handoff": { "state": "requested", "triggeredBy": ["unknown"] } }, + "right": null + }, + { + "change": "added", + "stage": "people", + "payloadDigest": "sha256:6060606060606060606060606060606060606060606060606060606060606060", + "occurrence": 1, + "left": null, + "right": { "kind": "not-applicable", "reasons": ["not-applicable"], "handoff": { "state": "none" } } + } + ] +} diff --git a/conformance/workflow/finding.json b/conformance/workflow/finding.json new file mode 100644 index 0000000..b4df0f6 --- /dev/null +++ b/conformance/workflow/finding.json @@ -0,0 +1,4 @@ +{ + "result": "finding", + "finding": "preparation-mismatch" +} diff --git a/conformance/workflow/left-bundle.json b/conformance/workflow/left-bundle.json new file mode 100644 index 0000000..9961e25 --- /dev/null +++ b/conformance/workflow/left-bundle.json @@ -0,0 +1,87 @@ +{ + "fixtureVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "executionDigest": "sha256:2121212121212121212121212121212121212121212121212121212121212121", + "execution": "left-run", + "stages": [ + { + "stage": "organisations", + "records": [ + { + "recordDigest": "sha256:7171717171717171717171717171717171717171717171717171717171717171", + "output": { + "outputRecordVersion": 1, + "payloadDigest": "sha256:1010101010101010101010101010101010101010101010101010101010101010", + "contributingRule": "r-review", + "lineage": { + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", "stage": "organisations", "instance": "org-instance", + "publishedDialectDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bindingDocumentDigest": null, + "boundDialectDigest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "packDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "releaseDigest": "sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", + "preparationDigest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "auditDigest": "sha256:b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3", + "outputPointer": "/organisations", "index": 0, + "rootCaseDigest": "sha256:0101010101010101010101010101010101010101010101010101010101010101", + "caseDigest": "sha256:0202020202020202020202020202020202020202020202020202020202020202", + "parentRecord": null, "selectionDigest": null, + "relation": { "label": "discovered-for", "direction": "output-to-parent" }, + "influence": { "classes": ["generated"], "generatedInfluence": true } + } + }, + "disposition": { "kind": "outcome", "outcomeId": "review", "reasons": [], "handoff": { "state": "none" } } + } + ], + "summary": { + "summaryVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", "stage": "organisations", "records": 1, + "recordsDigest": "sha256:8f69a6171c67069c21de3cd559af7917c71f6940d9a4ebcc381f88bf949f2a23", + "byKind": { "outcome": 1, "not-applicable": 0, "unresolved": 0 }, + "byOutcome": { "review": 1 }, "byReason": {}, + "byRule": { "review": { "contributors": { "r-review": 1 }, "fallback": 0 } } + } + }, + { + "stage": "people", + "records": [ + { + "recordDigest": "sha256:7474747474747474747474747474747474747474747474747474747474747474", + "output": { + "outputRecordVersion": 1, + "payloadDigest": "sha256:5050505050505050505050505050505050505050505050505050505050505050", + "lineage": { + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", "stage": "people", "instance": "people-two", + "publishedDialectDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "bindingDocumentDigest": null, + "boundDialectDigest": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "packDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "releaseDigest": "sha256:d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1", + "preparationDigest": "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "auditDigest": "sha256:d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5", + "outputPointer": "/people", "index": 0, + "rootCaseDigest": "sha256:0101010101010101010101010101010101010101010101010101010101010101", + "caseDigest": "sha256:0404040404040404040404040404040404040404040404040404040404040404", + "parentRecord": "sha256:7171717171717171717171717171717171717171717171717171717171717171", + "selectionDigest": "sha256:1313131313131313131313131313131313131313131313131313131313131313", + "relation": { "label": "person-at-organisation", "direction": "output-to-parent" }, + "influence": { "classes": ["generated"], "generatedInfluence": true } + } + }, + "disposition": { "kind": "unresolved", "reasons": ["unknown"], "handoff": { "state": "requested", "triggeredBy": ["unknown"] } } + } + ], + "summary": { + "summaryVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", "stage": "people", "records": 1, + "recordsDigest": "sha256:444f9f1322ba0d9696fb799fb7e39261e8855a55d9ebed6b547c464963297d60", + "byKind": { "outcome": 0, "not-applicable": 0, "unresolved": 1 }, + "byOutcome": {}, "byReason": { "unknown": 1 }, "byRule": {} + } + } + ] +} diff --git a/conformance/workflow/manifest.json b/conformance/workflow/manifest.json new file mode 100644 index 0000000..2119f6a --- /dev/null +++ b/conformance/workflow/manifest.json @@ -0,0 +1,22 @@ +{ + "manifestVersion": 1, + "schema": "../../schema/workflow-1.schema.json", + "files": [ + { "path": "workflow.json", "schemaValid": true }, + { "path": "left-bundle.json", "schemaValid": true }, + { "path": "right-bundle.json", "schemaValid": true }, + { "path": "sample.json", "schemaValid": true }, + { "path": "drift.json", "schemaValid": true }, + { "path": "reading.json", "schemaValid": true }, + { "path": "finding.json", "schemaValid": true }, + { "path": "reports/sample-size-over-bound.json", "schemaValid": true }, + { "path": "reports/sample-seed-invalid.json", "schemaValid": true }, + { "path": "reports/summary-mismatch.json", "schemaValid": true }, + { "path": "vectors/sample-size-over-bound.json", "schemaValid": true }, + { "path": "vectors/sample-seed-invalid.json", "schemaValid": false }, + { "path": "vectors/sample-seed-empty.json", "schemaValid": false }, + { "path": "vectors/sample-seed-missing.json", "schemaValid": false }, + { "path": "vectors/sample-seed-oversized.json", "schemaValid": false }, + { "path": "vectors/summary-mismatch.json", "schemaValid": true } + ] +} diff --git a/conformance/workflow/reading.json b/conformance/workflow/reading.json new file mode 100644 index 0000000..0e1b19c --- /dev/null +++ b/conformance/workflow/reading.json @@ -0,0 +1,18 @@ +{ + "result": "reading", + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "executionDigest": "sha256:2121212121212121212121212121212121212121212121212121212121212121", + "consistency": "agrees", + "completion": "complete", + "preparations": "agree", + "authentication": "not-established", + "dispositions": "not-replayed", + "counts": { + "stages": 2, + "instances": 2, + "outputs": 2, + "selections": 1, + "summaries": 2, + "samples": 1 + } +} diff --git a/conformance/workflow/reports/sample-seed-invalid.json b/conformance/workflow/reports/sample-seed-invalid.json new file mode 100644 index 0000000..1fe165a --- /dev/null +++ b/conformance/workflow/reports/sample-seed-invalid.json @@ -0,0 +1,4 @@ +{ + "result": "refusal", + "refusal": "input-malformed" +} diff --git a/conformance/workflow/reports/sample-size-over-bound.json b/conformance/workflow/reports/sample-size-over-bound.json new file mode 100644 index 0000000..57c6290 --- /dev/null +++ b/conformance/workflow/reports/sample-size-over-bound.json @@ -0,0 +1,4 @@ +{ + "result": "refusal", + "refusal": "input-over-bound" +} diff --git a/conformance/workflow/reports/summary-mismatch.json b/conformance/workflow/reports/summary-mismatch.json new file mode 100644 index 0000000..b953fd3 --- /dev/null +++ b/conformance/workflow/reports/summary-mismatch.json @@ -0,0 +1,4 @@ +{ + "result": "refusal", + "refusal": "summary-mismatch" +} diff --git a/conformance/workflow/right-bundle.json b/conformance/workflow/right-bundle.json new file mode 100644 index 0000000..4286838 --- /dev/null +++ b/conformance/workflow/right-bundle.json @@ -0,0 +1,86 @@ +{ + "fixtureVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "executionDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "execution": "right-run", + "stages": [ + { + "stage": "organisations", + "records": [ + { + "recordDigest": "sha256:8181818181818181818181818181818181818181818181818181818181818181", + "output": { + "outputRecordVersion": 1, + "payloadDigest": "sha256:1010101010101010101010101010101010101010101010101010101010101010", + "lineage": { + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "right-run", "stage": "organisations", "instance": "org-instance", + "publishedDialectDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bindingDocumentDigest": null, + "boundDialectDigest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "packDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "releaseDigest": "sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", + "preparationDigest": "sha256:b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4", + "auditDigest": "sha256:b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5", + "outputPointer": "/organisations", "index": 0, + "rootCaseDigest": "sha256:0101010101010101010101010101010101010101010101010101010101010101", + "caseDigest": "sha256:0202020202020202020202020202020202020202020202020202020202020202", + "parentRecord": null, "selectionDigest": null, + "relation": { "label": "discovered-for", "direction": "output-to-parent" }, + "influence": { "classes": ["generated"], "generatedInfluence": true } + } + }, + "disposition": { "kind": "outcome", "outcomeId": "low", "reasons": [], "handoff": { "state": "none" } } + } + ], + "summary": { + "summaryVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "right-run", "stage": "organisations", "records": 1, + "recordsDigest": "sha256:d917664b0b7081729703649c7a9d96b2cf3e220371d577b1b6ce604d2900b9d9", + "byKind": { "outcome": 1, "not-applicable": 0, "unresolved": 0 }, + "byOutcome": { "low": 1 }, "byReason": {}, + "byRule": { "low": { "contributors": {}, "fallback": 1 } } + } + }, + { + "stage": "people", + "records": [ + { + "recordDigest": "sha256:8484848484848484848484848484848484848484848484848484848484848484", + "output": { + "outputRecordVersion": 1, + "payloadDigest": "sha256:6060606060606060606060606060606060606060606060606060606060606060", + "lineage": { + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "right-run", "stage": "people", "instance": "people-three", + "publishedDialectDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "bindingDocumentDigest": null, + "boundDialectDigest": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "packDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "releaseDigest": "sha256:d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1", + "preparationDigest": "sha256:d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8", + "auditDigest": "sha256:d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9d9", + "outputPointer": "/people", "index": 0, + "rootCaseDigest": "sha256:0101010101010101010101010101010101010101010101010101010101010101", + "caseDigest": "sha256:0606060606060606060606060606060606060606060606060606060606060606", + "parentRecord": "sha256:8181818181818181818181818181818181818181818181818181818181818181", + "selectionDigest": "sha256:1414141414141414141414141414141414141414141414141414141414141414", + "relation": { "label": "person-at-organisation", "direction": "output-to-parent" }, + "influence": { "classes": ["generated"], "generatedInfluence": true } + } + }, + "disposition": { "kind": "not-applicable", "reasons": ["not-applicable"], "handoff": { "state": "none" } } + } + ], + "summary": { + "summaryVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "right-run", "stage": "people", "records": 1, + "recordsDigest": "sha256:f0bd46c6e2634becb94e5fc6f1707a04eaf3d970e65552adf1e9801932aae105", + "byKind": { "outcome": 0, "not-applicable": 1, "unresolved": 0 }, + "byOutcome": {}, "byReason": {}, "byRule": {} + } + } + ] +} diff --git a/conformance/workflow/sample.json b/conformance/workflow/sample.json new file mode 100644 index 0000000..6ddb7e9 --- /dev/null +++ b/conformance/workflow/sample.json @@ -0,0 +1,15 @@ +{ + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "size": 1, + "algorithm": "sha256-rank-v1" +} diff --git a/conformance/workflow/vectors/sample-seed-empty.json b/conformance/workflow/vectors/sample-seed-empty.json new file mode 100644 index 0000000..9792027 --- /dev/null +++ b/conformance/workflow/vectors/sample-seed-empty.json @@ -0,0 +1,21 @@ +{ + "vectorVersion": 1, + "kind": "sample", + "expectedRefusal": "input-malformed", + "stageMaxSize": 1, + "selection": { + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "seed": "", + "size": 1, + "algorithm": "sha256-rank-v1" + } +} diff --git a/conformance/workflow/vectors/sample-seed-invalid.json b/conformance/workflow/vectors/sample-seed-invalid.json new file mode 100644 index 0000000..07b19a2 --- /dev/null +++ b/conformance/workflow/vectors/sample-seed-invalid.json @@ -0,0 +1,21 @@ +{ + "vectorVersion": 1, + "kind": "sample", + "expectedRefusal": "input-malformed", + "stageMaxSize": 1, + "selection": { + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "seed": "not-a-64-character-lowercase-hex-seed", + "size": 1, + "algorithm": "sha256-rank-v1" + } +} diff --git a/conformance/workflow/vectors/sample-seed-missing.json b/conformance/workflow/vectors/sample-seed-missing.json new file mode 100644 index 0000000..8eb97d5 --- /dev/null +++ b/conformance/workflow/vectors/sample-seed-missing.json @@ -0,0 +1,20 @@ +{ + "vectorVersion": 1, + "kind": "sample", + "expectedRefusal": "input-malformed", + "stageMaxSize": 1, + "selection": { + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "size": 1, + "algorithm": "sha256-rank-v1" + } +} diff --git a/conformance/workflow/vectors/sample-seed-oversized.json b/conformance/workflow/vectors/sample-seed-oversized.json new file mode 100644 index 0000000..b4c2d76 --- /dev/null +++ b/conformance/workflow/vectors/sample-seed-oversized.json @@ -0,0 +1,21 @@ +{ + "vectorVersion": 1, + "kind": "sample", + "expectedRefusal": "input-malformed", + "stageMaxSize": 1, + "selection": { + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "seed": "00000000000000000000000000000000000000000000000000000000000000000", + "size": 1, + "algorithm": "sha256-rank-v1" + } +} diff --git a/conformance/workflow/vectors/sample-size-over-bound.json b/conformance/workflow/vectors/sample-size-over-bound.json new file mode 100644 index 0000000..ee07b8a --- /dev/null +++ b/conformance/workflow/vectors/sample-size-over-bound.json @@ -0,0 +1,21 @@ +{ + "vectorVersion": 1, + "kind": "sample", + "expectedRefusal": "input-over-bound", + "stageMaxSize": 1, + "selection": { + "selectionVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "upstreamStage": "organisations", + "outputSetDigest": "sha256:1212121212121212121212121212121212121212121212121212121212121212", + "selected": [ + "sha256:7171717171717171717171717171717171717171717171717171717171717171" + ], + "mode": "sample", + "revision": 1, + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "size": 2, + "algorithm": "sha256-rank-v1" + } +} diff --git a/conformance/workflow/vectors/summary-mismatch.json b/conformance/workflow/vectors/summary-mismatch.json new file mode 100644 index 0000000..f06b98a --- /dev/null +++ b/conformance/workflow/vectors/summary-mismatch.json @@ -0,0 +1,20 @@ +{ + "vectorVersion": 1, + "kind": "summary", + "expectedRefusal": "summary-mismatch", + "dispositions": [ + { "kind": "outcome", "outcomeId": "review", "reasons": [], "handoff": { "state": "none" } } + ], + "summary": { + "summaryVersion": 1, + "workflowDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "execution": "left-run", + "stage": "organisations", + "records": 1, + "recordsDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "byKind": { "outcome": 1, "not-applicable": 0, "unresolved": 0 }, + "byOutcome": { "review": 1 }, + "byReason": {}, + "byRule": { "review": { "contributors": { "r-review": 1 }, "fallback": 0 } } + } +} diff --git a/conformance/workflow/workflow.json b/conformance/workflow/workflow.json new file mode 100644 index 0000000..945d95b --- /dev/null +++ b/conformance/workflow/workflow.json @@ -0,0 +1,38 @@ +{ + "workflowVersion": 1, + "stages": [ + { + "id": "organisations", + "dialect": { "version": 3, "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "pack": { "id": "https://example.invalid/packs/organisations", "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + "input": { "root": true }, + "execution": "once", + "caseBindings": [], + "outputs": { "pointer": "/organisations", "cardinality": "many" }, + "relation": { "label": "discovered-for", "direction": "output-to-parent" }, + "selection": { "mode": "reviewer" }, + "sample": { "mode": "sample", "maxSize": 1 } + }, + { + "id": "people", + "dialect": { "version": 3, "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }, + "pack": { "id": "https://example.invalid/packs/people", "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + "input": { "fromStage": "organisations", "selected": true }, + "execution": "each-selected-record", + "caseBindings": [ + { "from": "/id", "to": "/organisationId", "type": "string", "required": true } + ], + "outputs": { "pointer": "/people", "cardinality": "many" }, + "relation": { "label": "person-at-organisation", "direction": "output-to-parent" }, + "sample": { "mode": "sample", "maxSize": 1 } + } + ], + "result": "people", + "limits": { + "maxStages": 2, + "maxRecords": 16, + "maxInstances": 8, + "maxAttempts": 2, + "maxBytes": 1048576 + } +} diff --git a/rfcs/0020-workflow.md b/rfcs/0020-workflow.md index 279cfa6..0f0c5ff 100644 --- a/rfcs/0020-workflow.md +++ b/rfcs/0020-workflow.md @@ -4,8 +4,9 @@ - Type: Standards track (a companion document of the specification, with a reader conformance class; fixtures recorded from a two-stage run) - Created: 2026-10-15 -- Amended: 2026-10-15 (a research stage, issue #174) -- Issue: [#160](https://github.com/Judgment-Pack/judgment-pack-spec/issues/160) +- Amended: 2026-10-22 (verifiable bulk records, issue #228) +- Issues: [#160](https://github.com/Judgment-Pack/judgment-pack-spec/issues/160), + [#228](https://github.com/Judgment-Pack/judgment-pack-spec/issues/228) This is an open proposal. Its companion text is present for review, normative for nothing. No implementation or workflow fixture exists. The Type names the evidence to obtain, not evidence @@ -52,13 +53,31 @@ workflow instances to migrate. Existing preparations and run exports are unchang Two independent readers must agree on integer/named variants and on missing, malformed, changed and misassociated error-envelope variants before any conformance claim. +## Amendment of 2026-10-22: verifiable bulk records + +The maintainer accepted issue #228 as proposed. Output envelopes now carry the rule or forcing +exception that contributed an outcome, outside Core's unchanged disposition. Each stage has a +closed summary of its disposition kinds, outcomes, unresolved reasons and contributors, plus an +exact digest over the ordered dispositions. A separate seeded sample selection uses a named +SHA-256 ranking algorithm and a stage bound so independent implementations draw the same records. + +The workflow verifier redraws samples and recomputes summaries from checked output records, +refusing disagreement. It can also compare two separately verified bundles of the same workflow, +reporting summary deltas and per-record additions, removals and disposition changes without a +verdict. Section 11 adds these records and their refusal and mutation cases to the fixture plan. +The RFC remains Draft: this amendment records the contract and planned vectors, not an executor, +recorded fixture or conformance claim. + ## Summary A **workflow** declares how one dialect runs for each record selected from another stage's outputs. Each stage pins its own published dialect and pack, names its input and output projection, and declares the relation between its outputs and their parent. Selection records bind the whole offered set and the chosen subset; output records preserve parentage; an execution -record closes the instance inventory, including zero-output and unsuccessful work. +record closes the instance inventory, including zero-output and unsuccessful work. Per-stage +summaries and reproducible samples make a bulk execution inspectable without changing Core's +per-case disposition, and the verifier derives drift from two bundles rather than trusting a +dashboard projection. This is option A of the design memo recorded on issue #160: a standalone companion document, `spec/workflow.md`, with a `workflow-verifier` reader class. The Runner executes, the Desk @@ -182,8 +201,9 @@ are refused. An id is `[a-z][a-z0-9-]{0,63}`. A digest is `sha256:` and 64 lower characters, compared exactly. A timestamp has the form and valid instant of the dialect §1. JSON is strict as in [the dialect](../spec/dialect.md), §1: one value, well-formed Unicode and -number literals retained. The workflow, selection, output, set and execution objects use its `canon` -form: object members ordered by code point, arrays preserved, no whitespace or inserted defaults, +number literals retained. The workflow, selection, output, set, summary and execution objects use +its `canon` form: object members ordered by code point, arrays preserved, no whitespace or +inserted defaults, all supplied members included, integers from −(2⁵³−1) to 2⁵³−1, and `-0` written `0`. Non-integer number literals have no form here. Their digests are SHA-256 of those canonical bytes. A digest member lives outside the object it names; an object never contains its own digest. @@ -242,16 +262,16 @@ It names that stage's ordered outputs, without aggregating dispositions or disca | `maxAttempts` | 16 | attempts per instance | | `maxBytes` | 1073741824 | supplied bundle bytes, each file counted once | -The bounds below and §1's integer domain apply to workflow, selection, output, set and execution -objects and to cases and payloads; packs, acquisitions, preparations, audits and run exports keep +The bounds below and §1's integer domain apply to workflow, selection, output, set, summary and +execution objects and to cases and payloads; packs, acquisitions, preparations, audits and run exports keep their own formats' bounds and number domains, within the branch's aggregate byte and file limits. Those workflow objects have at most 64 container levels; cases and payloads have at most 32. The dialect §12 counting convention applies. Every object has at most 4096 members, every array at most 4096 elements, every decoded string and member name at most 65536 UTF-8 bytes, and every number literal at most 128 characters. Smaller bounds stated here or by a referenced format win. The bundle has at most 65536 files. The workflow is at most 262144 bytes; each selection and -output envelope at most 65536; each case and payload at most 1048576; each set and the execution -record at most 8388608. Dialect §12's smaller artifact and combined-input bounds +output envelope at most 65536; each case and payload at most 1048576; each set, summary and the +execution record at most 8388608. Dialect §12's smaller artifact and combined-input bounds also apply separately to every preparation, including retained attempts. Counts include empty containers and unsuccessful work; nothing is truncated to fit. The same bounds hold canonical bytes and input bytes. A workflow runner refuses admission before acquisition when a known bound @@ -264,7 +284,7 @@ set with those failures explicit. Nothing is truncated to fit. ### 3. Stages and projected cases A stage is exactly `{id, dialect, pack, input, execution, caseBindings, outputs, relation}` and -the conditional `selection` below. `dialect` selects exactly one of two closed forms: +the conditional `selection` and `sample` below. `dialect` selects exactly one of two closed forms: - `{version, digest}` for an integer-format dialect, where `version` is integer `2` or `3`; - `{formatVersion, id, version, digest}` for a named dialect, where `formatVersion` is exactly @@ -327,6 +347,12 @@ O is a declared pack outcome id, 1 to 256 UTF-8 bytes. This is equality on the r Disposition mode requires `outputs.cardinality: "one"`. Evaluation errors have no disposition and no outputs. Rules do not select a batch's members by the batch's disposition. +The independent conditional `sample` is exactly `{mode: "sample", maxSize: N}`, where `N` is an +integer from 1 through `limits.maxRecords`. It requests §6's reproducible human-review sample and +does not select inputs for a consumer. It may therefore be present on a terminal stage and may +coexist with `selection`. `maxSize` is the stage's bound on the requested records per outcome; +it does not raise any §2 bound. + `relation` is `{label, direction}`: label is an id, and direction is `output-to-parent` or `parent-to-output`. A root's parent is the root case; a dependent output's parent is its selected record. The label declares provenance, not a verified identity, employment or other domain fact. @@ -391,9 +417,16 @@ binds only that bound dialect; its binding is unchanged. Re-expansion checks con ### 5. Output records and closed sets -An output record is `{outputRecordVersion: 1, payloadDigest, lineage}`; the payload is a separate -object in §1's literal-preserving bytes. Its identity is the envelope's canonical digest, not -the payload digest: duplicate payloads can be distinct records. `lineage` is exactly: +An output record is `{outputRecordVersion: 1, payloadDigest, contributingRule, lineage}`, with +the conditional `contributingRule` omitted as specified below; the payload is a separate object +in §1's literal-preserving bytes. Its identity is the envelope's canonical digest, not the +payload digest: duplicate payloads can be distinct records. `contributingRule` is outside the +Core §8.3 disposition and never changes that closed form. For an outcome forced by one or more +true exceptions it is the least forcing exception id by code point. Otherwise, for an `outcome` +produced by one or more true rules it is the contributing rule id, choosing the least id by code +point when several true rules produce that same outcome. It is absent for a fallback outcome, +`not-applicable` and `unresolved`. This accounting choice gives no Core rule or exception +priority. `lineage` is exactly: - `workflowDigest`, `execution`, `stage`, `instance`: the workflow digest and three ids; - `publishedDialectDigest`, `bindingDocumentDigest`, `boundDialectDigest`: digests, with only @@ -422,18 +455,48 @@ digest binds the whole offered set, selected or not. Failed instances contribute but remain in the inventory. The set is closed only after every expected instance is terminal; it is never presented as complete discovery. Every stage, even one with no instances, has a set. +A **stage summary** is one closed record per stage and execution, exactly +`{summaryVersion: 1, workflowDigest, execution, stage, records, recordsDigest, byKind, byOutcome, +byReason, byRule}`. The first member is the integer `1`; the next three identify the workflow, +execution and stage; and `records` is the number of output records in that stage's recorded set, +including zero for a blocked stage. +The remaining members are: + +- `recordsDigest`: SHA-256 of the exact bytes `canon([D1, D2, …, Dn])`, prefixed `sha256:`, where + each `Di` is the decoded Core §8.3 disposition from the successful attempt for the corresponding + output in closed-set order. Repeated dispositions are repeated. The covered bytes are exactly + the opening bracket, every disposition in `canon` form separated by commas, and the closing + bracket, with no whitespace or final newline; for no records they are exactly `[]`. +- `byKind`: exactly `{outcome, not-applicable, unresolved}`, with a nonnegative count for each. +- `byOutcome`: an object whose members are exactly the outcome ids observed on `outcome` records + and whose positive integer values count them; it is `{}` when none were observed. +- `byReason`: an object whose members are exactly the distinct Core reason values on `unresolved` + records and whose positive integer values count records carrying each reason. One record can + increment several reasons; it is `{}` when no unresolved reason was observed. +- `byRule`: an object with exactly one member for each member of `byOutcome`. Its value is exactly + `{contributors, fallback}`: `contributors` maps each observed `contributingRule` id for that + outcome to its positive count, and `fallback` is the nonnegative count of records for which the + conditional member is absent. A forcing exception id is counted as the contributing id recorded + by the envelope. Contributor members with zero counts are omitted. + +All counters use §1's integer domain. `records`, the three `byKind` counts and the sum of +`byOutcome` are equal. For each outcome, its contributor counts plus `fallback` equal its +`byOutcome` count. Object member order in retained bytes follows `canon`; no display order or +implementation map order is significant. + ### 6. Selection records A selection is exactly `{selectionVersion: 1, workflowDigest, execution, upstreamStage, outputSetDigest, selected, mode, revision}` and the conditional members below. `execution` and `upstreamStage` are ids; both digest members are digests. `selected` contains 0 to `limits.maxRecords` distinct output-envelope digests, all in that closed set, in upstream order. -`revision` is an integer from 1 to 16. Revisions increase by one for each replacement selection -of the same execution and upstream stage; the first is 1. The bytes of each revision are retained. +`revision` is an integer from 1 to 16. Revisions increase by one for each replacement of the same +execution, upstream stage and role, where sample and consuming selections are separate roles; the +first of each role is 1. The bytes of each revision are retained. -`upstreamStage` names the producing stage whose outputs are offered. `mode` equals that stage's -`selection.mode`. In disposition mode the additional member `rule` equals that stage's -`selection.rule`, and `selected` is exactly every output whose evaluated case's recorded +`upstreamStage` names the producing stage whose outputs are offered. `mode` is `sample`, or it +equals that stage's `selection.mode`. In disposition mode the additional member `rule` equals that +stage's `selection.rule`, and `selected` is exactly every output whose evaluated case's recorded disposition satisfies it. A dependent stage consumes this selection through `input.fromStage` and `input.selected` (§3). In reviewer mode the additional member `review` is exactly `{actor, assertion, at}`: an @@ -443,17 +506,38 @@ real person's identity or intent. An empty selection is explicit, never inferred The conditional member `refusal`, when present, is exactly `"limit-exceeded"`: the selection was refused at admission (§8), remains unconsumed, and blocks its consumers for that recorded reason. +In sample mode the additional members are `seed`, `size` and `algorithm`; `review`, `rule` and +`refusal` are absent. The stage MUST carry §3's `sample`. `seed` is exactly 64 lowercase +hexadecimal characters. `size` is an integer from 1 through that stage's `sample.maxSize`, and +`algorithm` is exactly `"sha256-rank-v1"`. Partition the offered output records by the outcome id +in their Core disposition, excluding `not-applicable` and `unresolved`. Within each partition, +rank a record by the unsigned 32-byte SHA-256 value of +`UTF8(seed) || 0x00 || UTF8(recordDigest)`, least first, breaking an otherwise equal rank by the +record digest's code-point order. Take the first `min(size, partition length)` records from every +partition, then write `selected` in their original upstream order. Thus the seed, size, offered +set and recorded dispositions determine the sample without an implementation random-number +generator. + +A writer refuses a sample request whose size exceeds the stage's `maxSize` as `input-over-bound` +before writing a selection, and refuses a seed of any other form as `input-malformed`. A verifier +gives the same refusals for a supplied sample record, including an empty, missing or oversized +seed. These are report refusals, not `refusal` members on an otherwise valid sample record. + Once consumed by a downstream admission, a selection is immutable. A change writes a new revision, with a new digest, and a new branch. All consumers within a branch use the same selection of an upstream stage. Existing children remain attached to their original selection. Branches share an execution id and unchanged ancestor instances; an execution record closes exactly one branch, identified by its ordered selection digests, without overwriting earlier branch records. Changed downstream output sets require new selections before any further children are admitted. +A sample is never consumed and does not open a workflow branch. Replacing one writes a new sample +revision and an execution record naming that digest; earlier sample bytes and execution records +remain unchanged. ### 7. Execution records, instances and attempts An execution record is exactly `{executionVersion: 1, workflowDigest, execution, rootCaseDigest, -frozen, instances, outputSets, selections}`. The digest members are digests and `execution` an id. +frozen, instances, outputSets, summaries, selections, samples}`. The digest members are digests +and `execution` an id. `frozen` has one entry per stage in document order, exactly `{stage, publishedDialectDigest, bindingDocumentDigest, boundDialectDigest, packDigest, releaseDigest, runtimeDigest}`. These are an id and digests, with only the binding nullable under §4. Retain the named documents. The @@ -539,13 +623,16 @@ Existing run exports and their result members are unchanged by this workflow sid `outputSets` has one entry per stage in stage order, exactly `{stage, status, records}`. `status` is `closed` or `blocked`; `records` is the digest array of §5. Its closed-set digest is derived -using the execution's workflow digest and id. `selections` has one entry per stage in that same -order: a selection digest, or null. A stage with consumers may lack a selection only when the +using the execution's workflow digest and id. `summaries` has one stage-summary digest per stage +in stage order, including the empty summary for a blocked stage. `selections` has one entry per +stage in that same order: a consuming selection digest, or null. `samples` likewise has one entry +per stage: its sample-selection digest exactly when the stage declares `sample`, otherwise null. +A stage with consumers may lack a consuming selection only when the branch ends before review or selection completes. Its consumers are `blocked`, with no instances, outputs or selection, transitively; a selection carrying `refusal` blocks them in the same way, with its digest retained in `selections`. A stage with a consumed upstream selection is `closed`, including an empty selection yielding zero instances. Root is closed. Terminal stages have null -selections. +consuming selections. Sample selections never affect closure, admission or completion. For each non-blocked dependent stage, reconstruct one expected instance for every selected parent. Missing, extra, repeated or foreign-parent instances are findings. A consumed selection @@ -565,7 +652,9 @@ bound without partial admission. A successful selection enters the execution's ` atomically with reservation of all its direct consumers' children. A bound refusal instead writes a selection record carrying `refusal: "limit-exceeded"` and retains its digest there, without reserving children. It is unconsumed and its consumers are blocked. The inventory -survives interruption or cancellation. +survives interruption or cancellation. Every persisted or resumed execution carries the complete +`summaries` and `samples` arrays and every digest already assigned to them; recovery does not +reconstruct a different seed, sample or summary from mutable state. A retried acquisition is a new recorded attempt under the same instance, with new retained responses and receipts, never an offline replay. Uncertain acquisitions are not retried @@ -578,8 +667,8 @@ recomputes derivations over retained answers and never calls a model or a source admission is a writer rule; an offline reader checks the resulting inventory, not the history of every side effect. There is no end-to-end exactly-once claim. -The execution record commits outputs and selections without a digest cycle. It is unsigned in -this version. Existing audit signatures cover their own audit bytes and preparation bindings, +The execution record commits outputs, summaries and selections without a digest cycle. It is +unsigned in this version. Existing audit signatures cover their own audit bytes and preparation bindings, not these new envelopes or which published dialect and binding produced the preparation. An independently held execution digest can expose subsequent replacement, but this version specifies no authenticated execution commitment. The Runner's journal is unsigned and unchained @@ -588,7 +677,8 @@ specifies no authenticated execution commitment. The Runner's journal is unsigne ### 9. The workflow verifier The `workflow-verifier` reads the workflow, its expected digest, one execution record and its -expected digest, and all referenced current-branch artifacts. Other branches and earlier selection +expected digest, and all referenced current-branch artifacts, including summaries and samples. +Other branches and earlier selection revisions are retained by the writer but not traversed. The reader checks the chosen revision's form; it cannot establish an unprovided revision history or enforce transactional immutability. The runtime executable is supplied separately only for optional replay; this reader compares the @@ -626,8 +716,8 @@ is none, the first preparation finding maps to `preparation-mismatch`, including preparation passes do the checks below run. Influence uses the recomputed lineage, never the unchecked retained lineage. No freshness check substitutes today's clock. -Then perform checks 1 to 4 in order, stopping at the first finding. Within each check use stage -order, then instance and attempt order; finish one instance's listed clauses before the next. +Then perform checks 1 to 5 in order, stopping at the first finding or refusal. Within each check +use stage order, then instance and attempt order; finish one instance's listed clauses before the next. Within check 1, workflow and execution pins come first, then frozen stage pins, then instance artifacts; within an instance, `digest-mismatch` precedes `stage-binding-mismatch`. Within check 3, an earlier instance's `influence-mismatch` precedes a later one's `case-mismatch`. @@ -642,7 +732,9 @@ check 3, an earlier instance's `influence-mismatch` precedes a later one's `case give `stage-binding-mismatch`. A workflow runner refuses to freeze a stage with incompatible research profiles, so no instance is admitted (§4). 2. Each selection's full offered set, revision and exact ordered membership; recompute the closed - disposition predicate against the retained Core disposition (`selection-mismatch`). + disposition predicate against the retained Core disposition (`selection-mismatch`). Redraw each + sample from its seed, size and offered set. A different ordered membership is a + `sample-mismatch` refusal, not a plausible alternative sample. 3. Every projected case against its parent and bindings, including expected projection failures (`case-mismatch`), then inherited classes, influence and explicit admission (`influence-mismatch`). @@ -650,6 +742,11 @@ check 3, an earlier instance's `influence-mismatch` precedes a later one's `case deterministic bound accounting and stage closure (`inventory-mismatch`); then re-extract all outputs, compare payload bytes, order, complete sets, parent/selection references, relation and lineage pins (`output-mismatch`). Neither failure nor missing data becomes `[]`. +5. From those checked output records and their retained dispositions, recompute every stage + summary, including all counters and the exact `recordsDigest` bytes of §5. A well-formed but + unequal recorded summary is a `summary-mismatch` refusal. Missing, extra and malformed summary + references have the earlier reference-phase answer. The supplied summary is never trusted as + the source for this computation. Predicate checking establishes agreement with the recorded disposition, not its recomputation. Runtime replay and verification of signatures or checkpoints are separate checks; this class @@ -660,19 +757,69 @@ A refusal is `{result: "refusal", refusal: C}` and a finding is `{result: "findi C one code above. A reading is exactly `{result: "reading", workflowDigest, executionDigest, consistency: "agrees", completion, preparations: "agree", authentication: "not-established", dispositions: "not-replayed", counts}`. `completion` is `complete` or `partial` by §7; `counts` -is `{stages, instances, outputs, selections}`, nonnegative integers within §2's bounds (selections -at most stages). A partial reading is consistency of an explicitly incomplete execution, not +is `{stages, instances, outputs, selections, summaries, samples}`, nonnegative integers within +§2's bounds (selections, summaries and samples at most stages). A partial reading is consistency +of an explicitly incomplete execution, not successful completion. Reports are at most 4096 bytes. A program exits 0 for a reading, 1 for a refusal, 2 for a finding, and 3 with no report when it could not run, including an existing file that cannot be read or an invalid command argument. A missing pinned artifact is instead the `inventory-mismatch` refusal above. A library exposes the same outcomes in its own idiom. +The verifier also exposes a **drift computation** over two bundles only after each bundle has its +own reading. Their `workflowDigest` values MUST be equal; otherwise it gives +`{result: "refusal", refusal: "drift-workflow-mismatch"}`. For each stage it subtracts the left +summary's `byKind`, `byOutcome`, `byReason` and `byRule` counters from the right summary's counters, +treating an absent map member as zero, and reports the signed deltas. It then matches output +records by `(stage, payloadDigest, occurrence)`, where `occurrence` starts at 1 among equal payload +digests in each stage's closed-set order. For each matched key whose canonical disposition differs +it reports the left and right dispositions; an unmatched key is reported as added or removed with +the absent side null. + +The drift result is exactly `{result: "drift", workflowDigest, leftExecutionDigest, +rightExecutionDigest, stages, records}`. `stages` is in workflow order and carries those counter +deltas; `records` is in stage order, then payload-digest and occurrence order, and carries only +changed, added or removed keys. Empty differences are explicit empty arrays. + +A stage-delta row is exactly `{stage, records, byKind, byOutcome, byReason, byRule}`. It occurs +once for every workflow stage, including a stage with only zero deltas. Its members are closed: + +| Member | Type and bound | +| --- | --- | +| `stage` | that stage's id | +| `records` | right summary `records` minus left, an integer from -4096 through 4096 | +| `byKind` | exactly `{outcome, not-applicable, unresolved}`, each the corresponding signed delta in that range | +| `byOutcome` | outcome-id members whose values are nonzero signed deltas in that range; absent on both sides is zero | +| `byReason` | Core-reason members whose values are nonzero signed deltas in that range; absent on both sides is zero | +| `byRule` | outcome-id members whose values are exactly `{contributors, fallback}` | + +Each `byRule` `contributors` object has contributing-rule-id members whose values are nonzero +signed deltas from -4096 through 4096; absent contributors are zero. `fallback` is the signed +fallback delta in that range and is present even when zero. An outcome row is present exactly +when at least one contributor or fallback delta is nonzero. Thus no zero dynamic member or open +row can give an alternative representation of the same stage delta. The stage array has 1 to 32 +rows; each dynamic object has at most 4096 members, subject to §2's smaller execution bounds. + +A record-delta row is exactly one of the following closed forms; `stage` is an id, +`payloadDigest` a digest and `occurrence` an integer from 1 through 4096: + +| Change | Exact row | Meaning | +| --- | --- | --- | +| changed | `{change: "changed", stage, payloadDigest, occurrence, left, right}` | `left` and `right` are the unequal Core §8.3 dispositions | +| added | `{change: "added", stage, payloadDigest, occurrence, left: null, right}` | no left output has that matching key; `right` is the added disposition | +| removed | `{change: "removed", stage, payloadDigest, occurrence, left, right: null}` | `left` is the removed disposition; no right output has that matching key | + +Null means only that the named side has no output at the matching key; it is never an unknown or +missing disposition. A non-null side is one complete, closed Core disposition. The records array +has 0 to 4096 rows. The result is a +comparison of two separately verified bundles: it makes no judgment, verdict or authorization, +does not replay either disposition, and is not a workflow reading or finding. + A conforming reader gives these answers for every admitted input and all fixtures published with the specification version its claim names. Its claim names `workflow-verifier`, workflow version 1, that `specVersion`, and the dialect versions, kinds and shapes the published fixtures exercise. This is a reader class, not an executor, preparation writer or Core conformance class; -none implies another. No claim is available while this text is Draft or until such fixtures are -published. No fixtures or vectors have been recorded for it. +none implies another. No claim is available while this text is Draft. The §11 contract fixture +tests these closed forms but is not an implementation recording or a conformance corpus. ### 10. What a workflow establishes @@ -685,10 +832,21 @@ model answers, which model answered, or exactly-once execution. Digest pins esta identity, not currency. Generated-only admission checks a profile's declared class. The installation's classification of what its channel actually serves remains a trust assumption; a reading cannot detect an undeclared model behind a record-class channel (§4; dialect §5.1). +Summary recomputation establishes that the bulk counts agree with the retained output envelopes +and dispositions. Because this reader does not replay dispositions, it does not independently +establish that a recorded `contributingRule` condition was true; that remains part of the retained +evaluation evidence and any separate Runtime replay. -### 11. The two-stage fixture to record +### 11. The two-stage fixture -Nothing in this section is recorded yet. The first fixture is a run from the Desk's research +The hand-written contract fixture at `conformance/workflow/` records `workflow.json`, both +verified-bundle projections, every stage's output envelopes, dispositions and summaries, one +seeded sample, and the expected drift result. Its manifest also names the oversized-size, +malformed-seed and unequal-summary refusal vectors. The workflow schema at +`schema/workflow-1.schema.json` closes every form. These records test bytes and structure; they +are not captured executor output and establish no reader claim. + +The first full run fixture is from the Desk's research line using two version-3 unbound `operation` dialects, bound by the Runner at import. This remains the acceptance construction for issue #160's condition 2. An additional research-stage recording follows fixture 8's `selected-parent-child` plan in @@ -719,7 +877,19 @@ a workflow executor and verifier; those are not established by single-stage prep - `result` is `people`. A reviewer selects the first and third organisation in upstream order; one child returns two synthetic people objects, the other an empty array. The inventory holds three instances, one `completed-zero`, five output records and the complete three-record - offered set. The relation says which organisation was researched, not that employment is true. + offered set. Every output carries its rule or forced-exception contributor, with fallback and + non-outcome cases omitting it. The relation says which organisation was researched, not that + employment is true. + +The recording carries one summary for each stage and one seeded sample for `organisations`. Both +stage sample configurations use the same `maxSize`; the sample record uses a fixed hand-written +seed and size and names `sha256-rank-v1`, and its selected array is computed independently from +the recorded inputs. +The primary summaries carry the closed member forms, contributor and fallback counts, and +independently fixed `recordsDigest` values over the ordered dispositions. The fixture variants +collectively carry every kind, outcome, unresolved reason and contributing-rule form. A second +bundle of the same workflow and its independently fixed expected drift result exercise summary +deltas and changed, added and removed per-record rows without assigning a verdict. A recording carries exactly one branch: one set per stage and only the selection revision that branch names. Other revisions and branches are retained separately. Its directory contains the @@ -734,13 +904,15 @@ bounds above, not literal path characters: `evaluation-error.json` under that instance; artifacts, including the case, are absent exactly when their pins are null; - `outputs////payload.json` and `record.json`; -- `selections//.json`, `sets/.json`, `expected-report.json`, `trust.json` +- `selections//.json`, `samples//.json`, + `sets/.json`, `summaries/.json`, `expected-report.json`, `trust.json` (declared public test trust material) and `README.md` (recording commands and independent pins). Each `.sha256` file holds one digest and a newline; `verified-at.txt` holds the preparation's timestamp and a newline. The preparation's own `verifiedAt` and digest computed from its bytes govern; these side files must agree and never override them. `workflow.json`, `execution.json`, -selection files, set files and output `record.json` hold their `canon` bytes, with no newline; +selection, sample, set and summary files and output `record.json` hold their `canon` bytes, with +no newline; so do `dialect.json`, `binding.json`, `bound-dialect.json` and `profiles.json` in their own forms. `root-case.json`, `case.json`, `payload.json` and `preparation.json` hold the dialect §9.5 bytes, with no newline. `pack.json`, `sources.json`, `release.json`, `run.json`, `audit.json` and @@ -771,7 +943,19 @@ refusal, and retries before and after consumption. Freeze expected reports with Add integer and named family-3 variants with matching published identities, and refuse changed selectors, identity-stripped bound documents, missing/tampered error envelopes, error envelopes attached to successful attempts and ordinary invocation errors mislabeled as Core errors. -This layout supplies no evidence of those runs today. +Add hand-written summary mutations for every counter, contributor, fallback and digest member, +and a hand-written twin mutation in the other reader's cases; each must give `summary-mismatch`. +Add sample mutations for the seed, size, algorithm and selected order; include empty, missing, +malformed and oversized seeds, a sample size one above the stage's `maxSize`, and a redrawn member +that differs by one record. The form cases give `input-malformed` or `input-over-bound` as §6 +requires, and the well-formed redraw mutation gives `sample-mismatch`. Interrupted and resumed +variants retain all summary and sample checkpoint members byte for byte. +For every new refusal code, retain a valid expected-report event and hand-write its twin for the +other reader. Include absent artifacts, empty objects or arrays, missing required members and an +object, array or string one above its applicable bound; the reference-phase refusal wins whenever +that malformed form precedes the semantic mutation. Compare the drift pair with a bundle of a +different workflow for `drift-workflow-mismatch` as well as with its matching workflow. +The hand-written contract fixture supplies no evidence of those full runs today. ## Alternatives @@ -797,6 +981,12 @@ conforming readers to migrate. Readers of the earlier draft must refuse these ad than silently dropping them. Research release encoding is now pinned in §1; its additional acceptance recording remains due beside the operation demonstration. +The 2026-10-22 amendment adds the conditional output `contributingRule`, stage `sample`, execution +`summaries` and `samples`, the stage-summary record and sample-selection members to workflow +version 1's still-Draft forms. It also extends reading counts and adds summary/sample refusal codes +and a drift result. There are still no published instances or conforming readers to migrate; an +older draft reader must refuse these members rather than ignore them. + Core, the dialect, the graph and the Runner's existing API are unchanged. A standalone pack or dialect keeps its existing meaning. This RFC defines documents and records, not new endpoints, trigger kinds or a reinterpretation of a current Runner job. The Runner's `openapi.json` fixes @@ -857,7 +1047,10 @@ selection, empty outputs, missing and null values, duplicate payloads, ordering negative mutations of pins, selections, inventory and influence; bound and fan-out cases; and interruption after admission, uncertain acquisition and retries on either side of consumption. The generated-payload parameter case and deterministic sibling-bound case hold the rules of §§4 -and 2 respectively. These are recordings to obtain, not evidence already held. +and 2 respectively. The plan now also requires per-stage summaries and seeded samples, every new +member and counter form, malformed and over-bound sample inputs, hand-written summary/sample +mutations in both readers, resumed checkpoints and a same-workflow drift pair. These are +recordings to obtain, not evidence already held. ## Implementation @@ -879,7 +1072,10 @@ Implementation follows this order; none of these additions is implemented by thi reviewer assertions. Record the required run from the research line, retaining the answers and the complete offered set, without organisation/person special cases in the Runner. 4. **Exchange:** a versioned workflow entry referencing the standalone definition and its - fixtures, last, through a revision of RFC 0019. Runtime and Gateway require no semantic change. + fixtures, last, through a revision of RFC 0019. + +This amendment does not implement the Runtime evaluation record, the Runner executor or either +project's bulk benchmarks. Those downstream changes remain separately measured and reviewed. This is the allocation in option A's cost paragraph of the design memo recorded on issue #160. No reader code is added here. Repository tests hold the RFC's Specification equal to the companion, diff --git a/schema/workflow-1.schema.json b/schema/workflow-1.schema.json new file mode 100644 index 0000000..96c36ff --- /dev/null +++ b/schema/workflow-1.schema.json @@ -0,0 +1,840 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://judgmentpack.org/schema/0.2.0-draft/workflow-1.schema.json", + "title": "Judgment Pack workflow companion records, version 1", + "oneOf": [ + { "$ref": "#/$defs/workflow" }, + { "$ref": "#/$defs/outputRecord" }, + { "$ref": "#/$defs/stageSummary" }, + { "$ref": "#/$defs/selection" }, + { "$ref": "#/$defs/verifierReport" }, + { "$ref": "#/$defs/drift" }, + { "$ref": "#/$defs/bundleFixture" }, + { "$ref": "#/$defs/fixtureManifest" }, + { "$ref": "#/$defs/refusalVector" } + ], + "$defs": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "longId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "seed": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 4096 + }, + "positiveCount": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "delta": { + "type": "integer", + "minimum": -4096, + "maximum": 4096 + }, + "nonzeroDelta": { + "type": "integer", + "minimum": -4096, + "maximum": 4096, + "not": { "const": 0 } + }, + "reason": { + "enum": [ + "not-applicable", + "missing-required-evidence", + "unknown", + "conflict", + "no-match", + "exception-escalation" + ] + }, + "handoff": { + "oneOf": [ + { + "type": "object", + "required": ["state"], + "properties": { "state": { "const": "none" } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["state", "triggeredBy"], + "properties": { + "state": { "const": "requested" }, + "triggeredBy": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "uniqueItems": true, + "items": { "$ref": "#/$defs/reason" } + } + }, + "additionalProperties": false + } + ] + }, + "disposition": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "outcomeId", "reasons", "handoff"], + "properties": { + "kind": { "const": "outcome" }, + "outcomeId": { "$ref": "#/$defs/longId" }, + "reasons": { "type": "array", "maxItems": 0 }, + "handoff": { "$ref": "#/$defs/handoff" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["kind", "reasons", "handoff"], + "properties": { + "kind": { "const": "not-applicable" }, + "reasons": { + "type": "array", + "prefixItems": [{ "const": "not-applicable" }], + "items": false, + "minItems": 1, + "maxItems": 1 + }, + "handoff": { "$ref": "#/$defs/handoff" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["kind", "reasons", "handoff"], + "properties": { + "kind": { "const": "unresolved" }, + "reasons": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "uniqueItems": true, + "items": { + "enum": [ + "missing-required-evidence", + "unknown", + "conflict", + "no-match", + "exception-escalation" + ] + } + }, + "handoff": { "$ref": "#/$defs/handoff" } + }, + "additionalProperties": false + } + ] + }, + "relation": { + "type": "object", + "required": ["label", "direction"], + "properties": { + "label": { "$ref": "#/$defs/id" }, + "direction": { "enum": ["output-to-parent", "parent-to-output"] } + }, + "additionalProperties": false + }, + "dialectReference": { + "oneOf": [ + { + "type": "object", + "required": ["version", "digest"], + "properties": { + "version": { "enum": [2, 3] }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["formatVersion", "id", "version", "digest"], + "properties": { + "formatVersion": { "enum": ["2.0.0", "3.0.0"] }, + "id": { "type": "string", "format": "uri", "maxLength": 2048 }, + "version": { "type": "string", "minLength": 1, "maxLength": 256 }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + } + ] + }, + "selectionRule": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "outcomeId"], + "properties": { + "kind": { "const": "outcome" }, + "outcomeId": { "$ref": "#/$defs/longId" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["kind"], + "properties": { "kind": { "enum": ["unresolved", "not-applicable"] } }, + "additionalProperties": false + } + ] + }, + "caseBinding": { + "type": "object", + "required": ["from", "to", "type", "required"], + "properties": { + "from": { "type": "string", "maxLength": 1024 }, + "to": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "type": { + "enum": ["string", "integer", "timestamp", "boolean", "object", "array", "null"] + }, + "required": { "type": "boolean" } + }, + "additionalProperties": false + }, + "stage": { + "type": "object", + "required": [ + "id", "dialect", "pack", "input", "execution", "caseBindings", "outputs", "relation" + ], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "dialect": { "$ref": "#/$defs/dialectReference" }, + "pack": { + "type": "object", + "required": ["id", "digest"], + "properties": { + "id": { "type": "string", "format": "uri", "maxLength": 2048 }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + "input": { + "oneOf": [ + { + "type": "object", + "required": ["root"], + "properties": { "root": { "const": true } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["fromStage", "selected"], + "properties": { + "fromStage": { "$ref": "#/$defs/id" }, + "selected": { "const": true } + }, + "additionalProperties": false + } + ] + }, + "execution": { "enum": ["once", "each-selected-record"] }, + "caseBindings": { + "type": "array", + "maxItems": 64, + "items": { "$ref": "#/$defs/caseBinding" } + }, + "outputs": { + "type": "object", + "required": ["pointer", "cardinality"], + "properties": { + "pointer": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "cardinality": { "enum": ["one", "many"] } + }, + "additionalProperties": false + }, + "relation": { "$ref": "#/$defs/relation" }, + "selection": { + "oneOf": [ + { + "type": "object", + "required": ["mode"], + "properties": { "mode": { "const": "reviewer" } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["mode", "rule"], + "properties": { + "mode": { "const": "disposition" }, + "rule": { "$ref": "#/$defs/selectionRule" } + }, + "additionalProperties": false + } + ] + }, + "sample": { + "type": "object", + "required": ["mode", "maxSize"], + "properties": { + "mode": { "const": "sample" }, + "maxSize": { "type": "integer", "minimum": 1, "maximum": 4096 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "workflow": { + "type": "object", + "required": ["workflowVersion", "stages", "result", "limits"], + "properties": { + "workflowVersion": { "const": 1 }, + "stages": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { "$ref": "#/$defs/stage" } + }, + "result": { "$ref": "#/$defs/id" }, + "limits": { + "type": "object", + "required": ["maxStages", "maxRecords", "maxInstances", "maxAttempts", "maxBytes"], + "properties": { + "maxStages": { "type": "integer", "minimum": 1, "maximum": 32 }, + "maxRecords": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "maxInstances": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "maxAttempts": { "type": "integer", "minimum": 1, "maximum": 16 }, + "maxBytes": { "type": "integer", "minimum": 1, "maximum": 1073741824 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "lineage": { + "type": "object", + "required": [ + "workflowDigest", "execution", "stage", "instance", "publishedDialectDigest", + "bindingDocumentDigest", "boundDialectDigest", "packDigest", "releaseDigest", + "preparationDigest", "auditDigest", "outputPointer", "index", "rootCaseDigest", + "caseDigest", "parentRecord", "selectionDigest", "relation", "influence" + ], + "properties": { + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "stage": { "$ref": "#/$defs/id" }, + "instance": { "$ref": "#/$defs/id" }, + "publishedDialectDigest": { "$ref": "#/$defs/digest" }, + "bindingDocumentDigest": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] }, + "boundDialectDigest": { "$ref": "#/$defs/digest" }, + "packDigest": { "$ref": "#/$defs/digest" }, + "releaseDigest": { "$ref": "#/$defs/digest" }, + "preparationDigest": { "$ref": "#/$defs/digest" }, + "auditDigest": { "$ref": "#/$defs/digest" }, + "outputPointer": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "index": { "type": "integer", "minimum": 0, "maximum": 4095 }, + "rootCaseDigest": { "$ref": "#/$defs/digest" }, + "caseDigest": { "$ref": "#/$defs/digest" }, + "parentRecord": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] }, + "selectionDigest": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] }, + "relation": { "$ref": "#/$defs/relation" }, + "influence": { + "type": "object", + "required": ["classes", "generatedInfluence"], + "properties": { + "classes": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": { "enum": ["asserted", "generated", "record"] } + }, + "generatedInfluence": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "outputRecord": { + "type": "object", + "required": ["outputRecordVersion", "payloadDigest", "lineage"], + "properties": { + "outputRecordVersion": { "const": 1 }, + "payloadDigest": { "$ref": "#/$defs/digest" }, + "contributingRule": { "$ref": "#/$defs/id" }, + "lineage": { "$ref": "#/$defs/lineage" } + }, + "additionalProperties": false + }, + "byKind": { + "type": "object", + "required": ["outcome", "not-applicable", "unresolved"], + "properties": { + "outcome": { "$ref": "#/$defs/count" }, + "not-applicable": { "$ref": "#/$defs/count" }, + "unresolved": { "$ref": "#/$defs/count" } + }, + "additionalProperties": false + }, + "positiveCountMap": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { "^.{1,256}$": { "$ref": "#/$defs/positiveCount" } }, + "additionalProperties": false + }, + "positiveReasonCountMap": { + "type": "object", + "maxProperties": 6, + "patternProperties": { + "^(not-applicable|missing-required-evidence|unknown|conflict|no-match|exception-escalation)$": { + "$ref": "#/$defs/positiveCount" + } + }, + "additionalProperties": false + }, + "positiveContributorCountMap": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { + "^[a-z][a-z0-9-]{0,63}$": { "$ref": "#/$defs/positiveCount" } + }, + "additionalProperties": false + }, + "ruleCount": { + "type": "object", + "required": ["contributors", "fallback"], + "properties": { + "contributors": { "$ref": "#/$defs/positiveContributorCountMap" }, + "fallback": { "$ref": "#/$defs/count" } + }, + "additionalProperties": false + }, + "byRule": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { "^.{1,256}$": { "$ref": "#/$defs/ruleCount" } }, + "additionalProperties": false + }, + "stageSummary": { + "type": "object", + "required": [ + "summaryVersion", "workflowDigest", "execution", "stage", "records", "recordsDigest", + "byKind", "byOutcome", "byReason", "byRule" + ], + "properties": { + "summaryVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "stage": { "$ref": "#/$defs/id" }, + "records": { "$ref": "#/$defs/count" }, + "recordsDigest": { "$ref": "#/$defs/digest" }, + "byKind": { "$ref": "#/$defs/byKind" }, + "byOutcome": { "$ref": "#/$defs/positiveCountMap" }, + "byReason": { "$ref": "#/$defs/positiveReasonCountMap" }, + "byRule": { "$ref": "#/$defs/byRule" } + }, + "additionalProperties": false + }, + "selectionBase": { + "type": "object", + "required": [ + "selectionVersion", "workflowDigest", "execution", "upstreamStage", "outputSetDigest", + "selected", "mode", "revision" + ], + "properties": { + "selectionVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "upstreamStage": { "$ref": "#/$defs/id" }, + "outputSetDigest": { "$ref": "#/$defs/digest" }, + "selected": { + "type": "array", + "maxItems": 4096, + "uniqueItems": true, + "items": { "$ref": "#/$defs/digest" } + }, + "mode": { "enum": ["reviewer", "disposition", "sample"] }, + "revision": { "type": "integer", "minimum": 1, "maximum": 16 } + }, + "additionalProperties": false + }, + "selection": { + "oneOf": [ + { + "type": "object", + "required": [ + "selectionVersion", "workflowDigest", "execution", "upstreamStage", + "outputSetDigest", "selected", "mode", "revision", "seed", "size", "algorithm" + ], + "properties": { + "selectionVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "upstreamStage": { "$ref": "#/$defs/id" }, + "outputSetDigest": { "$ref": "#/$defs/digest" }, + "selected": { + "type": "array", "maxItems": 4096, "uniqueItems": true, + "items": { "$ref": "#/$defs/digest" } + }, + "mode": { "const": "sample" }, + "revision": { "type": "integer", "minimum": 1, "maximum": 16 }, + "seed": { "$ref": "#/$defs/seed" }, + "size": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "algorithm": { "const": "sha256-rank-v1" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "selectionVersion", "workflowDigest", "execution", "upstreamStage", + "outputSetDigest", "selected", "mode", "revision", "rule" + ], + "properties": { + "selectionVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "upstreamStage": { "$ref": "#/$defs/id" }, + "outputSetDigest": { "$ref": "#/$defs/digest" }, + "selected": { + "type": "array", "maxItems": 4096, "uniqueItems": true, + "items": { "$ref": "#/$defs/digest" } + }, + "mode": { "const": "disposition" }, + "revision": { "type": "integer", "minimum": 1, "maximum": 16 }, + "rule": { "$ref": "#/$defs/selectionRule" }, + "refusal": { "const": "limit-exceeded" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "selectionVersion", "workflowDigest", "execution", "upstreamStage", + "outputSetDigest", "selected", "mode", "revision", "review" + ], + "properties": { + "selectionVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "upstreamStage": { "$ref": "#/$defs/id" }, + "outputSetDigest": { "$ref": "#/$defs/digest" }, + "selected": { + "type": "array", "maxItems": 4096, "uniqueItems": true, + "items": { "$ref": "#/$defs/digest" } + }, + "mode": { "const": "reviewer" }, + "revision": { "type": "integer", "minimum": 1, "maximum": 16 }, + "review": { + "type": "object", + "required": ["actor", "assertion", "at"], + "properties": { + "actor": { "type": "string", "minLength": 1, "maxLength": 256 }, + "assertion": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false + }, + "refusal": { "const": "limit-exceeded" } + }, + "additionalProperties": false + } + ] + }, + "verifierReport": { + "oneOf": [ + { + "type": "object", + "required": ["result", "refusal"], + "properties": { + "result": { "const": "refusal" }, + "refusal": { + "enum": [ + "input-malformed", "input-over-bound", "reference-mismatch", + "stage-binding-mismatch", "selection-mismatch", "sample-mismatch", + "case-mismatch", "influence-mismatch", "inventory-mismatch", + "output-mismatch", "summary-mismatch", "drift-workflow-mismatch" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["result", "finding"], + "properties": { + "result": { "const": "finding" }, + "finding": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "result", "workflowDigest", "executionDigest", "consistency", "completion", + "preparations", "authentication", "dispositions", "counts" + ], + "properties": { + "result": { "const": "reading" }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "executionDigest": { "$ref": "#/$defs/digest" }, + "consistency": { "const": "agrees" }, + "completion": { "enum": ["complete", "partial"] }, + "preparations": { "const": "agree" }, + "authentication": { "const": "not-established" }, + "dispositions": { "const": "not-replayed" }, + "counts": { + "type": "object", + "required": ["stages", "instances", "outputs", "selections", "summaries", "samples"], + "properties": { + "stages": { "$ref": "#/$defs/count" }, + "instances": { "$ref": "#/$defs/count" }, + "outputs": { "$ref": "#/$defs/count" }, + "selections": { "$ref": "#/$defs/count" }, + "summaries": { "$ref": "#/$defs/count" }, + "samples": { "$ref": "#/$defs/count" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "deltaByKind": { + "type": "object", + "required": ["outcome", "not-applicable", "unresolved"], + "properties": { + "outcome": { "$ref": "#/$defs/delta" }, + "not-applicable": { "$ref": "#/$defs/delta" }, + "unresolved": { "$ref": "#/$defs/delta" } + }, + "additionalProperties": false + }, + "deltaMap": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { "^.{1,256}$": { "$ref": "#/$defs/nonzeroDelta" } }, + "additionalProperties": false + }, + "deltaReasonMap": { + "type": "object", + "maxProperties": 6, + "patternProperties": { + "^(not-applicable|missing-required-evidence|unknown|conflict|no-match|exception-escalation)$": { + "$ref": "#/$defs/nonzeroDelta" + } + }, + "additionalProperties": false + }, + "deltaContributorMap": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { + "^[a-z][a-z0-9-]{0,63}$": { "$ref": "#/$defs/nonzeroDelta" } + }, + "additionalProperties": false + }, + "ruleDelta": { + "type": "object", + "required": ["contributors", "fallback"], + "properties": { + "contributors": { "$ref": "#/$defs/deltaContributorMap" }, + "fallback": { "$ref": "#/$defs/delta" } + }, + "additionalProperties": false + }, + "stageDelta": { + "type": "object", + "required": ["stage", "records", "byKind", "byOutcome", "byReason", "byRule"], + "properties": { + "stage": { "$ref": "#/$defs/id" }, + "records": { "$ref": "#/$defs/delta" }, + "byKind": { "$ref": "#/$defs/deltaByKind" }, + "byOutcome": { "$ref": "#/$defs/deltaMap" }, + "byReason": { "$ref": "#/$defs/deltaReasonMap" }, + "byRule": { + "type": "object", + "maxProperties": 4096, + "patternProperties": { "^.{1,256}$": { "$ref": "#/$defs/ruleDelta" } }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "recordDeltaBaseProperties": { + "type": "object", + "properties": { + "change": { "enum": ["changed", "added", "removed"] }, + "stage": { "$ref": "#/$defs/id" }, + "payloadDigest": { "$ref": "#/$defs/digest" }, + "occurrence": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "left": { "oneOf": [{ "$ref": "#/$defs/disposition" }, { "type": "null" }] }, + "right": { "oneOf": [{ "$ref": "#/$defs/disposition" }, { "type": "null" }] } + }, + "additionalProperties": false + }, + "recordDelta": { + "oneOf": [ + { + "type": "object", + "required": ["change", "stage", "payloadDigest", "occurrence", "left", "right"], + "properties": { + "change": { "const": "changed" }, + "stage": { "$ref": "#/$defs/id" }, + "payloadDigest": { "$ref": "#/$defs/digest" }, + "occurrence": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "left": { "$ref": "#/$defs/disposition" }, + "right": { "$ref": "#/$defs/disposition" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["change", "stage", "payloadDigest", "occurrence", "left", "right"], + "properties": { + "change": { "const": "added" }, + "stage": { "$ref": "#/$defs/id" }, + "payloadDigest": { "$ref": "#/$defs/digest" }, + "occurrence": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "left": { "type": "null" }, + "right": { "$ref": "#/$defs/disposition" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["change", "stage", "payloadDigest", "occurrence", "left", "right"], + "properties": { + "change": { "const": "removed" }, + "stage": { "$ref": "#/$defs/id" }, + "payloadDigest": { "$ref": "#/$defs/digest" }, + "occurrence": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "left": { "$ref": "#/$defs/disposition" }, + "right": { "type": "null" } + }, + "additionalProperties": false + } + ] + }, + "drift": { + "type": "object", + "required": [ + "result", "workflowDigest", "leftExecutionDigest", "rightExecutionDigest", "stages", "records" + ], + "properties": { + "result": { "const": "drift" }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "leftExecutionDigest": { "$ref": "#/$defs/digest" }, + "rightExecutionDigest": { "$ref": "#/$defs/digest" }, + "stages": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { "$ref": "#/$defs/stageDelta" } + }, + "records": { + "type": "array", "maxItems": 4096, + "items": { "$ref": "#/$defs/recordDelta" } + } + }, + "additionalProperties": false + }, + "fixtureRecord": { + "type": "object", + "required": ["recordDigest", "output", "disposition"], + "properties": { + "recordDigest": { "$ref": "#/$defs/digest" }, + "output": { "$ref": "#/$defs/outputRecord" }, + "disposition": { "$ref": "#/$defs/disposition" } + }, + "additionalProperties": false + }, + "fixtureStage": { + "type": "object", + "required": ["stage", "records", "summary"], + "properties": { + "stage": { "$ref": "#/$defs/id" }, + "records": { + "type": "array", "maxItems": 4096, + "items": { "$ref": "#/$defs/fixtureRecord" } + }, + "summary": { "$ref": "#/$defs/stageSummary" } + }, + "additionalProperties": false + }, + "bundleFixture": { + "type": "object", + "required": ["fixtureVersion", "workflowDigest", "executionDigest", "execution", "stages"], + "properties": { + "fixtureVersion": { "const": 1 }, + "workflowDigest": { "$ref": "#/$defs/digest" }, + "executionDigest": { "$ref": "#/$defs/digest" }, + "execution": { "$ref": "#/$defs/id" }, + "stages": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { "$ref": "#/$defs/fixtureStage" } + } + }, + "additionalProperties": false + }, + "fixtureManifest": { + "type": "object", + "required": ["manifestVersion", "schema", "files"], + "properties": { + "manifestVersion": { "const": 1 }, + "schema": { "const": "../../schema/workflow-1.schema.json" }, + "files": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "required": ["path", "schemaValid"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 256 }, + "schemaValid": { "type": "boolean" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "refusalVector": { + "oneOf": [ + { + "type": "object", + "required": ["vectorVersion", "kind", "expectedRefusal", "stageMaxSize", "selection"], + "properties": { + "vectorVersion": { "const": 1 }, + "kind": { "const": "sample" }, + "expectedRefusal": { "enum": ["input-over-bound", "input-malformed"] }, + "stageMaxSize": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "selection": { "$ref": "#/$defs/selection" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["vectorVersion", "kind", "expectedRefusal", "dispositions", "summary"], + "properties": { + "vectorVersion": { "const": 1 }, + "kind": { "const": "summary" }, + "expectedRefusal": { "const": "summary-mismatch" }, + "dispositions": { + "type": "array", "maxItems": 4096, + "items": { "$ref": "#/$defs/disposition" } + }, + "summary": { "$ref": "#/$defs/stageSummary" } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/spec/workflow.md b/spec/workflow.md index 15cfc19..c556ebd 100644 --- a/spec/workflow.md +++ b/spec/workflow.md @@ -4,8 +4,9 @@ This is the proposed companion text of [RFC 0020](../rfcs/0020-workflow.md), a Draft. It is normative for nothing. Acceptance would place it beside Core, with the `workflow-verifier` -reader class defined below. No workflow implementation, recorded fixture or conformance claim -exists. The two-stage run in §11 is a recording requirement, not evidence already obtained. +reader class defined below. No workflow implementation or conformance claim exists. Section 11's +hand-written contract fixture records the bulk forms; the full two-stage run +remains a recording requirement, not execution evidence already obtained. ## 1. Terms and canonical forms @@ -22,8 +23,9 @@ are refused. An id is `[a-z][a-z0-9-]{0,63}`. A digest is `sha256:` and 64 lower characters, compared exactly. A timestamp has the form and valid instant of the dialect §1. JSON is strict as in [the dialect](../spec/dialect.md), §1: one value, well-formed Unicode and -number literals retained. The workflow, selection, output, set and execution objects use its `canon` -form: object members ordered by code point, arrays preserved, no whitespace or inserted defaults, +number literals retained. The workflow, selection, output, set, summary and execution objects use +its `canon` form: object members ordered by code point, arrays preserved, no whitespace or +inserted defaults, all supplied members included, integers from −(2⁵³−1) to 2⁵³−1, and `-0` written `0`. Non-integer number literals have no form here. Their digests are SHA-256 of those canonical bytes. A digest member lives outside the object it names; an object never contains its own digest. @@ -82,16 +84,16 @@ It names that stage's ordered outputs, without aggregating dispositions or disca | `maxAttempts` | 16 | attempts per instance | | `maxBytes` | 1073741824 | supplied bundle bytes, each file counted once | -The bounds below and §1's integer domain apply to workflow, selection, output, set and execution -objects and to cases and payloads; packs, acquisitions, preparations, audits and run exports keep +The bounds below and §1's integer domain apply to workflow, selection, output, set, summary and +execution objects and to cases and payloads; packs, acquisitions, preparations, audits and run exports keep their own formats' bounds and number domains, within the branch's aggregate byte and file limits. Those workflow objects have at most 64 container levels; cases and payloads have at most 32. The dialect §12 counting convention applies. Every object has at most 4096 members, every array at most 4096 elements, every decoded string and member name at most 65536 UTF-8 bytes, and every number literal at most 128 characters. Smaller bounds stated here or by a referenced format win. The bundle has at most 65536 files. The workflow is at most 262144 bytes; each selection and -output envelope at most 65536; each case and payload at most 1048576; each set and the execution -record at most 8388608. Dialect §12's smaller artifact and combined-input bounds +output envelope at most 65536; each case and payload at most 1048576; each set, summary and the +execution record at most 8388608. Dialect §12's smaller artifact and combined-input bounds also apply separately to every preparation, including retained attempts. Counts include empty containers and unsuccessful work; nothing is truncated to fit. The same bounds hold canonical bytes and input bytes. A workflow runner refuses admission before acquisition when a known bound @@ -104,7 +106,7 @@ set with those failures explicit. Nothing is truncated to fit. ## 3. Stages and projected cases A stage is exactly `{id, dialect, pack, input, execution, caseBindings, outputs, relation}` and -the conditional `selection` below. `dialect` selects exactly one of two closed forms: +the conditional `selection` and `sample` below. `dialect` selects exactly one of two closed forms: - `{version, digest}` for an integer-format dialect, where `version` is integer `2` or `3`; - `{formatVersion, id, version, digest}` for a named dialect, where `formatVersion` is exactly @@ -167,6 +169,12 @@ O is a declared pack outcome id, 1 to 256 UTF-8 bytes. This is equality on the r Disposition mode requires `outputs.cardinality: "one"`. Evaluation errors have no disposition and no outputs. Rules do not select a batch's members by the batch's disposition. +The independent conditional `sample` is exactly `{mode: "sample", maxSize: N}`, where `N` is an +integer from 1 through `limits.maxRecords`. It requests §6's reproducible human-review sample and +does not select inputs for a consumer. It may therefore be present on a terminal stage and may +coexist with `selection`. `maxSize` is the stage's bound on the requested records per outcome; +it does not raise any §2 bound. + `relation` is `{label, direction}`: label is an id, and direction is `output-to-parent` or `parent-to-output`. A root's parent is the root case; a dependent output's parent is its selected record. The label declares provenance, not a verified identity, employment or other domain fact. @@ -231,9 +239,16 @@ binds only that bound dialect; its binding is unchanged. Re-expansion checks con ## 5. Output records and closed sets -An output record is `{outputRecordVersion: 1, payloadDigest, lineage}`; the payload is a separate -object in §1's literal-preserving bytes. Its identity is the envelope's canonical digest, not -the payload digest: duplicate payloads can be distinct records. `lineage` is exactly: +An output record is `{outputRecordVersion: 1, payloadDigest, contributingRule, lineage}`, with +the conditional `contributingRule` omitted as specified below; the payload is a separate object +in §1's literal-preserving bytes. Its identity is the envelope's canonical digest, not the +payload digest: duplicate payloads can be distinct records. `contributingRule` is outside the +Core §8.3 disposition and never changes that closed form. For an outcome forced by one or more +true exceptions it is the least forcing exception id by code point. Otherwise, for an `outcome` +produced by one or more true rules it is the contributing rule id, choosing the least id by code +point when several true rules produce that same outcome. It is absent for a fallback outcome, +`not-applicable` and `unresolved`. This accounting choice gives no Core rule or exception +priority. `lineage` is exactly: - `workflowDigest`, `execution`, `stage`, `instance`: the workflow digest and three ids; - `publishedDialectDigest`, `bindingDocumentDigest`, `boundDialectDigest`: digests, with only @@ -262,18 +277,48 @@ digest binds the whole offered set, selected or not. Failed instances contribute but remain in the inventory. The set is closed only after every expected instance is terminal; it is never presented as complete discovery. Every stage, even one with no instances, has a set. +A **stage summary** is one closed record per stage and execution, exactly +`{summaryVersion: 1, workflowDigest, execution, stage, records, recordsDigest, byKind, byOutcome, +byReason, byRule}`. The first member is the integer `1`; the next three identify the workflow, +execution and stage; and `records` is the number of output records in that stage's recorded set, +including zero for a blocked stage. +The remaining members are: + +- `recordsDigest`: SHA-256 of the exact bytes `canon([D1, D2, …, Dn])`, prefixed `sha256:`, where + each `Di` is the decoded Core §8.3 disposition from the successful attempt for the corresponding + output in closed-set order. Repeated dispositions are repeated. The covered bytes are exactly + the opening bracket, every disposition in `canon` form separated by commas, and the closing + bracket, with no whitespace or final newline; for no records they are exactly `[]`. +- `byKind`: exactly `{outcome, not-applicable, unresolved}`, with a nonnegative count for each. +- `byOutcome`: an object whose members are exactly the outcome ids observed on `outcome` records + and whose positive integer values count them; it is `{}` when none were observed. +- `byReason`: an object whose members are exactly the distinct Core reason values on `unresolved` + records and whose positive integer values count records carrying each reason. One record can + increment several reasons; it is `{}` when no unresolved reason was observed. +- `byRule`: an object with exactly one member for each member of `byOutcome`. Its value is exactly + `{contributors, fallback}`: `contributors` maps each observed `contributingRule` id for that + outcome to its positive count, and `fallback` is the nonnegative count of records for which the + conditional member is absent. A forcing exception id is counted as the contributing id recorded + by the envelope. Contributor members with zero counts are omitted. + +All counters use §1's integer domain. `records`, the three `byKind` counts and the sum of +`byOutcome` are equal. For each outcome, its contributor counts plus `fallback` equal its +`byOutcome` count. Object member order in retained bytes follows `canon`; no display order or +implementation map order is significant. + ## 6. Selection records A selection is exactly `{selectionVersion: 1, workflowDigest, execution, upstreamStage, outputSetDigest, selected, mode, revision}` and the conditional members below. `execution` and `upstreamStage` are ids; both digest members are digests. `selected` contains 0 to `limits.maxRecords` distinct output-envelope digests, all in that closed set, in upstream order. -`revision` is an integer from 1 to 16. Revisions increase by one for each replacement selection -of the same execution and upstream stage; the first is 1. The bytes of each revision are retained. +`revision` is an integer from 1 to 16. Revisions increase by one for each replacement of the same +execution, upstream stage and role, where sample and consuming selections are separate roles; the +first of each role is 1. The bytes of each revision are retained. -`upstreamStage` names the producing stage whose outputs are offered. `mode` equals that stage's -`selection.mode`. In disposition mode the additional member `rule` equals that stage's -`selection.rule`, and `selected` is exactly every output whose evaluated case's recorded +`upstreamStage` names the producing stage whose outputs are offered. `mode` is `sample`, or it +equals that stage's `selection.mode`. In disposition mode the additional member `rule` equals that +stage's `selection.rule`, and `selected` is exactly every output whose evaluated case's recorded disposition satisfies it. A dependent stage consumes this selection through `input.fromStage` and `input.selected` (§3). In reviewer mode the additional member `review` is exactly `{actor, assertion, at}`: an @@ -283,17 +328,38 @@ real person's identity or intent. An empty selection is explicit, never inferred The conditional member `refusal`, when present, is exactly `"limit-exceeded"`: the selection was refused at admission (§8), remains unconsumed, and blocks its consumers for that recorded reason. +In sample mode the additional members are `seed`, `size` and `algorithm`; `review`, `rule` and +`refusal` are absent. The stage MUST carry §3's `sample`. `seed` is exactly 64 lowercase +hexadecimal characters. `size` is an integer from 1 through that stage's `sample.maxSize`, and +`algorithm` is exactly `"sha256-rank-v1"`. Partition the offered output records by the outcome id +in their Core disposition, excluding `not-applicable` and `unresolved`. Within each partition, +rank a record by the unsigned 32-byte SHA-256 value of +`UTF8(seed) || 0x00 || UTF8(recordDigest)`, least first, breaking an otherwise equal rank by the +record digest's code-point order. Take the first `min(size, partition length)` records from every +partition, then write `selected` in their original upstream order. Thus the seed, size, offered +set and recorded dispositions determine the sample without an implementation random-number +generator. + +A writer refuses a sample request whose size exceeds the stage's `maxSize` as `input-over-bound` +before writing a selection, and refuses a seed of any other form as `input-malformed`. A verifier +gives the same refusals for a supplied sample record, including an empty, missing or oversized +seed. These are report refusals, not `refusal` members on an otherwise valid sample record. + Once consumed by a downstream admission, a selection is immutable. A change writes a new revision, with a new digest, and a new branch. All consumers within a branch use the same selection of an upstream stage. Existing children remain attached to their original selection. Branches share an execution id and unchanged ancestor instances; an execution record closes exactly one branch, identified by its ordered selection digests, without overwriting earlier branch records. Changed downstream output sets require new selections before any further children are admitted. +A sample is never consumed and does not open a workflow branch. Replacing one writes a new sample +revision and an execution record naming that digest; earlier sample bytes and execution records +remain unchanged. ## 7. Execution records, instances and attempts An execution record is exactly `{executionVersion: 1, workflowDigest, execution, rootCaseDigest, -frozen, instances, outputSets, selections}`. The digest members are digests and `execution` an id. +frozen, instances, outputSets, summaries, selections, samples}`. The digest members are digests +and `execution` an id. `frozen` has one entry per stage in document order, exactly `{stage, publishedDialectDigest, bindingDocumentDigest, boundDialectDigest, packDigest, releaseDigest, runtimeDigest}`. These are an id and digests, with only the binding nullable under §4. Retain the named documents. The @@ -379,13 +445,16 @@ Existing run exports and their result members are unchanged by this workflow sid `outputSets` has one entry per stage in stage order, exactly `{stage, status, records}`. `status` is `closed` or `blocked`; `records` is the digest array of §5. Its closed-set digest is derived -using the execution's workflow digest and id. `selections` has one entry per stage in that same -order: a selection digest, or null. A stage with consumers may lack a selection only when the +using the execution's workflow digest and id. `summaries` has one stage-summary digest per stage +in stage order, including the empty summary for a blocked stage. `selections` has one entry per +stage in that same order: a consuming selection digest, or null. `samples` likewise has one entry +per stage: its sample-selection digest exactly when the stage declares `sample`, otherwise null. +A stage with consumers may lack a consuming selection only when the branch ends before review or selection completes. Its consumers are `blocked`, with no instances, outputs or selection, transitively; a selection carrying `refusal` blocks them in the same way, with its digest retained in `selections`. A stage with a consumed upstream selection is `closed`, including an empty selection yielding zero instances. Root is closed. Terminal stages have null -selections. +consuming selections. Sample selections never affect closure, admission or completion. For each non-blocked dependent stage, reconstruct one expected instance for every selected parent. Missing, extra, repeated or foreign-parent instances are findings. A consumed selection @@ -405,7 +474,9 @@ bound without partial admission. A successful selection enters the execution's ` atomically with reservation of all its direct consumers' children. A bound refusal instead writes a selection record carrying `refusal: "limit-exceeded"` and retains its digest there, without reserving children. It is unconsumed and its consumers are blocked. The inventory -survives interruption or cancellation. +survives interruption or cancellation. Every persisted or resumed execution carries the complete +`summaries` and `samples` arrays and every digest already assigned to them; recovery does not +reconstruct a different seed, sample or summary from mutable state. A retried acquisition is a new recorded attempt under the same instance, with new retained responses and receipts, never an offline replay. Uncertain acquisitions are not retried @@ -418,8 +489,8 @@ recomputes derivations over retained answers and never calls a model or a source admission is a writer rule; an offline reader checks the resulting inventory, not the history of every side effect. There is no end-to-end exactly-once claim. -The execution record commits outputs and selections without a digest cycle. It is unsigned in -this version. Existing audit signatures cover their own audit bytes and preparation bindings, +The execution record commits outputs, summaries and selections without a digest cycle. It is +unsigned in this version. Existing audit signatures cover their own audit bytes and preparation bindings, not these new envelopes or which published dialect and binding produced the preparation. An independently held execution digest can expose subsequent replacement, but this version specifies no authenticated execution commitment. The Runner's journal is unsigned and unchained @@ -428,7 +499,8 @@ specifies no authenticated execution commitment. The Runner's journal is unsigne ## 9. The workflow verifier The `workflow-verifier` reads the workflow, its expected digest, one execution record and its -expected digest, and all referenced current-branch artifacts. Other branches and earlier selection +expected digest, and all referenced current-branch artifacts, including summaries and samples. +Other branches and earlier selection revisions are retained by the writer but not traversed. The reader checks the chosen revision's form; it cannot establish an unprovided revision history or enforce transactional immutability. The runtime executable is supplied separately only for optional replay; this reader compares the @@ -466,8 +538,8 @@ is none, the first preparation finding maps to `preparation-mismatch`, including preparation passes do the checks below run. Influence uses the recomputed lineage, never the unchecked retained lineage. No freshness check substitutes today's clock. -Then perform checks 1 to 4 in order, stopping at the first finding. Within each check use stage -order, then instance and attempt order; finish one instance's listed clauses before the next. +Then perform checks 1 to 5 in order, stopping at the first finding or refusal. Within each check +use stage order, then instance and attempt order; finish one instance's listed clauses before the next. Within check 1, workflow and execution pins come first, then frozen stage pins, then instance artifacts; within an instance, `digest-mismatch` precedes `stage-binding-mismatch`. Within check 3, an earlier instance's `influence-mismatch` precedes a later one's `case-mismatch`. @@ -482,7 +554,9 @@ check 3, an earlier instance's `influence-mismatch` precedes a later one's `case give `stage-binding-mismatch`. A workflow runner refuses to freeze a stage with incompatible research profiles, so no instance is admitted (§4). 2. Each selection's full offered set, revision and exact ordered membership; recompute the closed - disposition predicate against the retained Core disposition (`selection-mismatch`). + disposition predicate against the retained Core disposition (`selection-mismatch`). Redraw each + sample from its seed, size and offered set. A different ordered membership is a + `sample-mismatch` refusal, not a plausible alternative sample. 3. Every projected case against its parent and bindings, including expected projection failures (`case-mismatch`), then inherited classes, influence and explicit admission (`influence-mismatch`). @@ -490,6 +564,11 @@ check 3, an earlier instance's `influence-mismatch` precedes a later one's `case deterministic bound accounting and stage closure (`inventory-mismatch`); then re-extract all outputs, compare payload bytes, order, complete sets, parent/selection references, relation and lineage pins (`output-mismatch`). Neither failure nor missing data becomes `[]`. +5. From those checked output records and their retained dispositions, recompute every stage + summary, including all counters and the exact `recordsDigest` bytes of §5. A well-formed but + unequal recorded summary is a `summary-mismatch` refusal. Missing, extra and malformed summary + references have the earlier reference-phase answer. The supplied summary is never trusted as + the source for this computation. Predicate checking establishes agreement with the recorded disposition, not its recomputation. Runtime replay and verification of signatures or checkpoints are separate checks; this class @@ -500,19 +579,69 @@ A refusal is `{result: "refusal", refusal: C}` and a finding is `{result: "findi C one code above. A reading is exactly `{result: "reading", workflowDigest, executionDigest, consistency: "agrees", completion, preparations: "agree", authentication: "not-established", dispositions: "not-replayed", counts}`. `completion` is `complete` or `partial` by §7; `counts` -is `{stages, instances, outputs, selections}`, nonnegative integers within §2's bounds (selections -at most stages). A partial reading is consistency of an explicitly incomplete execution, not +is `{stages, instances, outputs, selections, summaries, samples}`, nonnegative integers within +§2's bounds (selections, summaries and samples at most stages). A partial reading is consistency +of an explicitly incomplete execution, not successful completion. Reports are at most 4096 bytes. A program exits 0 for a reading, 1 for a refusal, 2 for a finding, and 3 with no report when it could not run, including an existing file that cannot be read or an invalid command argument. A missing pinned artifact is instead the `inventory-mismatch` refusal above. A library exposes the same outcomes in its own idiom. +The verifier also exposes a **drift computation** over two bundles only after each bundle has its +own reading. Their `workflowDigest` values MUST be equal; otherwise it gives +`{result: "refusal", refusal: "drift-workflow-mismatch"}`. For each stage it subtracts the left +summary's `byKind`, `byOutcome`, `byReason` and `byRule` counters from the right summary's counters, +treating an absent map member as zero, and reports the signed deltas. It then matches output +records by `(stage, payloadDigest, occurrence)`, where `occurrence` starts at 1 among equal payload +digests in each stage's closed-set order. For each matched key whose canonical disposition differs +it reports the left and right dispositions; an unmatched key is reported as added or removed with +the absent side null. + +The drift result is exactly `{result: "drift", workflowDigest, leftExecutionDigest, +rightExecutionDigest, stages, records}`. `stages` is in workflow order and carries those counter +deltas; `records` is in stage order, then payload-digest and occurrence order, and carries only +changed, added or removed keys. Empty differences are explicit empty arrays. + +A stage-delta row is exactly `{stage, records, byKind, byOutcome, byReason, byRule}`. It occurs +once for every workflow stage, including a stage with only zero deltas. Its members are closed: + +| Member | Type and bound | +| --- | --- | +| `stage` | that stage's id | +| `records` | right summary `records` minus left, an integer from -4096 through 4096 | +| `byKind` | exactly `{outcome, not-applicable, unresolved}`, each the corresponding signed delta in that range | +| `byOutcome` | outcome-id members whose values are nonzero signed deltas in that range; absent on both sides is zero | +| `byReason` | Core-reason members whose values are nonzero signed deltas in that range; absent on both sides is zero | +| `byRule` | outcome-id members whose values are exactly `{contributors, fallback}` | + +Each `byRule` `contributors` object has contributing-rule-id members whose values are nonzero +signed deltas from -4096 through 4096; absent contributors are zero. `fallback` is the signed +fallback delta in that range and is present even when zero. An outcome row is present exactly +when at least one contributor or fallback delta is nonzero. Thus no zero dynamic member or open +row can give an alternative representation of the same stage delta. The stage array has 1 to 32 +rows; each dynamic object has at most 4096 members, subject to §2's smaller execution bounds. + +A record-delta row is exactly one of the following closed forms; `stage` is an id, +`payloadDigest` a digest and `occurrence` an integer from 1 through 4096: + +| Change | Exact row | Meaning | +| --- | --- | --- | +| changed | `{change: "changed", stage, payloadDigest, occurrence, left, right}` | `left` and `right` are the unequal Core §8.3 dispositions | +| added | `{change: "added", stage, payloadDigest, occurrence, left: null, right}` | no left output has that matching key; `right` is the added disposition | +| removed | `{change: "removed", stage, payloadDigest, occurrence, left, right: null}` | `left` is the removed disposition; no right output has that matching key | + +Null means only that the named side has no output at the matching key; it is never an unknown or +missing disposition. A non-null side is one complete, closed Core disposition. The records array +has 0 to 4096 rows. The result is a +comparison of two separately verified bundles: it makes no judgment, verdict or authorization, +does not replay either disposition, and is not a workflow reading or finding. + A conforming reader gives these answers for every admitted input and all fixtures published with the specification version its claim names. Its claim names `workflow-verifier`, workflow version 1, that `specVersion`, and the dialect versions, kinds and shapes the published fixtures exercise. This is a reader class, not an executor, preparation writer or Core conformance class; -none implies another. No claim is available while this text is Draft or until such fixtures are -published. No fixtures or vectors have been recorded for it. +none implies another. No claim is available while this text is Draft. The §11 contract fixture +tests these closed forms but is not an implementation recording or a conformance corpus. ## 10. What a workflow establishes @@ -525,10 +654,21 @@ model answers, which model answered, or exactly-once execution. Digest pins esta identity, not currency. Generated-only admission checks a profile's declared class. The installation's classification of what its channel actually serves remains a trust assumption; a reading cannot detect an undeclared model behind a record-class channel (§4; dialect §5.1). +Summary recomputation establishes that the bulk counts agree with the retained output envelopes +and dispositions. Because this reader does not replay dispositions, it does not independently +establish that a recorded `contributingRule` condition was true; that remains part of the retained +evaluation evidence and any separate Runtime replay. + +## 11. The two-stage fixture -## 11. The two-stage fixture to record +The hand-written contract fixture at `conformance/workflow/` records `workflow.json`, both +verified-bundle projections, every stage's output envelopes, dispositions and summaries, one +seeded sample, and the expected drift result. Its manifest also names the oversized-size, +malformed-seed and unequal-summary refusal vectors. The workflow schema at +`schema/workflow-1.schema.json` closes every form. These records test bytes and structure; they +are not captured executor output and establish no reader claim. -Nothing in this section is recorded yet. The first fixture is a run from the Desk's research +The first full run fixture is from the Desk's research line using two version-3 unbound `operation` dialects, bound by the Runner at import. This remains the acceptance construction for issue #160's condition 2. An additional research-stage recording follows fixture 8's `selected-parent-child` plan in @@ -559,7 +699,19 @@ a workflow executor and verifier; those are not established by single-stage prep - `result` is `people`. A reviewer selects the first and third organisation in upstream order; one child returns two synthetic people objects, the other an empty array. The inventory holds three instances, one `completed-zero`, five output records and the complete three-record - offered set. The relation says which organisation was researched, not that employment is true. + offered set. Every output carries its rule or forced-exception contributor, with fallback and + non-outcome cases omitting it. The relation says which organisation was researched, not that + employment is true. + +The recording carries one summary for each stage and one seeded sample for `organisations`. Both +stage sample configurations use the same `maxSize`; the sample record uses a fixed hand-written +seed and size and names `sha256-rank-v1`, and its selected array is computed independently from +the recorded inputs. +The primary summaries carry the closed member forms, contributor and fallback counts, and +independently fixed `recordsDigest` values over the ordered dispositions. The fixture variants +collectively carry every kind, outcome, unresolved reason and contributing-rule form. A second +bundle of the same workflow and its independently fixed expected drift result exercise summary +deltas and changed, added and removed per-record rows without assigning a verdict. A recording carries exactly one branch: one set per stage and only the selection revision that branch names. Other revisions and branches are retained separately. Its directory contains the @@ -574,13 +726,15 @@ bounds above, not literal path characters: `evaluation-error.json` under that instance; artifacts, including the case, are absent exactly when their pins are null; - `outputs////payload.json` and `record.json`; -- `selections//.json`, `sets/.json`, `expected-report.json`, `trust.json` +- `selections//.json`, `samples//.json`, + `sets/.json`, `summaries/.json`, `expected-report.json`, `trust.json` (declared public test trust material) and `README.md` (recording commands and independent pins). Each `.sha256` file holds one digest and a newline; `verified-at.txt` holds the preparation's timestamp and a newline. The preparation's own `verifiedAt` and digest computed from its bytes govern; these side files must agree and never override them. `workflow.json`, `execution.json`, -selection files, set files and output `record.json` hold their `canon` bytes, with no newline; +selection, sample, set and summary files and output `record.json` hold their `canon` bytes, with +no newline; so do `dialect.json`, `binding.json`, `bound-dialect.json` and `profiles.json` in their own forms. `root-case.json`, `case.json`, `payload.json` and `preparation.json` hold the dialect §9.5 bytes, with no newline. `pack.json`, `sources.json`, `release.json`, `run.json`, `audit.json` and @@ -611,4 +765,16 @@ refusal, and retries before and after consumption. Freeze expected reports with Add integer and named family-3 variants with matching published identities, and refuse changed selectors, identity-stripped bound documents, missing/tampered error envelopes, error envelopes attached to successful attempts and ordinary invocation errors mislabeled as Core errors. -This layout supplies no evidence of those runs today. +Add hand-written summary mutations for every counter, contributor, fallback and digest member, +and a hand-written twin mutation in the other reader's cases; each must give `summary-mismatch`. +Add sample mutations for the seed, size, algorithm and selected order; include empty, missing, +malformed and oversized seeds, a sample size one above the stage's `maxSize`, and a redrawn member +that differs by one record. The form cases give `input-malformed` or `input-over-bound` as §6 +requires, and the well-formed redraw mutation gives `sample-mismatch`. Interrupted and resumed +variants retain all summary and sample checkpoint members byte for byte. +For every new refusal code, retain a valid expected-report event and hand-write its twin for the +other reader. Include absent artifacts, empty objects or arrays, missing required members and an +object, array or string one above its applicable bound; the reference-phase refusal wins whenever +that malformed form precedes the semantic mutation. Compare the drift pair with a bundle of a +different workflow for `drift-workflow-mismatch` as well as with its matching workflow. +The hand-written contract fixture supplies no evidence of those full runs today. diff --git a/tests/test_repository.py b/tests/test_repository.py index 71cb816..38752e5 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1148,7 +1148,8 @@ def test_manifest_covers_every_fixture_once(self) -> None: path.relative_to(CONFORMANCE).as_posix() for path in CONFORMANCE.rglob("*.json") if not (path.name.endswith(".schema.json") or path.name in ("manifest.json", "cases.json")) - and path.relative_to(CONFORMANCE).parts[0] not in ("evaluation", "witness", "dialect", "policy") + and path.relative_to(CONFORMANCE).parts[0] + not in ("evaluation", "witness", "dialect", "policy", "workflow") } for path in sorted(listed - on_disk): @@ -1165,7 +1166,7 @@ def test_core_fixture_audit_keeps_orphans_and_unknown_namespaces_strict(self) -> named = root / "valid/named.json" named.parent.mkdir() named.write_text("{}") - for namespace in ("evaluation", "witness", "dialect", "policy"): + for namespace in ("evaluation", "witness", "dialect", "policy", "workflow"): owned = root / namespace / "owned.json" owned.parent.mkdir() owned.write_text("{}") diff --git a/tests/test_site.py b/tests/test_site.py index 685bf4d..c003419 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -23,7 +23,15 @@ # directory either (conformance/dialect/README.md). The companion document they belong to is # published. DIALECT_VECTORS = "conformance/dialect" -UNPUBLISHED_DIRECTORIES = (STAGED_EVALUATION_ROWS, WITNESS_VECTORS, DIALECT_VECTORS, "conformance/policy", "schema/policy") +WORKFLOW_VECTORS = "conformance/workflow" +UNPUBLISHED_DIRECTORIES = ( + STAGED_EVALUATION_ROWS, + WITNESS_VECTORS, + DIALECT_VECTORS, + WORKFLOW_VECTORS, + "conformance/policy", + "schema/policy", +) # The retired pre-publication brand, and the raw hosting domain its identifiers used, must not appear # anywhere the site publishes. The tokens are assembled from fragments deliberately: the repository @@ -683,6 +691,9 @@ def test_schemas_are_served_at_their_canonical_id_path(self) -> None: "schema/0.2.0-draft/judgment-pack-core.schema.json": ROOT / "schema" / "judgment-pack-core.schema.json", + "schema/0.2.0-draft/workflow-1.schema.json": ROOT + / "schema" + / "workflow-1.schema.json", # The superseded schemas keep the $id path each was published under, so an older pack stays # checkable and a previously published identifier does not 404. "schema/0.1.0-draft/judgment-pack-core.schema.json": ROOT diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 47e15a4..e9187f2 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -1,10 +1,53 @@ from __future__ import annotations +import hashlib +import json import unittest from pathlib import Path +import jsonschema + ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "schema" / "workflow-1.schema.json" +FIXTURE = ROOT / "conformance" / "workflow" + + +def canonical(value: object) -> bytes: + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + + +def digest(value: object) -> str: + return "sha256:" + hashlib.sha256(canonical(value)).hexdigest() + + +def redraw_sample(selection: dict[str, object]) -> list[str]: + bundle = json.loads((FIXTURE / "left-bundle.json").read_text(encoding="utf-8")) + stage = next( + row for row in bundle["stages"] if row["stage"] == selection["upstreamStage"] + ) + partitions: dict[str, list[tuple[bytes, str]]] = {} + for row in stage["records"]: + disposition = row["disposition"] + if disposition["kind"] != "outcome": + continue + record_digest = row["recordDigest"] + rank = hashlib.sha256( + selection["seed"].encode("utf-8") + + b"\x00" + + record_digest.encode("utf-8") + ).digest() + partitions.setdefault(disposition["outcomeId"], []).append((rank, record_digest)) + chosen = { + record_digest + for rows in partitions.values() + for _, record_digest in sorted(rows)[: selection["size"]] + } + return [ + row["recordDigest"] for row in stage["records"] if row["recordDigest"] in chosen + ] class WorkflowTextTests(unittest.TestCase): @@ -19,10 +62,10 @@ def test_the_rfcs_specification_is_the_companion_text(self) -> None: "a Draft. It is\n" "normative for nothing. Acceptance would place it beside Core, with the " "`workflow-verifier`\n" - "reader class defined below. No workflow implementation, recorded fixture or " - "conformance claim\n" - "exists. The two-stage run in §11 is a recording requirement, " - "not evidence already obtained.\n", + "reader class defined below. No workflow implementation or conformance claim " + "exists. Section 11's\n" + "hand-written contract fixture records the bulk forms; the full two-stage run\n" + "remains a recording requirement, not execution evidence already obtained.\n", preface, ) expected = ("## 1. " + numbered).rstrip("\n") @@ -33,6 +76,322 @@ def test_the_rfcs_specification_is_the_companion_text(self) -> None: ) self.assertEqual(expected, promoted) + def test_bulk_output_and_summary_forms_are_closed(self) -> None: + companion = (ROOT / "spec" / "workflow.md").read_text(encoding="utf-8") + section = companion.split("\n## 5. ", 1)[1].split("\n## 6. ", 1)[0] + unwrapped = section.replace("\n", " ") + self.assertIn( + "`{outputRecordVersion: 1, payloadDigest, contributingRule, lineage}`", + unwrapped, + ) + self.assertIn( + "`{summaryVersion: 1, workflowDigest, execution, stage, records, " + "recordsDigest, byKind, byOutcome, byReason, byRule}`", + unwrapped, + ) + self.assertIn("`canon([D1, D2, …, Dn])`", unwrapped) + self.assertIn("outside the Core §8.3 disposition", unwrapped) + self.assertIn("absent for a fallback outcome,", unwrapped) + self.assertIn("`not-applicable` and `unresolved`", unwrapped) + self.assertIn("`{contributors, fallback}`", unwrapped) + + def test_seeded_sample_form_and_algorithm_are_exact(self) -> None: + companion = (ROOT / "spec" / "workflow.md").read_text(encoding="utf-8") + section = companion.split("\n## 6. ", 1)[1].split("\n## 7. ", 1)[0] + self.assertIn("`{mode: \"sample\", maxSize: N}`", companion) + self.assertIn("`seed`, `size` and `algorithm`", section) + self.assertIn('`algorithm` is exactly `"sha256-rank-v1"`', section) + self.assertIn("`UTF8(seed) || 0x00 || UTF8(recordDigest)`", section) + self.assertIn("`input-over-bound`", section) + self.assertIn("`input-malformed`", section) + + def test_execution_carries_summaries_samples_and_resume_state(self) -> None: + companion = (ROOT / "spec" / "workflow.md").read_text(encoding="utf-8") + execution = companion.split("\n## 7. ", 1)[1].split("\n## 8. ", 1)[0] + recovery = companion.split("\n## 8. ", 1)[1].split("\n## 9. ", 1)[0] + unwrapped = execution.replace("\n", " ") + self.assertIn( + "`{executionVersion: 1, workflowDigest, execution, rootCaseDigest, " + "frozen, instances, outputSets, summaries, selections, samples}`", + unwrapped, + ) + self.assertIn("one stage-summary digest per stage", execution) + self.assertIn("sample-selection digest", execution) + self.assertIn( + "complete `summaries` and `samples` arrays", recovery.replace("\n", " ") + ) + + def test_verifier_recomputes_bulk_records_and_reports_drift(self) -> None: + companion = (ROOT / "spec" / "workflow.md").read_text(encoding="utf-8") + section = companion.split("\n## 9. ", 1)[1].split("\n## 10. ", 1)[0] + self.assertIn("`summary-mismatch`", section) + self.assertIn("`sample-mismatch`", section) + self.assertIn('refusal: "drift-workflow-mismatch"', section) + self.assertIn( + '`{result: "drift", workflowDigest, leftExecutionDigest,', section + ) + self.assertIn("**drift computation**", section) + self.assertIn("A stage-delta row is exactly", section) + self.assertIn("A record-delta row is exactly one of", section) + self.assertIn("left: null", section) + self.assertIn("right: null", section) + self.assertIn("no judgment, verdict or authorization", section) + + def test_two_stage_plan_carries_bulk_and_refusal_vectors(self) -> None: + companion = (ROOT / "spec" / "workflow.md").read_text(encoding="utf-8") + section = companion.split("\n## 11. ", 1)[1] + unwrapped = section.replace("\n", " ") + self.assertIn("one summary for each stage and one seeded sample", unwrapped) + self.assertIn("empty, missing, malformed and oversized seeds", unwrapped) + self.assertIn("a sample size one above the stage's `maxSize`", unwrapped) + self.assertIn("A second bundle of the same workflow", unwrapped) + self.assertIn("For every new refusal code, retain a valid expected-report", unwrapped) + self.assertIn("Interrupted and resumed variants retain", unwrapped) + + +class WorkflowFixtureTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + jsonschema.Draft202012Validator.check_schema(cls.schema) + cls.validator = jsonschema.Draft202012Validator( + cls.schema, format_checker=jsonschema.FormatChecker() + ) + cls.manifest = json.loads((FIXTURE / "manifest.json").read_text(encoding="utf-8")) + cls.validator.validate(cls.manifest) + + def test_every_object_schema_is_explicitly_closed(self) -> None: + def visit(value: object) -> None: + if isinstance(value, dict): + if value.get("type") == "object": + self.assertIs(value.get("additionalProperties"), False, value) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(self.schema) + + def test_every_fixture_file_has_its_recorded_schema_result(self) -> None: + paths = {entry["path"] for entry in self.manifest["files"]} + actual = { + str(path.relative_to(FIXTURE)) + for path in FIXTURE.rglob("*.json") + if path.name != "manifest.json" + } + self.assertEqual(paths, actual) + for entry in self.manifest["files"]: + with self.subTest(path=entry["path"]): + value = json.loads((FIXTURE / entry["path"]).read_text(encoding="utf-8")) + errors = list(self.validator.iter_errors(value)) + self.assertEqual(entry["schemaValid"], not errors) + + def test_recorded_summary_digests_cover_the_ordered_dispositions(self) -> None: + for name in ("left-bundle.json", "right-bundle.json"): + bundle = json.loads((FIXTURE / name).read_text(encoding="utf-8")) + for stage in bundle["stages"]: + with self.subTest(bundle=name, stage=stage["stage"]): + dispositions = [row["disposition"] for row in stage["records"]] + self.assertEqual(stage["summary"]["recordsDigest"], digest(dispositions)) + + def test_recorded_summaries_are_recomputed_from_output_rows(self) -> None: + for name in ("left-bundle.json", "right-bundle.json"): + bundle = json.loads((FIXTURE / name).read_text(encoding="utf-8")) + for stage in bundle["stages"]: + by_kind = {"outcome": 0, "not-applicable": 0, "unresolved": 0} + by_outcome: dict[str, int] = {} + by_reason: dict[str, int] = {} + by_rule: dict[str, dict[str, object]] = {} + for row in stage["records"]: + disposition = row["disposition"] + by_kind[disposition["kind"]] += 1 + for reason in disposition["reasons"]: + if disposition["kind"] == "unresolved": + by_reason[reason] = by_reason.get(reason, 0) + 1 + if disposition["kind"] == "outcome": + outcome = disposition["outcomeId"] + by_outcome[outcome] = by_outcome.get(outcome, 0) + 1 + counts = by_rule.setdefault( + outcome, {"contributors": {}, "fallback": 0} + ) + contributor = row["output"].get("contributingRule") + if contributor is None: + counts["fallback"] = int(counts["fallback"]) + 1 + else: + contributors = counts["contributors"] + assert isinstance(contributors, dict) + contributors[contributor] = contributors.get(contributor, 0) + 1 + summary = stage["summary"] + self.assertEqual(summary["records"], len(stage["records"])) + self.assertEqual(summary["byKind"], by_kind) + self.assertEqual(summary["byOutcome"], by_outcome) + self.assertEqual(summary["byReason"], by_reason) + self.assertEqual(summary["byRule"], by_rule) + + def test_recorded_summary_mismatch_is_caught_by_recomputation(self) -> None: + vector = json.loads( + (FIXTURE / "vectors" / "summary-mismatch.json").read_text(encoding="utf-8") + ) + self.assertEqual(vector["expectedRefusal"], "summary-mismatch") + self.assertNotEqual(vector["summary"]["recordsDigest"], digest(vector["dispositions"])) + + def test_sample_refusal_vectors_cover_bound_and_seed_form(self) -> None: + vector_names = ( + "sample-seed-empty.json", + "sample-seed-invalid.json", + "sample-seed-missing.json", + "sample-seed-oversized.json", + "sample-size-over-bound.json", + ) + vectors = { + name: json.loads((FIXTURE / "vectors" / name).read_text(encoding="utf-8")) + for name in vector_names + } + size = vectors["sample-size-over-bound.json"] + seed = vectors["sample-seed-invalid.json"] + self.assertEqual(size["expectedRefusal"], "input-over-bound") + self.assertGreater(size["selection"]["size"], size["stageMaxSize"]) + self.assertEqual(seed["expectedRefusal"], "input-malformed") + self.assertNotRegex(seed["selection"]["seed"], r"^[0-9a-f]{64}$") + for name in ("sample-seed-empty.json", "sample-seed-missing.json", "sample-seed-oversized.json"): + with self.subTest(name=name): + vector = vectors[name] + self.assertEqual(vector["expectedRefusal"], "input-malformed") + self.assertTrue(list(self.validator.iter_errors(vector))) + + for name, vector in vectors.items(): + with self.subTest(repaired=name): + repaired = dict(vector["selection"]) + if name == "sample-size-over-bound.json": + repaired["size"] = vector["stageMaxSize"] + else: + repaired["seed"] = "0" * 64 + self.validator.validate(repaired) + self.assertLessEqual(repaired["size"], vector["stageMaxSize"]) + self.assertEqual(repaired["selected"], redraw_sample(repaired)) + + def test_recorded_sample_is_redrawn_from_seed(self) -> None: + sample = json.loads((FIXTURE / "sample.json").read_text(encoding="utf-8")) + self.assertEqual(sample["selected"], redraw_sample(sample)) + + def test_drift_is_recomputed_from_the_two_recorded_bundles(self) -> None: + left = json.loads((FIXTURE / "left-bundle.json").read_text(encoding="utf-8")) + right = json.loads((FIXTURE / "right-bundle.json").read_text(encoding="utf-8")) + recorded = json.loads((FIXTURE / "drift.json").read_text(encoding="utf-8")) + stage_rows = [] + record_rows = [] + self.assertEqual(len(left["stages"]), len(right["stages"])) + for left_stage, right_stage in zip(left["stages"], right["stages"]): + left_summary, right_summary = left_stage["summary"], right_stage["summary"] + dynamic = {} + for member in ("byOutcome", "byReason"): + dynamic[member] = { + key: right_summary[member].get(key, 0) - left_summary[member].get(key, 0) + for key in sorted(set(left_summary[member]) | set(right_summary[member])) + if right_summary[member].get(key, 0) != left_summary[member].get(key, 0) + } + by_rule = {} + for outcome in sorted(set(left_summary["byRule"]) | set(right_summary["byRule"])): + left_rule = left_summary["byRule"].get(outcome, {"contributors": {}, "fallback": 0}) + right_rule = right_summary["byRule"].get(outcome, {"contributors": {}, "fallback": 0}) + contributors = { + key: right_rule["contributors"].get(key, 0) + - left_rule["contributors"].get(key, 0) + for key in sorted( + set(left_rule["contributors"]) | set(right_rule["contributors"]) + ) + if right_rule["contributors"].get(key, 0) + != left_rule["contributors"].get(key, 0) + } + fallback = right_rule["fallback"] - left_rule["fallback"] + if contributors or fallback: + by_rule[outcome] = {"contributors": contributors, "fallback": fallback} + stage_rows.append( + { + "stage": left_stage["stage"], + "records": right_summary["records"] - left_summary["records"], + "byKind": { + key: right_summary["byKind"][key] - left_summary["byKind"][key] + for key in ("outcome", "not-applicable", "unresolved") + }, + "byOutcome": dynamic["byOutcome"], + "byReason": dynamic["byReason"], + "byRule": by_rule, + } + ) + + def keyed(stage: dict[str, object]) -> dict[tuple[str, int], dict[str, object]]: + counts: dict[str, int] = {} + rows = {} + for row in stage["records"]: # type: ignore[index] + payload = row["output"]["payloadDigest"] + counts[payload] = counts.get(payload, 0) + 1 + rows[(payload, counts[payload])] = row["disposition"] + return rows + + left_records, right_records = keyed(left_stage), keyed(right_stage) + for payload, occurrence in sorted(set(left_records) | set(right_records)): + left_disposition = left_records.get((payload, occurrence)) + right_disposition = right_records.get((payload, occurrence)) + if left_disposition == right_disposition: + continue + change = "added" if left_disposition is None else "removed" if right_disposition is None else "changed" + record_rows.append( + { + "change": change, + "stage": left_stage["stage"], + "payloadDigest": payload, + "occurrence": occurrence, + "left": left_disposition, + "right": right_disposition, + } + ) + self.assertEqual(recorded["stages"], stage_rows) + self.assertEqual(recorded["records"], record_rows) + + def test_refusal_vectors_have_closed_expected_reports(self) -> None: + cases = { + "sample-size-over-bound": "input-over-bound", + "sample-seed-invalid": "input-malformed", + "summary-mismatch": "summary-mismatch", + } + for name, refusal in cases.items(): + with self.subTest(name=name): + report = json.loads((FIXTURE / "reports" / f"{name}.json").read_text(encoding="utf-8")) + self.assertEqual(report, {"result": "refusal", "refusal": refusal}) + self.validator.validate(report) + + def test_fixture_carries_each_bulk_contract_form(self) -> None: + left = json.loads((FIXTURE / "left-bundle.json").read_text(encoding="utf-8")) + right = json.loads((FIXTURE / "right-bundle.json").read_text(encoding="utf-8")) + outputs = [ + row["output"] + for bundle in (left, right) + for stage in bundle["stages"] + for row in stage["records"] + ] + self.assertTrue(any("contributingRule" in output for output in outputs)) + self.assertTrue(any("contributingRule" not in output for output in outputs)) + for bundle in (left, right): + for stage in bundle["stages"]: + self.assertEqual( + { + "summaryVersion", "workflowDigest", "execution", "stage", "records", + "recordsDigest", "byKind", "byOutcome", "byReason", "byRule", + }, + set(stage["summary"]), + ) + drift = json.loads((FIXTURE / "drift.json").read_text(encoding="utf-8")) + self.assertEqual({"changed", "added", "removed"}, {row["change"] for row in drift["records"]}) + self.assertEqual( + {"reading", "finding", "refusal"}, + { + json.loads(path.read_text(encoding="utf-8"))["result"] + for path in (FIXTURE / "reading.json", FIXTURE / "finding.json", FIXTURE / "reports" / "summary-mismatch.json") + }, + ) + if __name__ == "__main__": unittest.main() diff --git a/web/build.py b/web/build.py index 71657ff..40d3af6 100644 --- a/web/build.py +++ b/web/build.py @@ -2333,6 +2333,7 @@ def build_license(output_root: Path) -> None: PurePosixPath("conformance/evaluation/staged"), PurePosixPath("conformance/witness"), PurePosixPath("conformance/dialect"), + PurePosixPath("conformance/workflow"), PurePosixPath("conformance/policy"), PurePosixPath("schema/policy"), ) @@ -2545,6 +2546,7 @@ def build_not_found(output_root: Path) -> None: CANONICAL_SCHEMA_SOURCES = ( "schema/judgment-pack-core.schema.json", + "schema/workflow-1.schema.json", # The superseded schemas stay served at the $id each was published under, so a pack or manifest that # still declares the older exact specVersion remains checkable and a cited identifier keeps resolving. "schema/judgment-pack-core-0.1.0-draft.schema.json",