diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index df04f04..51ca305 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -99,7 +99,7 @@ jobs: RELEASE_MANIFEST_SHA256: ${{ inputs.release_manifest_sha256 }} run: | set -euo pipefail - [[ "$VERSION" =~ ^0\.1\.0(-rc\.[1-9][0-9]*)?$ ]] + [[ "$VERSION" =~ ^0\.1\.1(-rc\.[1-9][0-9]*)?$ ]] [[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] [[ "$PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ]] [[ "$CHECKSUMS_SHA256" =~ ^[0-9a-f]{64}$ ]] diff --git a/.seal/reviews/cr-12aiik/events.jsonl b/.seal/reviews/cr-12aiik/events.jsonl new file mode 100644 index 0000000..630e805 --- /dev/null +++ b/.seal/reviews/cr-12aiik/events.jsonl @@ -0,0 +1,12 @@ +{"ts":"2026-09-10T23:35:16.425498439Z","author":"sigil-dev","event":"ReviewCreated","data":{"review_id":"cr-12aiik","jj_change_id":"detached:cdc8280e5c937382c8f3d5f7d46c8a1ad635a719","scm_kind":"git","scm_anchor":"detached:cdc8280e5c937382c8f3d5f7d46c8a1ad635a719","initial_commit":"cdc8280e5c937382c8f3d5f7d46c8a1ad635a719","base_commit":"d4271d81cdca7c87836fa6af497f111adf0d3dff","title":"bn-27oc/bn-gjo0/bn-31f0: compatible Temporal candidate and bounded Lua companion","description":"risk:medium. Whole persisted range d4271d81..cdc8280 (three commits) in Temporal bn-gjo0. Unpublished0.1.1 manifest minimumSigil>=0.35.0/exactAPI1.3; closed pack/publisher family with immutable existing artifacts and frozen client/WIT identities; canonical writer compression parity and multiblock regression; opt-in bounded project Lua polling/history/result/single JSON helper. No runtime WIT/proto/client authority changes. Both branches individually full just check PASS; integrated check/conformance/pack and real pinned-host decoder repeats underway independently. RPC fixtures are offline, JSON scenario real ordinary host with accepted official0.1.0 lock. Review callback/exception/error precedence, no Start retry, input/result bounds and plain-table safety, raw bytes/numeric preservation, exact compatibility/publisher gates and truthful evidence. Local candidate is not provenanced release or CAPI acceptance. Read Sigil security protocol at /home/bob/src/sigil/.agents/edict/security-review.md; only this Temporal target in scope."}} +{"ts":"2026-09-10T23:35:16.425579711Z","author":"sigil-dev","event":"ReviewersRequested","data":{"review_id":"cr-12aiik","reviewers":["sigil-security"]}} +{"ts":"2026-09-10T23:41:35.264688668Z","author":"sigil-security","event":"ThreadCreated","data":{"thread_id":"th-2c3jcs","review_id":"cr-12aiik","file_path":"README.md","selection":{"type":"Line","line":183},"commit_hash":"cdc8280e5c937382c8f3d5f7d46c8a1ad635a719"}} +{"ts":"2026-09-10T23:41:35.264714647Z","author":"sigil-security","event":"CommentAdded","data":{"comment_id":"th-2c3jcs.1","thread_id":"th-2c3jcs","body":"[low] Release-family documentation contradicts the exact immutable gates. This paragraph says 0.1.0-rc.N identifies release candidates, but plugin.toml, release-pack.py, publish-release.yml, CHANGELOG.md, RELEASING.md, and README.md:233-237 admit only the unpublished 0.1.1 / 0.1.1-rc.N family and intentionally reject historical 0.1.0 identities. The machine gates fail closed, so this is not a publisher bypass, but an operator following this section receives the wrong exact candidate identity. Change only the package candidate reference here to 0.1.1-rc.N while retaining the frozen WIT and Start identity at 0.1.0."}} +{"ts":"2026-09-10T23:41:35.482102844Z","author":"sigil-security","event":"ReviewerVoted","data":{"review_id":"cr-12aiik","vote":"block","reason":"[low] README.md:183 retains the historical 0.1.0-rc.N candidate family and contradicts the reviewed 0.1.1-only packer/publisher gates; correct the package candidate reference without changing frozen WIT/Start identity.","target_commit":"cdc8280e5c937382c8f3d5f7d46c8a1ad635a719"}} +{"ts":"2026-09-10T23:44:49.553132297Z","author":"sigil-dev","event":"CommentAdded","data":{"comment_id":"th-2c3jcs.2","thread_id":"th-2c3jcs","body":"Fixed in53cd07797dd8b4d2244fcffb254a3b662de1e243: the active candidate paragraph now names0.1.1-rc.N. Historical release evidence and frozen WIT/Start identity0.1.0 remain unchanged. Only one README line changed after prior target; full pinned-host just check rerun PASS. Please verify and resolve in rereview."}} +{"ts":"2026-09-10T23:44:49.607903183Z","author":"sigil-dev","event":"ReviewRetargeted","data":{"review_id":"cr-12aiik","old_scm_anchor":"detached:cdc8280e5c937382c8f3d5f7d46c8a1ad635a719","new_scm_anchor":"detached:53cd07797dd8b4d2244fcffb254a3b662de1e243","old_target_commit":"cdc8280e5c937382c8f3d5f7d46c8a1ad635a719","new_target_commit":"53cd07797dd8b4d2244fcffb254a3b662de1e243"}} +{"ts":"2026-09-10T23:44:49.658965059Z","author":"sigil-dev","event":"ReviewersRequested","data":{"review_id":"cr-12aiik","reviewers":["sigil-security"]}} +{"ts":"2026-09-10T23:49:50.844992804Z","author":"sigil-security","event":"ThreadResolved","data":{"thread_id":"th-2c3jcs","reason":"Verified at 53cd07797dd8b4d2244fcffb254a3b662de1e243: README.md:183 now names 0.1.1-rc.N, matching the closed plugin.toml, release-pack.py, and publish-release.yml family while retaining frozen WIT, client, and Start identity 0.1.0."}} +{"ts":"2026-09-10T23:49:50.889303612Z","author":"sigil-security","event":"ReviewerVoted","data":{"review_id":"cr-12aiik","vote":"lgtm","reason":"Whole persisted d4271d81cdca7c87836fa6af497f111adf0d3dff..53cd07797dd8b4d2244fcffb254a3b662de1e243 (4 commits; tree 161a25d15be6e39025762754a39ad5818f1bf56a) statically reviewed across all 16 changed paths; th-2c3jcs fix verified. Failure modes fail closed through exact 0.1.1 family, manifest/API/WIT identity gates, single-shot Start, and bounded nonretrying History. Edge cases cover plain-table/metatable/cycle/sparse shapes, callback snapshots, exact raw bytes/numbers, and node/page/event/byte/token exhaustion. Rollback is reverting this unpublished four-commit range before publication; historical 0.1.0 artifacts are not overwritten. Monitoring remains ordinary per-operation Sigil traces plus namespaced helper exhaustion without payload logging. Supplied integrated Lua/release/component/real-host results corroborate the source review only. LGTM is commit-scoped static approval, not provenance, CAPI, future-native, publication, or release qualification.","target_commit":"53cd07797dd8b4d2244fcffb254a3b662de1e243"}} +{"ts":"2026-09-10T23:49:50.924643495Z","author":"sigil-security","event":"ReviewApproved","data":{"review_id":"cr-12aiik","target_commit":"53cd07797dd8b4d2244fcffb254a3b662de1e243"}} +{"ts":"2026-09-10T23:51:27.796889182Z","author":"sigil-dev","event":"ReviewMerged","data":{"review_id":"cr-12aiik","final_commit":"53cd07797dd8b4d2244fcffb254a3b662de1e243"}} diff --git a/.seal/reviews/cr-38eiua/events.jsonl b/.seal/reviews/cr-38eiua/events.jsonl new file mode 100644 index 0000000..f1073f3 --- /dev/null +++ b/.seal/reviews/cr-38eiua/events.jsonl @@ -0,0 +1,12 @@ +{"ts":"2026-09-10T22:48:44.346115390Z","author":"sigil-dev","event":"ReviewCreated","data":{"review_id":"cr-38eiua","jj_change_id":"detached:15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12","scm_kind":"git","scm_anchor":"detached:15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12","initial_commit":"15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12","base_commit":"effb9cb0022ef80583794d723ac6149f452fedb2","title":"bn-1ecg and bn-q4yp: honest Start semantics and tested operator contract","description":"Two commits, docs and conformance only. Clarify proto3 started ambiguity without changing WIT or runtime; independently generated false/omitted wire fixtures; complete grant template parsed by pinned public host; raw payload and strict JSON caveats. Lead integrated just check PASS, no publication. risk:medium. Check authority statements, no new-execution inference from Applied, no weak grant policy, truthful fixture provenance and isolation."}} +{"ts":"2026-09-10T22:48:44.346230753Z","author":"sigil-dev","event":"ReviewersRequested","data":{"review_id":"cr-38eiua","reviewers":["sigil-security"]}} +{"ts":"2026-09-10T22:55:23.952073791Z","author":"sigil-security","event":"ThreadCreated","data":{"thread_id":"th-1dh3lv","review_id":"cr-38eiua","file_path":"examples/operator-grant.toml","selection":{"type":"Range","start":13,"end":16},"commit_hash":"15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12"}} +{"ts":"2026-09-10T22:55:23.952134582Z","author":"sigil-security","event":"CommentAdded","data":{"comment_id":"th-1dh3lv.1","thread_id":"th-1dh3lv","body":"[risk:medium] Effective host policy is broader than this complete least-authority template claims. Stable Sigil 0.35.0 deserializes each omitted capability allowlist to github:sigil-plugins/*, so omitting random, entropy, log, and sigv4 here authorizes those capabilities for matching official-source manifests. examples/README.md:5-9 says this template grants only Temporal semantic gRPC authority, but tests/test_release_examples.py:46-66 inspects raw TOML and therefore cannot see the host-injected defaults; the release-binary test proves parsing and endpoint validation, not the effective allowlists. Explicitly set every unused capability allowlist to [] and make the contract test cover the host-effective policy (or narrow and document the claim if broader project-wide defaults are intentional)."}} +{"ts":"2026-09-10T22:55:26.734972889Z","author":"sigil-security","event":"ReviewerVoted","data":{"review_id":"cr-38eiua","vote":"block","reason":"risk:medium blocker th-1dh3lv: the complete operator template leaves four unused capability allowlists at broad Sigil 0.35.0 defaults, while its raw-TOML tests do not validate effective least authority","target_commit":"15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12"}} +{"ts":"2026-09-10T22:59:09.954868901Z","author":"sigil-dev","event":"CommentAdded","data":{"comment_id":"th-1dh3lv.2","thread_id":"th-1dh3lv","body":"Fixed in 99e33b4: all seven capability allowlists explicit; six unused lists empty. Pinned public host schema enumerates all capability fields/defaults, computes effective defaults-plus-declarations, and omission negative controls show broadening. Host config parser still validates complete template and rejects missing endpoint. Full just check PASS. Also added separately lead-reviewed bn-181h helper design in f52da8b; no helper runtime implementation or WIT change, review whole extended range."}} +{"ts":"2026-09-10T22:59:10.211464097Z","author":"sigil-dev","event":"ReviewersRequested","data":{"review_id":"cr-38eiua","reviewers":["sigil-security"]}} +{"ts":"2026-09-10T23:00:16.928112995Z","author":"sigil-dev","event":"ReviewRetargeted","data":{"review_id":"cr-38eiua","old_scm_anchor":"detached:15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12","new_scm_anchor":"detached:99e33b4e8c436703b2825abfd7fd7c866bd1635f","old_target_commit":"15037c8e0f1fc98f32f8c8e55e9d9c70907c9b12","new_target_commit":"99e33b4e8c436703b2825abfd7fd7c866bd1635f"}} +{"ts":"2026-09-10T23:03:36.876935380Z","author":"sigil-security","event":"ThreadResolved","data":{"thread_id":"th-1dh3lv","reason":"[risk:medium] Fixed at 99e33b4: examples/operator-grant.toml explicitly declares all seven host capability allowlists, with only grpc-unary authorized for github:sigil-plugins/temporal. tests/test_release_examples.py binds the declared field set and effective defaults to the hash-pinned Sigil 0.35.0 schema, includes per-field omission negative controls, parses the complete template through the real host, and rejects a missing endpoint reference. The least-authority claim is now sound."}} +{"ts":"2026-09-10T23:03:43.022880389Z","author":"sigil-security","event":"ReviewerVoted","data":{"review_id":"cr-38eiua","vote":"lgtm","reason":"Dedicated static security re-review of the whole persisted effb9cb0022ef80583794d723ac6149f452fedb2..99e33b4e8c436703b2825abfd7fd7c866bd1635f four-commit range: no remaining blocker. 99e33b4 closes every unused capability default and tests the pinned host-effective field/default set plus real config parsing. Start fixtures and guidance preserve implicit-presence ambiguity, exact one-call/no-retry behavior, and do not treat effect=applied as independent creation proof. f52da8b is design-only; its companion contract preserves plugin errors and raw bytes, fails closed on malformed/bounded history, and leaves sticky host failures and external qualification gates authoritative. Reviewer ran no builds or tests.","target_commit":"99e33b4e8c436703b2825abfd7fd7c866bd1635f"}} +{"ts":"2026-09-10T23:03:43.057834988Z","author":"sigil-security","event":"ReviewApproved","data":{"review_id":"cr-38eiua","target_commit":"99e33b4e8c436703b2825abfd7fd7c866bd1635f"}} +{"ts":"2026-09-10T23:04:33.119354807Z","author":"sigil-dev","event":"ReviewMerged","data":{"review_id":"cr-38eiua","final_commit":"99e33b4e8c436703b2825abfd7fd7c866bd1635f"}} diff --git a/.seal/reviews/cr-3d7lvc/events.jsonl b/.seal/reviews/cr-3d7lvc/events.jsonl new file mode 100644 index 0000000..1d3afcb --- /dev/null +++ b/.seal/reviews/cr-3d7lvc/events.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-09-11T01:47:28.260220976Z","author":"sigil-dev","event":"ReviewCreated","data":{"review_id":"cr-3d7lvc","jj_change_id":"detached:be0d0070376057a0a9a5b12b7b999c071e33ee07","scm_kind":"git","scm_anchor":"detached:be0d0070376057a0a9a5b12b7b999c071e33ee07","initial_commit":"be0d0070376057a0a9a5b12b7b999c071e33ee07","base_commit":"effb9cb0022ef80583794d723ac6149f452fedb2","title":"bn-37xu: Temporal 0.1.1-rc.1 complete source release review","description":"Review whole unpublished range through exact RC commit, including bounded Lua companion, minimum-only host compatibility, canonical zstd writer parity, operator docs, and version prep. Publication candidate/tuple/provenance and CAPI are separate gates. risk:high: answer failure modes, edge cases, rollback, monitoring, validation. Source-only defensive static review; preserve fixed RPC identity, Host API1.3, lazy grants, sticky faults and raw byte semantics."}} +{"ts":"2026-09-11T01:47:28.260313375Z","author":"sigil-dev","event":"ReviewersRequested","data":{"review_id":"cr-3d7lvc","reviewers":["sigil-security"]}} +{"ts":"2026-09-11T01:52:06.610242407Z","author":"sigil-security","event":"ReviewerVoted","data":{"review_id":"cr-3d7lvc","vote":"lgtm","reason":"LGTM for the exact persisted effb9cb0022ef80583794d723ac6149f452fedb2..be0d0070376057a0a9a5b12b7b999c071e33ee07 source range (tree 73e08c8cae734934f3df10b98a2c9d32125cbc83); no security blockers.\n\nFailure modes: bounded polling/pagination can exhaust, malformed or over-limit pages can fail, Start may have applied despite ambiguous creation evidence, host/server failures can interrupt, and candidate/version/artifact identity can drift. The source fails closed, returns no partial success, never retries Start, preserves returned error identity and sticky host faults, and binds candidate production to exact reviewed source and hashes.\n\nEdge cases: inclusive zero/max event and byte limits, 1/180 attempts, 1/128 pages, empty pages, repeated binary tokens, non-UTF-8 payload bytes, false/omitted/wire-explicit started, unknown enum numbers, terminal conflicts, callback exceptions, future stable hosts, and prerelease last-stable matching were traced. Unknown or conflicting data does not become completion, creation proof, or product success.\n\nRollback: before publication, stop without dispatch. Published releases are immutable and burned, so never overwrite or mutate one; repair with a new version, source commit, candidate tuple and independent review. Project adoption of the copied companion or package must be reverted through a separately reviewed caller/lock change to a known published identity.\n\nMonitoring: retain normal per-RPC Sigil traces, sticky infrastructure faults, companion code plus attempts/pages, server/run evidence when creation matters, candidate run/asset hashes, OIDC attestation and immutable-release readback, and fresh CAPI assertion/expected-RED results. Do not log payload bodies.\n\nValidation: static defensive inspection covered every persisted changed source file and the unchanged execution paths needed to verify WIT, host, packer and publisher claims. No tests, builds, network, containers or reproduction were run in this review. Existing local fixtures/checks corroborate source only; this LGTM is not official provenance, exact candidate tuple approval, native acceptance, CAPI acceptance, publication authorization or stable promotion. Supporting stable Sigil and fresh official-lock CAPI acceptance remain gates.","target_commit":"be0d0070376057a0a9a5b12b7b999c071e33ee07"}} +{"ts":"2026-09-11T01:52:06.655446674Z","author":"sigil-security","event":"ReviewApproved","data":{"review_id":"cr-3d7lvc","target_commit":"be0d0070376057a0a9a5b12b7b999c071e33ee07"}} +{"ts":"2026-09-11T01:55:43.327162380Z","author":"sigil-dev","event":"ReviewMerged","data":{"review_id":"cr-3d7lvc","final_commit":"be0d0070376057a0a9a5b12b7b999c071e33ee07"}} diff --git a/CHANGELOG.md b/CHANGELOG.md index da30fd4..d33df61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.1.1-rc.1 — Unpublished release candidate + +- Require Sigil >=0.35.0 without an evaluator minor ceiling, while retaining + exact Host API 1.3.0, schema 4 and the unchanged three-operation WIT 0.1.0. + This admits compatible future hosts; it does not certify unmeasured versions. +- Prepare only the 0.1.1/0.1.1-rc.N package family for independently reviewed + publication. Existing 0.1.0 packages and their provenance remain immutable. +- Preserve the fixed Start identity `sigil-temporal@0.1.0`, even though the + candidate package and runtime crate have version 0.1.1-rc.1. Keep the public + Sigil 0.35.0 release validator and executable hashes pinned. +- Use explicit single-thread zstd compression to match Sigil's canonical + writer for multi-block components. The former CLI worker mode could produce + different compressed bytes for the same valid tar. Add a large valid-component + parity regression; published packages remain untouched. +- Add an opt-in project-side Lua companion for bounded polling, explicit + History pagination, completion payload selection and one-layer JSON decoding. + Preserve raw payload bytes, metadata and exact server/effect identities; + no new WIT exports, implicit component retries or operator authority. +- Document proto3 Start boolean semantics: absent and false have the same + decoded value, and a successful applied effect does not prove a new execution. + Include independent presence fixtures and regression coverage. +- Provide a tested least-authority operator grant template with all three + fixed aliases, explicit capabilities and the cumulative budget needed for + a 65-second Start; explain binary payload handling and JSON numeric limits. +- Schedule RC publication after supporting Sigil 0.35.1. Fresh official-lock + CAPI acceptance of this exact RC and any adopted helper source remains + required before a separately reviewed stable promotion. + ## 0.1.0 — stable - Promote the accepted three-operation component without runtime, WIT or diff --git a/Cargo.lock b/Cargo.lock index 2fd0623..ba06cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,7 +225,7 @@ dependencies = [ [[package]] name = "sigil-temporal" -version = "0.1.0" +version = "0.1.1-rc.1" dependencies = [ "prost", "prost-types", diff --git a/Cargo.toml b/Cargo.toml index 8522913..76e690b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigil-temporal" -version = "0.1.0" +version = "0.1.1-rc.1" edition = "2024" license = "MIT" publish = false diff --git a/Justfile b/Justfile index c13679a..d57c18f 100644 --- a/Justfile +++ b/Justfile @@ -4,6 +4,7 @@ check: python3 scripts/check-contracts.py scripts/rebuild-protobuf.sh --verify lua tests/temporal_poll.lua examples/lib/temporal_poll.lua + lua tests/temporal.lua examples/lib/temporal.lua PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_pack*.py' -v PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_release*.py' -v cargo fmt --all -- --check @@ -25,6 +26,10 @@ build: component-check: build cargo run --manifest-path tools/component-conformance/Cargo.toml --locked --offline -- target/component/temporal.wasm +# Existing normally locked/synced project required; no grants or acquisition here. +companion-host-check sigil_binary project_dir: + python3 scripts/check-temporal-host.py --sigil {{quote(sigil_binary)}} --project {{quote(project_dir)}} + # Explicit binary argument: the released 0.34.0 binary is NOT sufficient. local-pack sigil_binary output_dir: build python3 scripts/pack.py plugin.local.toml {{quote(output_dir)}} --sigil {{quote(sigil_binary)}} diff --git a/README.md b/README.md index e649bc2..ab31765 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,28 @@ The measured three-operation `wasm.temporal` component: start, describe and caller-paginated history. -Version **0.1.0** requires stable **Sigil 0.35.x** and Host API -1.3/schema 4. A version in this checkout is not evidence that its GitHub release -exists. The official locked **0.1.0-rc.1** passed CAPI caller-replacement +This checkout prepares **0.1.1-rc.1 (unpublished release candidate)**, +requiring **Sigil >=0.35.0** +and exact Host API 1.3.0/schema 4. Published **0.1.0** still requires **Sigil +0.35.x**; its immutable manifest is not changed by this preparation. A version +in this checkout is not evidence that its GitHub release exists. The official +locked **0.1.0-rc.1** passed CAPI caller-replacement acceptance on **2026-09-10**: five profiles, ten scenarios, 319 unchanged -assertions and both exact expected-RED fingerprints. Stable promotion preserves -the component bytes but creates a new manifest/package identity requiring its -own reviewed publication and verification. Routing, authority, TLS policy, +assertions and both exact expected-RED fingerprints. Its 0.1.0 stable promotion +preserved the component bytes but created a new manifest/package identity +requiring its own reviewed publication and verification. Routing, authority, TLS policy, credentials and transport limits belong to the operator-frozen Sigil host profile. The component receives none of them. It performs no retries, redirects, reconnections, sleeps or implicit pagination. +The candidate's minimum-only evaluator range removes routine minor-version +coupling, not host compatibility checks. Future stable versions must still +support the exact schema and host interface. An admissible version range is +not evidence that an unmeasured future host passed native or CAPI acceptance. +Prerelease evaluators retain Sigil's checked last-stable compatibility rules; +they do not impersonate their own final versions. Install examples below stay +on published 0.1.0 until a new package is officially published and verified. + The application WIT and machine contract are copied without semantic change from reviewed Sigil source `7403a479a36dc7a2fadf38c47578d64ba37ed679`. The host WIT must remain byte-identical to the canonical SDK contract. @@ -26,6 +37,13 @@ Stable Temporal promotion additionally requires real CAPI replacement acceptance Existing CAPI assertions, exact expected-RED fingerprints and non-Temporal pins must not change to obtain a pass. +This RC is scheduled after supporting **Sigil 0.35.1** publication. The release +pipeline still validates against pinned public **0.35.0**, the minimum host; +that check does not substitute for fresh official-lock CAPI acceptance of this +RC on 0.35.1. It adds documented operator grants and Start semantics, the +opt-in [bounded Lua companion](examples/README.md#bounded-lua-companion), and +minimum-only host compatibility without new component exports. + ## Implementation and checks The component exports only StartWorkflowExecution, DescribeWorkflowExecution @@ -55,6 +73,11 @@ The optional project-side polling helper and its caller obligations are in ## Operator and caller contract +Start with the [complete operator grant template](examples/operator-grant.toml) +and its [setup and sizing notes](examples/README.md#operator-grant-template). +The template uses reserved, non-routable placeholders, no credential, and no +endpoint binding. It is not production authority or an installation command. + The Lua export names are `start-workflow-execution`, `describe-workflow-execution`, and `get-workflow-execution-history`. The host profile's RPC keys are a **different namespace**: the plugin sends exactly @@ -98,6 +121,14 @@ workflow may reach the deadline. Follow nonempty page tokens explicitly, keep Start single-shot with one caller-owned request ID, and propagate infrastructure errors rather than converting them into expected product failures. +`payload.data` contains the exact Temporal payload bytes. WIT declares +`list`; Sigil exposes that list as a **binary Lua string**, not base64 or an +already-decoded JSON value. See [payload decoding](examples/README.md#payload-bytes-and-json) +for metadata checks and an optional, single-layer `sigil.json.decode` example. + +For Start responses, read [the `started` and mutation-effect semantics](docs/start-semantics.md) +before using either field as evidence of a newly created workflow execution. + ## Local packaging (non-gating) `plugin.local.toml` is an explicit **local development manifest**, not release @@ -157,7 +188,7 @@ supporting stable Sigil release, independent review or CAPI acceptance. ## Official candidate and publication workflow `plugin.toml` is the separate release manifest. The client WIT and measured -request identity remain `0.1.0`; `0.1.0-rc.N` versions identify release candidates +request identity remain `0.1.0`; `0.1.1-rc.N` versions identify release candidates without altering protobuf payloads or the interface contract. 1. Configure the public repository's protected `main`, main-only `release` @@ -207,8 +238,9 @@ without altering protobuf payloads or the interface contract. allowance or `plugin test` may substitute for the official lock and ordinary `sigil run` acceptance path. -The published RC remains prerelease and is not latest. Only `0.1.0` and -`0.1.0-rc.N` (positive canonical N) are admitted by this initial pipeline. -After CAPI acceptance, stable promotion is a new reviewed version and candidate; -it never mutates the immutable RC. See [RELEASING.md](RELEASING.md) for evidence -and failure handling. +The published 0.1.0 RC remains prerelease and is not latest. This checkout's +pipeline admits only the new `0.1.1` and `0.1.1-rc.N` family (positive canonical +N). It rejects the historical 0.1.0 family rather than preparing replacements. +Any new publication requires its own reviewed source, candidate and package +identity. It never mutates an immutable release. See [RELEASING.md](RELEASING.md) +for evidence and failure handling. diff --git a/RELEASING.md b/RELEASING.md index 0f873c3..ddf6ab6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,6 +4,47 @@ The authority is Sigil's P6 keyless provenance policy and its 2026-08-27 autonomous-publication amendment. The lead owns repository controls, release dispatch and external acceptance; workers do not publish. +## Unpublished 0.1.1-rc.1 preparation + +The manifest and runtime crate now prepare **0.1.1-rc.1**, not a published release. +Only `0.1.1` and canonical positive `0.1.1-rc.N` package versions are admitted +by this checkout's packer and publisher. Historical 0.1.0 releases are never +rebuilt under their old identities. Confirm the new version is unused before +publication; a local source version or earlier availability check is not proof. + +The candidate requires `sigil = ">=0.35.0"`, exact `host_api = "=1.3.0"`, +schema 4 and the unchanged `sigil:host/grpc-unary@1.3.0` import. The removed +minor ceiling does not prove compatibility with unmeasured future hosts. Keep +the public minimum-host validator pinned and record any additional measured +host versions separately. The WIT package, entrypoint and fixed Start identity +remain 0.1.0. Rebuild and measure the component before claiming byte identity; +a crate-version bump alone is not proof either way. + +The local development manifest/packer remains a separate, historical +`0.1.0-dev.N` path. Its 0.34.0 source-build requirement is not the requirement +for official 0.1.1 packages. No local package acquires publication authority. + +All following independent review, provenance, immutability and acquisition +gates remain required. Preparing this source does not authorize dispatch. + +For this RC, publish supporting Sigil **0.35.1** before the official Temporal +publication and measure that host separately during fresh CAPI acceptance. +Keep the public **0.35.0** minimum validator and its hashes pinned; do not +change the manifest floor merely to express this release order. Prior 0.1.0 +CAPI acceptance and local 0.1.1 qualification do not certify this RC's new +source, component or package identity. The Lua companion is project-side +source copied explicitly by callers, not an added file inside the two-member +plugin archive; CAPI must record the helper source revision when adopting it. + +The 0.1.1 release packer also selects zstd `--single-thread` explicitly. +Sigil's writer uses a single-threaded streaming encoder; the CLI's default +one-worker mode can yield different compressed bytes for the same multi-block +tar. Both forms can pass package validation, so validator acceptance is not +proof of writer byte parity. The large-component regression compares the +release packer with the pinned public Sigil writer, checks repeatability and +refuses output reuse. Historical local-development packaging is unchanged. +This correction changes new package identity, not existing published assets. + ## Before the candidate build - Source and whole feature range independently reviewed, no open blockers. @@ -66,15 +107,15 @@ CAPI acceptance uses ordinary `sigil run`, unchanged assertions and exact expected-RED fingerprints with pinned service/rig identities. No non-gating `plugin test` or local-source policy exception substitutes for that gate. -After acceptance, stable 0.1.0 is a new independently reviewed candidate and -publication. Promotion does not mutate the immutable prerelease or claim its -package digest is unchanged when the manifest version changes. +For any accepted RC, stable promotion is a new independently reviewed candidate +and publication. Promotion does not mutate the immutable prerelease or claim +its package digest is unchanged when the manifest version changes. The official locked 0.1.0-rc.1 received CAPI caller-replacement acceptance on 2026-09-10 (five profiles, ten scenarios, 319 unchanged assertions, both exact expected-RED fingerprints). This closes the RC service-acceptance prerequisite, -not the stable exact-candidate review or publication gate. Stable preparation -must reproduce the accepted component BLAKE3 +not a new package's exact-candidate review or publication gate. Historical +0.1.0 stable preparation required the accepted component BLAKE3 `b25139ed2e6eeab88ed26f8e306621cd83869670084f986e481143b57acc372d`; -retain the new package/manifest identities separately. The operator/caller +retain each new package/manifest identity separately. The operator/caller requirements that enabled acceptance are documented in the README. diff --git a/conformance/responses/README.md b/conformance/responses/README.md index 9e6dc60..fa3be4a 100644 --- a/conformance/responses/README.md +++ b/conformance/responses/README.md @@ -3,7 +3,8 @@ These fixtures are newly authored public-schema examples, not captured CAPI responses and not claims of live server acceptance. Their shapes exercise the measured Start/Describe/History projection. `protoc --encode` against the pinned -official schema produces every `.pb` directly from its checked-in textproto. +official schema produces the message fixtures from checked-in textproto, +with the two explicitly described wire-fragment compositions below. The plugin encoder is never used as its own oracle. The cases cover start/de-duplication and known/future status numbers, nested @@ -19,6 +20,17 @@ protoc-encoded `FutureFields` message. Its synthetic high-numbered field does not copy an official field tag. This tests unknown-field compatibility with independent wire bytes and keeps the normal history fields unchanged. +`start-omitted.pb` and `start-false.pb` are identical outputs from the official +schema: proto3 implicit-presence encoding omits the default boolean even when +the textproto explicitly sets it. `start-success.pb` supplies the true case. +`start-wire-false.pb` appends a separately protoc-encoded `StartedPresence` +proto2 fragment to the omitted response, so field 3 is explicitly present with +value false. This test-only fragment copies the pinned official field identity +(checked against the generated descriptor in the unit test); it is independent +of the plugin encoder, not an independent source for Temporal's field numbers +or evidence of any live server's serialization. See +[Start semantics](../../docs/start-semantics.md) for the caller implications. + `scripts/rebuild-protobuf.sh --check` verifies hashes, regenerates message source and all request/response oracles offline using protoc35.1/prost-build0.14.4, and compares byte-for-byte. `--write` updates only generated source and response diff --git a/conformance/responses/SHA256SUMS b/conformance/responses/SHA256SUMS index 43e8bc1..1bd8920 100644 --- a/conformance/responses/SHA256SUMS +++ b/conformance/responses/SHA256SUMS @@ -1,4 +1,4 @@ -461c96914bd2dcd95593de22cf90356e2168e241b224f8238a8c69e5dfb8e3c6 README.md +aa2c8fa57f56fd3a534cd40b896d0e4bbb2df656bbf0ca216dde2dcc489da50d README.md 3e3b3b9821fbdadd243f2d94477cdfb57b8685d94be76c2e9da78444a2bf4fea describe-completed.pb ed88bb030f2c5521c8cc3a31f25a6ca0a2ebed5c359f1d9070a6d5bc4c9208bc describe-future-status.pb c5e1ea2e64028c0cff61052cc611dc547d76ca163bcafa92bc87e278385b192c describe-running.pb @@ -19,8 +19,15 @@ fa0185046788f508a07511d27c665b0812ecee4bd86ed74481e0a7b1f6559ff4 source/describ fcbbd3455124e0d42db42622c50e51e7f5f2d132726bad1571a05d0a55c2eddf source/history-failed.textproto 8415df110f33ff188e46ea27ef930f2be1c4c613d572de19e6929e3c7bd59ddf source/history-future-event-page-two.textproto 4e095fbc62de374763ae577b3acdd3d92c0cedd30671a9f1d506111244efd387 source/start-existing.textproto +3c4c98811121d1c1daa12daf660195eb4beebc2ca07b1a96ece8803ff686893c source/start-false.textproto 05d89f8401ce8b44109fa135eb33d0a4dc041f329a5fb237524929c0a86e6374 source/start-future-status.textproto +95c08c2e4a1354cb794ac0c9391033ee1df66df4986734049fba1a555168a8ac source/start-omitted.textproto fd8bbed4dbf393634dcc9eedbf4cdce4fb5297a54a2ad6228cd81a60d75ac9b2 source/start-success.textproto +e14f8f477f5269519efd07b442f9276bf9cf66d517a3b66d47c0934f98e2b478 source/started-false.textproto +d14c6c20fc780d1abdcd4e24762651787484a5e9f53f064a70337dd8119fbf0d source/started-presence.proto 7313bc3788cbbf05782d4ce933718ee8b11d279ff1c03e6ee03f8fa4ece0f6fb start-existing.pb +cf99818552f94eccbda73bb792013bb39273bd2c933d31ad45c07c655660848e start-false.pb 9c65dfe7478559a332182b147614368e6e5bfa7ae69777454f31d0dd58237640 start-future-status.pb +cf99818552f94eccbda73bb792013bb39273bd2c933d31ad45c07c655660848e start-omitted.pb e24e11295eb3939bf44c85a163e4e2cc7e9122cab45253c5dfbbe54fc0f7351b start-success.pb +682132fb9970f37b7cb6678c9262c66273df2afd608814e3bcb88e1d3c31209c start-wire-false.pb diff --git a/conformance/responses/source/start-false.textproto b/conformance/responses/source/start-false.textproto new file mode 100644 index 0000000..70f8a5a --- /dev/null +++ b/conformance/responses/source/start-false.textproto @@ -0,0 +1,3 @@ +run_id: "11111111-2222-3333-4444-555555555555" +started: false +status: WORKFLOW_EXECUTION_STATUS_RUNNING diff --git a/conformance/responses/source/start-omitted.textproto b/conformance/responses/source/start-omitted.textproto new file mode 100644 index 0000000..106752c --- /dev/null +++ b/conformance/responses/source/start-omitted.textproto @@ -0,0 +1,2 @@ +run_id: "11111111-2222-3333-4444-555555555555" +status: WORKFLOW_EXECUTION_STATUS_RUNNING diff --git a/conformance/responses/source/started-false.textproto b/conformance/responses/source/started-false.textproto new file mode 100644 index 0000000..7df700f --- /dev/null +++ b/conformance/responses/source/started-false.textproto @@ -0,0 +1 @@ +started: false diff --git a/conformance/responses/source/started-presence.proto b/conformance/responses/source/started-presence.proto new file mode 100644 index 0000000..61fb9db --- /dev/null +++ b/conformance/responses/source/started-presence.proto @@ -0,0 +1,13 @@ +// Test-only wire-presence fragment, NOT a replacement Temporal schema. +// Field identity comes from Temporal API v1.63.5, commit +// 3ebdff42a9f07ac484b415fe8ff0b483b4ce3340: +// temporal/api/workflowservice/v1/request_response.proto, +// StartWorkflowExecutionResponse.started (bool, field 3). +// Proto2 presence permits protoc to emit an explicit false value that the +// official implicit-presence proto3 encoder normally omits. The descriptor +// identity is checked in protoc_start_presence_oracles_preserve_boolean_value. +syntax = "proto2"; +package sigil.temporal.conformance; +message StartedPresence { + optional bool started = 3; +} diff --git a/conformance/responses/start-false.pb b/conformance/responses/start-false.pb new file mode 100644 index 0000000..fbd5927 --- /dev/null +++ b/conformance/responses/start-false.pb @@ -0,0 +1,2 @@ + +$11111111-2222-3333-4444-555555555555( \ No newline at end of file diff --git a/conformance/responses/start-omitted.pb b/conformance/responses/start-omitted.pb new file mode 100644 index 0000000..fbd5927 --- /dev/null +++ b/conformance/responses/start-omitted.pb @@ -0,0 +1,2 @@ + +$11111111-2222-3333-4444-555555555555( \ No newline at end of file diff --git a/conformance/responses/start-wire-false.pb b/conformance/responses/start-wire-false.pb new file mode 100644 index 0000000..e0dff08 Binary files /dev/null and b/conformance/responses/start-wire-false.pb differ diff --git a/docs/lua-companion-design.md b/docs/lua-companion-design.md new file mode 100644 index 0000000..84c8502 --- /dev/null +++ b/docs/lua-companion-design.md @@ -0,0 +1,291 @@ +# Bounded Temporal Lua companion + +Status: lead-approved implementation contract for bn-31f0. This document does +not add an API to the released WASM. + +## Decision and scope + +Add `examples/lib/temporal.lua`, copied by projects into their scenario `lib/` +and loaded as `require("lib.temporal")`. It composes the existing three +`wasm.temporal` exports. The calling scenario still declares `wasm.temporal` +and owns its grants and monotonic `budget.max_seconds`. No WIT, host capability, +grant expansion, networking, credentials, shell, or plugin release change is +needed to implement this helper. + +Reuse the algorithm and names `wait_after_start` and `run` from +`examples/lib/temporal_poll.lua`, rather than adding another polling abstraction. +Add only bounded `history`, terminal `result`, and explicit `decode_json`. +Leave the old module and its 25 compatibility tests unchanged: its returned +`poll-timeout` sentinel must not silently become an error for existing callers. +New companion callers opt into the namespaced error contract below. Port the +existing polling cases into the new module's tests as well. + +History pagination is explicit project-side composition, not a hidden loop or +new export in the component. `result` does not start, describe, or poll a +workflow: waiting and result retrieval remain separate caller decisions. + +## API and data shapes + +The public functions are exactly: + +```lua +value, err = temporal.wait_after_start(describe_request, wait_options) +value, err = temporal.run(start_request, wait_options) +value, err = temporal.history(selector, history_options) +value, err = temporal.result(selector, result_options) +value, err = temporal.decode_json(payload, json_options) +``` + +`start_request` and `describe_request` retain their existing WIT-shaped fields, +including hyphenated keys. Requests are not mutated. `selector` contains only +`profile`, `namespace`, and `["workflow-id"]`. There is no `run-id` selection. +Reject unknown selector/option keys and invalid values before any RPC. + +Options are required tables, except `json_options`, which may be omitted. +Numeric options are finite Lua integers in the inclusive ranges below. Missing +required fields, unknown fields, and out-of-range values produce +`invalid-option`, not clamping or guessed defaults. + +| Options | Required fields | Optional fields | +| --- | --- | --- | +| `wait_options` | `max_attempts` (1..180), `interval_millis` (0..60000) | `checkpoint`; `describe_timeout_millis` (1..10000, default 10000, used by `run` only) | +| `history_options` | `filter` (`close-event` or `all-events`), `max_pages` (1..128), `max_events` (0..16384), `max_bytes` (0..16777216), `timeout_millis` | `checkpoint` | +| `result_options` | Same limits as History, without `filter`; uses `close-event` | `checkpoint` | +| `json_options` | None | `max_bytes` (0..2097152, default 2097152) | + +`timeout_millis` is 1..65000 for close-event and 1..10000 for all-events. +`wait_after_start` uses its request's `["timeout-millis"]`; reject a supplied +`describe_timeout_millis` option there instead of silently ignoring it. +`run` creates the Describe request from the Start profile/namespace/workflow ID +and the optional Describe timeout. It preserves all Start request values exactly, +including the caller's one request ID. Before callbacks, snapshot the bounded +request/option/selector tables, including the two Start payload metadata lists; +immutable payload byte strings need no copy. This keeps Start and Describe on +the same captured selector if a callback mutates the caller's original tables. +Validate all helper options before Start. + +`checkpoint`, when supplied, is a zero-argument caller function. Normal return +means continue; cancellation or an earlier caller-owned deadline is signalled +by throwing. It must not perform RPCs, sleep, or mutate requests/payloads or +returned data. No `now`, wall-clock arithmetic, +deadline timestamp, or fake cancellation primitive is supplied by the helper. + +Successful results: + +- `wait_after_start` and `run`: the unmodified Describe response when its exact + status number is anything other than RUNNING (1), including unknown numbers. + Neither function labels that outcome successful workflow completion. +- `history`: `{ events = , pages = N, bytes = N }`, + only after a final empty next-page token. No truncated success is returned. +- `result`: `{ tag = "completed", payloads = , + event = , history = }`, or + `{ tag = "not-completed", history = }`. An empty payload + list under `completed` means completed with zero payloads; it is not absent + completion. `not-completed` means no completed event was found in the bounded, + fully traversed close-event response, not a synthesized workflow status. +- `decode_json`: the single decoded value, which may be `false`, an integer, + a string, a structured value, or `sigil.json.null`. Never test success by + truthiness: inspect `err`; JSON null is not Lua nil. + +Returned plugin errors are passed through as `nil, err`, preserving the same +table and every kind/code/operation/effect/server field. Companion failures use +a distinct table: `{ kind = "companion", code = CODE, operation = NAME, +attempts = N, pages = N }`; counters apply only where relevant. Codes are +`invalid-option`, `poll-exhausted`, `page-exhausted`, `event-exhausted`, +`byte-exhausted`, `node-exhausted`, `repeated-token`, `malformed-result`, and +`unsupported-encoding`. Do not attach fake `server`, `status`, or `effect` +fields. Do not include identifiers, payloads, tokens, or error-source text. +No helper failure returns partial success data. + +## Polling and deadline/cancellation precedence + +`run` sends Start exactly once. Any returned error stops immediately, regardless +of effect classification; it never retries, regenerates a request ID, or calls +Describe after an unsuccessful Start. A thrown host error escapes unchanged. +On successful Start, call the same internal loop as `wait_after_start`. +Direct `wait_after_start` callers explicitly attest that this same workflow ID +has just had a successful Start; ordinary Describe callers should use the raw +plugin rather than reinterpret NOT_FOUND as retryable. + +Only completed Describe responses with status RUNNING (1), or typed post-start +NOT_FOUND (`kind == "server-status"`, `server.code == 5`, +`server.class == "not-found"`) continue the loop. All other returned errors +propagate, and all other status numbers return unchanged. There are at most +`max_attempts` Describe calls and `max_attempts - 1` sleeps, each of exactly +`interval_millis / 1000` seconds. Never sleep after the final allowed attempt. +An unfinished last attempt yields `poll-exhausted`, not Temporal TIMED_OUT (4), +server DEADLINE_EXCEEDED, failed expectation, or completed workflow data. + +No `pcall` or `xpcall` surrounds RPCs, `sigil.sleep`, JSON decode, or callbacks. +Apply boundary ordering consistently: + +1. Call `checkpoint` before each RPC and sleep; any exception stops execution. +2. An exception from RPC/sleep escapes immediately, unchanged. +3. A returned non-retryable plugin error propagates unchanged before invoking + another callback or manufacturing a local exhaustion error. This includes + infrastructure, cancellation, and deadline errors. +4. After a successful call, retryable post-start NOT_FOUND, or sleep, invoke + `checkpoint` before considering a result or local limit. Thus an observed + caller cancellation/deadline takes precedence over local exhaustion/success. +5. Enforce local limits and then process the successful result. No subsequent + RPC occurs after exhaustion or cancellation. + +The host's sticky failure state and scenario deadline remain authoritative even +if a caller catches an error outside this module. An attempt bound is not a +wall-clock promise: 180 attempts does not mean 180 seconds. The helper cannot +interrupt an in-flight host RPC or detect a deadline during a purely local step +ahead of the host's own checks. Do not advertise independent timeout machinery. + +## Canonical History traversal and resource accounting + +Derive `["wait-new-event"]` and `["skip-archival"]` from `filter`: both true +for close-event, both false for all-events. These flags are not public options. +Begin with empty binary token `""`. Pass each returned token byte-for-byte to +the next call; do not UTF-8 decode, base64 decode, concatenate pages' tokens, +or auto-resume in a later helper invocation. Each page causes exactly one RPC; +server errors, including NOT_FOUND, are never retried by History. + +Retain events in page/event order, preserving raw values. Track every nonempty +next token and reject any repeat, including A -> B -> A, before another RPC. +Empty pages with a fresh nonempty token continue within the same bounds. An +empty token completes traversal, including on the final allowed page. A +nonempty token on that page yields `page-exhausted`. Validate shape/node bounds +first, then check repeated-token and event/byte limits before page exhaustion, +in that order, so simultaneous local +failures have a deterministic code. Returned plugin errors/host exceptions +still have the higher precedence described above. + +Aggregate `max_bytes` counts the length of every string-valued field occurrence +in each returned page, including metadata keys/values, payload data, labels, +failure text, activity names, and the returned token. Count repeated occurrences +even if Lua interns a string. Count incoming page strings once before retaining +that page; do not count keys of the fixed WIT record representation. `max_events` +counts events, not only completed events. On excess, discard the new page and +return no accumulated success. Limits are inclusive; zero allows only zero +counted events/bytes, and still consumes a page/RPC if requested. + +Accounting must walk only the known WIT page/event/variant/payload/metadata/ +failure-node shapes, never generic recursive traversal of arbitrary Lua data. +Before traversing a table, reject any metatable (including protected ones). +Sigil's sandbox removes `rawget`/`rawset` (`src/runtime/sandbox.rs` in the host). +Use ordinary indexing only after `getmetatable(value) ~= nil` has rejected +every metatable, including a protected false value; indexing a plain table +cannot dispatch a metamethod. Enumerate with `next`, never `pairs`/`ipairs` +callbacks. This narrow host-compatibility clarification was lead-approved; +it does not restore raw globals or weaken the sandbox. +Records admit only their declared fields; lists must have only dense integer +keys 1..N, with no holes, foreign keys, or indices beyond their explicit bound. +Use an active-path identity set to reject cycles. Shared acyclic tables are +allowed, but count each occurrence: aliases must not bypass accounting. + +Enforce a fixed aggregate ceiling of 100000 visited value occurrences per +helper invocation, counting each table/scalar value at the root or reached +through a declared record field/list entry. Stop before visiting occurrence +100001 with `node-exhausted`; enumeration itself must consume this finite +budget rather than materialize all keys first. Validate unknown record keys +within that record's fixed field-count bound and reject immediately. Enforce +at most eight nested tables (page root depth one), rejecting deeper/cyclic/ +wrong-shaped data with `malformed-result`. These are closed hard limits, not +caller-widenable options. Existing per-shape bounds additionally apply: at most +4096 events per page, 32 metadata entries per payload, 16 failure nodes per +failure list, and 65536 bytes per page token. Every payload list is bounded by +the remaining node budget. The projected failure chain is already a flat WIT +list; do not follow invented recursive `cause` fields in Lua. + +Apply the same bounded shape validation to caller-supplied `decode_json` +payloads and to response fixtures/mocks. Option/selector validation also uses +closed plain-table shapes. A malformed object must fail closed before its +metamethod can run or its contents can cause unbounded work. These validation +limits are helper failures, not a claim that the server sent malformed data. + +These are exact logical retention limits, not an exact Lua heap-byte metric. +At most 128 pages, 16384 events, 100000 visited value occurrences and 16 MiB of +returned string occurrences may be accepted; token tracking reuses those +strings rather than copying them. +One additional response can exist transiently while being checked, bounded by +the plugin/host's existing per-response ceilings. Lua table overhead, metadata +entry counts and transient host conversion are additionally bounded by the +existing host memory/fuel limits, never described as covered by `max_bytes`. +Do not duplicate raw payload strings or create concatenated history dumps. + +## Result extraction, bytes, and JSON + +`result` performs a complete bounded close-event `history` traversal before +returning any payloads. A completion must have event type number 2 and +`details.tag == "workflow-completed"`; its variant value is the whole payload +list, never a single payload. Reject a completion/tag mismatch or more than one +known terminal event (even identical duplicates) as `malformed-result`; no +first-win or last-win policy. Known close-event numbers are 2, 3, 4, 21, 27 and +28 in the pinned schema; unknown numbers remain raw data and never imply +completion. +Preserve failure chains and other raw event details in the returned History. +An empty completed list is valid. Failed/timed-out/cancelled/terminated/ +continued-as-new outcomes are not a completed result and must not trigger a +Start or implicit follow-up to another run. + +WIT `list` is a binary Lua string at this host boundary. `payload.data` is +the original bytes, not inherently UTF-8, JSON, base64, or a decoded object. +Metadata values are also byte strings. Preserve payload ordering, duplicates, +zero-length data, NUL/non-UTF-8 bytes, metadata order and exact numeric/time +fields. Helpers never modify returned payload/event tables; reference sharing +with their enclosing History is documented, not presented as a deep copy. + +`decode_json` is a separate opt-in call for one payload. Require exactly one +metadata entry named `encoding`, with bytes exactly `json/plain`; reject missing, +duplicate or different encodings with `unsupported-encoding`. Validate the +payload/metadata shape before decode; malformed shape yields `malformed-result`. +Allow other metadata without changing or interpreting it. Call +`sigil.json.decode(payload.data, { max_bytes = limit })` exactly once and return +that value. Its exceptions propagate unchanged; there is no retry, fallback, +base64 handling, automatic second decode, or conversion to a workflow outcome. + +Sigil's decoder preserves signed 64-bit integer literals, including values above +2^53, and rejects fractional/exponent forms and out-of-range integers. It also +rejects invalid UTF-8, duplicate object keys, malformed/trailing JSON and its +own depth/node/memory limits. Null remains `sigil.json.null`; object/array shape +is retained by `sigil.data` values. A JSON string containing JSON-looking text +remains a string. Callers that deliberately want another layer must explicitly +decode again outside this helper. Raw payload data remains accessible unchanged +after a successful or failed decode. The 2 MiB decode ceiling may be lower than +a valid raw payload: raw transport success does not promise decoder acceptance. + +## Identity and observability limits + +Keep every individual host operation visible in the normal Sigil trace. The +helper neither logs payloads nor substitutes one synthetic operation for its +RPCs. Attempt/page counts are local accounting, not evidence of server receipt. +No helper catches sticky host failures or turns them into PASS/FAIL assertions. + +`started` is the upstream implicit-presence bool. Every successful Start +currently carries `effect = "applied"`, including an existing-execution result. +The CAPI guard `started == false and effect ~= "applied"` rejects no successful +Start. Neither value is independent creation proof. Describe/History select +the latest run by workflow ID and History returns no run ID. The companion +does not invent run-ID pinning, creation evidence, or a consistency guarantee +across calls. Record/correlate server evidence separately if the scenario's +contract requires a newly created, exact execution. + +## Implementation acceptance matrix (bn-31f0) + +Use offline fake exports with exact call recording, then a real Sigil scenario +for boundary/JSON behavior. Keep evidence categories separate from live CAPI +acceptance; no paid service or external Temporal mutation is required here. + +| Area | Required regressions | +| --- | --- | +| Legacy compatibility | Existing 25 temporal_poll tests unchanged; original module bytes unchanged. | +| Poll | Reuse all existing status/error cases; max 1 and 180 attempts; exact sleep count; invalid options cause zero Start/Describe calls; one Start with unchanged request ID; unknown-effect Start never retries/describes. | +| Exhaustion | Distinct namespaced errors, no workflow/server status/effect fields, no partial result; terminal status on final attempt wins over unused attempt bound. | +| Cancellation/deadline | Checkpoint before first RPC, between attempts/pages, after successful final response and before local exhaustion; sleep/RPC/checkpoint exceptions escape by identity; plugin infrastructure/server deadline errors preserve identity and precedence. | +| History | Both canonical flag combinations; exact timeout lowering; 1 and 128 pages; empty terminal page; empty intermediate page; exact binary token; immediate repeat and A/B/A; final-page success vs page exhaustion; never retry any History error. | +| Retention | Inclusive event/byte/node boundaries and zero limits; every WIT string field counted; multiple metadata/payload entries; one over returns no partial result; raw data is not copied or mutated; cycles/metatables/sparse lists/foreign keys/oversized indices/deep mock records fail closed without callbacks; shared acyclic aliases count on every occurrence; flat failure list max16 preserved. | +| Result | Completion with zero/one/multiple payloads; completion only on later page; no completion; each known non-completed terminal; unknown event numbers; multiple/mismatched/conflicting completion data; limits/errors after an earlier completion still prevent success. | +| JSON | Explicit json/plain only; metadata anomalies; raw bytes unchanged; object/array/null/false/integer/string roots; empty and binary data rejection; signed-i64 extrema and above-2^53 exactness; fractions/exponents/overflow rejected; escaped JSON string decoded once; duplicate keys/trailing JSON rejected; lowered input limit and host allocation/deadline failure propagate. | +| Isolation | No pcall/xpcall, shell, logging, grants, automatic Start retry or new WASM exports; real Sigil trace exposes each export and explicit decode without payload disclosure. | + +Implementation must update the example caller to show separately checking +`err`, then terminal status/result tag, then optional payload decoding. Document +that `not-completed` and helper exhaustion cannot be silently treated as a +product PASS/FAIL by the adjudicator. Run existing `just check`, added Lua tests, +and targeted real-host boundary tests before requesting review. No new plugin +release or CAPI prerequisite gate is implied by this design approval. diff --git a/docs/start-semantics.md b/docs/start-semantics.md new file mode 100644 index 0000000..dea2ecc --- /dev/null +++ b/docs/start-semantics.md @@ -0,0 +1,84 @@ +# Interpreting Start results + +Temporal 0.1.0 preserves the upstream `started` boolean. It does **not** expose +whether the server assigned that field, and `effect = "applied"` is not proof +that this invocation created a new execution. Do not use their combination as +a duplicate-start detector. + +## What the pinned contract establishes + +The source of truth is Temporal API v1.63.5, commit +`3ebdff42a9f07ac484b415fe8ff0b483b4ce3340`, recorded in +[vendor provenance](../vendor/provenance.json). Its +[StartWorkflowExecutionResponse](../vendor/temporal-api/temporal/api/workflowservice/v1/request_response.proto) +declares `bool started = 3`, without `optional`. The upstream comment says true +means a new workflow was started; `run_id` may identify a started **or used** +execution. `status` is execution status, not creation proof. + +Under protobuf's [implicit-presence rules](https://protobuf.dev/programming-guides/field_presence/), +an unset scalar and a scalar assigned its default encode identically. A normal +proto3 encoder omits false. Inspecting the raw wire could distinguish an +explicitly encoded false from an omitted field, but could not distinguish a +server that deliberately assigned false from one that never assigned it. +Changing the WIT to `option` would therefore not recover server intent. + +The [plugin implementation](../src/client.rs) directly forwards the decoded +boolean. It classifies every successfully decoded Start response with a +nonempty run ID as `effect = "applied"`, including the existing-execution +fixture. It does not turn `started = false` into an error or make another call. + +| Result | Safe interpretation | +| --- | --- | +| Success, `started = true` | The server's response reports a new workflow. | +| Success, `started = false` | The decoded boolean is false; it does not distinguish a deliberate false from a server that omitted the field. | +| Success, `effect = "applied"` | Successful Start outcome under this plugin's classification, possibly an existing execution; not independent creation proof. | +| Error, `effect = "unknown"` | Whether the mutation happened is unresolved. Do not automatically retry Start. | + +These are response semantics, not an assertion that every Temporal server +version implements the field identically. In particular, these fixtures do not +establish which older servers populate `started`, nor adjudicate a particular +live CAPI Start as new or duplicate. + +## Caller guidance and the CAPI guard + +The guard reported in findings #7: + +```lua +if response.started == false and response.effect ~= "applied" then + -- reject duplicate +end +``` + +cannot reject **any successful Start response** in 0.1.0. Success always carries +`effect = "applied"`, including the false and existing-execution cases. This +guard is not a weaker duplicate check; it is ineffective on the success path. +Handle typed errors separately and retain the no-retry rule for unknown effects. + +If the assertion only needs a usable workflow execution, accept the successful +result and observe its state through Describe/History. If it specifically needs +proof of creation by this invocation, establish that as a separate contract: +qualify the deployed server's `started` behavior, retain unique workflow and +request identities, and correlate server evidence for the returned run ID. +The plugin's current Describe/History operations select by workflow ID, not +run ID, so their success alone does not prove the returned run was newly +created. Never reissue Start merely to resolve this question. Until suitable +evidence exists, keep a new-execution claim unproven rather than treating +`effect = "applied"` as the missing evidence. + +## Regression evidence and scope + +[Response fixtures](../conformance/responses/README.md) now cover omitted, +textproto-assigned false, wire-explicit false, and true. Pinned libprotoc 35.1 +encodes the official-schema examples; a labelled proto2 fragment supplies the +legal wire-explicit false representation that normal proto3 encoding omits. +The fragment's field identity is checked against the pinned generated descriptor. +No plugin encoder is used to produce these oracles. This is independent codec +evidence, not independent discovery of schema tags or live server acceptance. + +Both binding and client regressions verify the boolean projection; client +regressions additionally check `effect = applied`, exactly one exchange, and +the ineffective CAPI guard. Existing error/effect regression tests remain in +place. No WIT, production behavior, host grant, or compatibility range changes +are made by this adjudication. A new creation-evidence enum would require a +separate versioned design with an explicit unknown state, not a fabricated +presence bit on this implicit-presence field. diff --git a/examples/README.md b/examples/README.md index 7f05478..d8078b3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,112 @@ -# Project-side polling example +# Operator and project-side examples + +## Operator grant template + +[`operator-grant.toml`](operator-grant.toml) is a complete parseable configuration +template for stable Temporal 0.1.0 on Sigil 0.35.x. It grants only the official +source semantic gRPC authority. The network table supplies host routing, not +guest networking. No third-party allowance, raw guest secret, endpoint binding, +credential, scenario or generated lock is included. Reserved `.invalid` DNS +names and `replace-me` values must not be used for a real service. +Every unused capability allowlist is explicitly empty: omitted lists inherit +official-source defaults. These lists apply project-wide; separately review +and add any authority needed by other plugins instead of deleting empty lists. + +1. Review the template before merging it into your project configuration. Replace + the service, logical target, TLS authority/server name, namespace, workflow + prefix, workflow type and task queue. Keep the metadata namespace equal to + the request-policy namespace. Review the other four metadata values for your + deployment. Do not change the fixed `sigil-temporal@0.1.0` request identity or + the `start`, `describe`, `history` RPC aliases, paths and kinds. +2. Bind the logical target to your endpoint map. A remapped port needs explicit + binding, such as `--plugin-route LOGICAL:7233=https://HOST:PUBLISHED_PORT`. + Substitute the reviewed values before use. Socket address, exact HTTP/2 + authority, and TLS server name are separate inputs. TLS server name must + match the authority's DNS host and the certificate. Use `tls_ca_file` for + an operator-selected private CA when needed. The template uses direct TLS + without authentication. For bearer authentication, set the profile's + `bearer_secret` to a declared scenario environment variable name, never its + value. Do not add raw `secrets` authority. Plaintext `h2c` is only suitable + for a separately reviewed, isolated local service and cannot carry bearer + credentials. +3. Follow the README's official install/lock sequence. A scenario must declare + `wasm.temporal` and select `profile = "workflow"`. Give it an explicit + scenario budget sufficient for the intended sequence. For example, + `budget = { max_seconds = 120 }` can accommodate one 65-second history call, + but does not promise that a whole polling loop will finish. + +### Budget relationships + +Start, Describe and all-events history accept at most **10000 ms** per call. +Close-event history accepts **65000 ms**. Use `wait-new-event=true` and +`skip-archival=true` only with `filter="close-event"`; all-events requires both +flags false. Every request requires a positive `timeout-millis`. + +The template sets profile `max_call_seconds=65`, outer runtime +`max_call_seconds=70`, and endpoint `io_timeout="70s"`. This avoids shorter +default ceilings truncating a close-event long poll. The five-second connect +limit only bounds connection establishment. The earliest profile, caller, +outer-call, scenario and I/O deadline still wins. No configured limit promises +that the server will answer within it. + +The 1 MiB request and 4 MiB response limits bound individual protobuf messages. +The endpoint's 16 MiB allowance instead covers **cumulative bidirectional HTTP/2 +plaintext bytes**, including framing, across calls in that scenario/lane. +It is not refreshed by each call and is not sized for 180 maximum-size replies. +Review this quota against your intended calls and pages. `max_connections=1` +limits concurrent connections, not the total number of sequential calls. + +`max_memory="256MiB"` follows Sigil's measured 4 MiB response qualification. +It is not a universal sizing formula or a guarantee for every response shape. +Guest memory, transfer fuel, host allocations and retained outputs impose +independent limits. Runtime settings apply project-wide to plugins, so review +their impact before copying them into a multi-plugin configuration. See +[Sigil's semantic gRPC limits](https://runsigil.com/guides/semantic-grpc/#limits-and-outcomes). + +The tests compare aliases, methods, mutation/read kinds, fixed identity and +metadata names with the frozen contract. With `SIGIL_RELEASE_BINARY` set, they +also parse this file using the hash-pinned public Sigil 0.35.0 executable and +enumerate its capability defaults from its generated schema, require every +capability list explicitly, and prove an invalid endpoint reference is rejected. +Negative controls demonstrate why an omitted unused allowlist is broader. +These offline checks do not +resolve a route, acquire a plugin, contact Temporal, or prove service acceptance. + +## Payload bytes and JSON + +WIT `payload.data` and metadata values are `list`. Sigil maps each to a +binary Lua string without base64 conversion, JSON decoding or UTF-8 conversion. +The plugin preserves the exact response bytes. Check each payload's `encoding` +metadata before interpreting it. Different payloads may use different encodings. +`workflow-completed` carries an ordered **list of payloads**, not one payload; +an empty list means completion without a return payload, not non-completion. + +For an explicitly selected `json/plain` payload, one optional decode looks like: + +```lua +local encoding +for _, entry in ipairs(payload.metadata) do + if entry.key == "encoding" then encoding = entry.value end +end +if encoding ~= "json/plain" then + error("expected a json/plain Temporal payload") +end +local value = sigil.json.decode(payload.data, { max_bytes = 65536 }) +-- Read the application's expected shape with sigil.data or direct field access. +-- If JSON encoded a string, value is a string. Do not decode it a second time +-- unless a separate application field explicitly contains JSON text. +``` + +This capability-free helper returns Sigil's shape-preserving structured values +without a `jq` process. It is **not a general Temporal data converter**: +Sigil 0.35.0 rejects fractional JSON numbers, integers outside signed 64-bit +range, duplicate keys and malformed UTF-8/JSON. The example lowers the hard +2 MiB input ceiling to 64 KiB. Depth, node-count and allocation limits also +apply. Preserve the raw payload when you need an unsupported encoding or number +representation. Decode errors must remain errors, never an empty successful +workflow result. JSON `null` remains `sigil.data.null`, distinct from no payload. + +## Project-side polling example `lib/temporal_poll.lua` is a reference helper for a scenario library, not a fourth plugin export or a drop-in replacement for the CAPI shell script. Copy it into @@ -31,3 +139,86 @@ lua tests/temporal_poll.lua examples/lib/temporal_poll.lua These tests use in-process callbacks and are not CAPI acceptance or evidence of a supporting stable Sigil/plugin installation. + +## Bounded Lua companion + +[`lib/temporal.lua`](lib/temporal.lua) is the new opt-in companion. It leaves +`lib/temporal_poll.lua` and its legacy sentinel behavior unchanged. Copy the +new file into the calling scenario's `lib/` directory. Every caller declares +`wasm.temporal`, including a caller using only `decode_json`: Sigil traces the +module's literal WASM dependency during capability checking. Lazy loading does +not bypass that check. The direct `sigil.json.decode` builtin example above, +in contrast, needs no plugin capability. + +The companion reuses `run`/`wait_after_start` and adds bounded `history`, +`result`, and explicit `decode_json`. All requests preserve their WIT field +names; helper options use underscore names. An illustrative success-contract +caller, after constructing one Start request with its two ordered payloads: + +```lua +local temporal = require("lib.temporal") +local workflow, err = temporal.run(start_request, { + max_attempts = 180, interval_millis = 1000, +}) +if err ~= nil then error("Temporal Start/wait infrastructure or helper error") end +expect(workflow.status.number == 2) -- measured workflow COMPLETED, not poll exhaustion +if workflow.status.number ~= 2 then return end -- do not mask a product failure with result retrieval + +local result, result_error = temporal.result({ + profile = start_request.profile, + namespace = start_request.namespace, + ["workflow-id"] = start_request["workflow-id"], +}, { + max_pages = 8, max_events = 32, max_bytes = 1048576, timeout_millis = 65000, +}) +if result_error ~= nil then error("Temporal result retrieval error") end +if result.tag ~= "completed" then error("No completed result was observed") end +-- Empty payloads is valid completion without a return value, not missing completion. +for index = 1, #result.payloads do + local value, decode_error = temporal.decode_json(result.payloads[index], { max_bytes = 65536 }) + if decode_error ~= nil then error("Temporal payload encoding/shape error") end + -- Inspect the application's expected value; false and sigil.json.null are valid. +end +``` + +Set an explicit scenario `budget.max_seconds`. Attempt counts are not elapsed +seconds; host deadlines still win. `checkpoint`, if supplied, may throw for +caller cancellation/earlier deadlines but must not mutate requests or perform +RPCs/sleep. The helper snapshots bounded Start/selector/option tables before +callbacks without copying immutable payload byte strings. It never retries +Start, suppresses a host exception, or follows another run. Describe/History +still select by workflow ID, not the Start run ID. + +Returned plugin errors preserve their exact table. Helper errors instead have +`kind="companion"` with codes such as `poll-exhausted` or `page-exhausted`, +never a fabricated workflow status/server status/effect. A scenario/adjudicator +must not turn exhaustion or `not-completed` into a successful result or an +expected product failure. Handle such gaps before product assertions, preserving +the project's established infrastructure-versus-product classification. + +`history` derives the two flags from its required `filter`; `result` always +selects close-event History. Both require explicit page/event/byte/time limits, +stop on repeated binary tokens, and return no partial success. Ordered raw +payloads and metadata are preserved. JSON decoding is optional, exactly one +layer, and rejects unsupported encodings/numbers rather than guessing. +See [the complete contract](../docs/lua-companion-design.md) for inclusive +limits, precedence, raw-data ownership and all returned shapes. + +`just check` runs the old 25 polling tests and the new recording/bounds tests. +For the real Sigil JSON/sandbox boundary, first prepare an isolated project via +normal official install/add/sync with Temporal 0.1.0 and no RPC routes. Then run: + +```sh +just companion-host-check /absolute/path/to/pinned-sigil /absolute/path/to/project +``` + +This separate target verifies the Linux Sigil 0.35.0 binary against +`scripts/release-tools.json` before and after execution. It does not acquire a +plugin, add a grant, or modify the project config/lock. The project's already +synced plugin cache must be selected through its normal `SIGIL_DATA_DIR` and +`SIGIL_CACHE_DIR`. The test declares `wasm.temporal` and exercises the full +helper's decoder under ordinary `sigil run`, without an RPC. It covers exact +large integers, null/shape, single-layer strings, invalid JSON/UTF-8/numbers, +input limits and metatable rejection in the real restricted sandbox. RPC +sequencing/exception/limit tests remain offline recordings; these checks are +not live Temporal behavior, full CAPI acceptance, or publication authority. diff --git a/examples/lib/temporal.lua b/examples/lib/temporal.lua new file mode 100644 index 0000000..1071e74 --- /dev/null +++ b/examples/lib/temporal.lua @@ -0,0 +1,325 @@ +-- Project-side companion; no new component exports or authority. +-- RPC callers declare wasm.temporal and retain an explicit scenario deadline. +local M = {} +local exports +local function rpc(name, request) + if exports == nil then exports = require("wasm.temporal") end + return exports[name](request) +end + +local function plain(value) + return type(value) == "table" and getmetatable(value) == nil +end + +local function failure(code, operation, attempts, pages) + return nil, { + kind = "companion", code = code, operation = operation, + attempts = attempts, pages = pages, + } +end + +local function integer(low, high) + return { kind = "integer", low = low, high = high } +end +local function string_value(low, high, pattern) + return { kind = "string", low = low or 0, high = high or 16777216, pattern = pattern } +end +local function record(fields, optional) + return { kind = "record", fields = fields, optional = optional or {} } +end +local function list(item, maximum) + return { kind = "list", item = item, maximum = maximum } +end +local function state() + return { nodes = 0, bytes = 0, active = {}, code = "malformed-result" } +end + +-- Schema-directed traversal only. The sandbox intentionally removes rawget. +-- After rejecting ANY metatable, indexing cannot dispatch a metamethod. +-- next visits entries without pairs/ipairs callbacks or a temporary key list. +local walk +walk = function(value, schema, budget, depth) + budget.nodes = budget.nodes + 1 + if budget.nodes > 100000 then + budget.code = "node-exhausted" + return false + end + if schema.kind == "string" then + if type(value) ~= "string" or #value < schema.low or #value > schema.high + or (schema.pattern and not string.match(value, schema.pattern)) then return false end + budget.bytes = budget.bytes + #value + return true + elseif schema.kind == "integer" then + return math.type(value) == "integer" and value >= schema.low and value <= schema.high + elseif schema.kind == "boolean" or schema.kind == "function" then + return type(value) == schema.kind + end + if not plain(value) or depth > 8 or budget.active[value] then return false end + budget.active[value] = true + if schema.kind == "variant" then + local tag = value.tag + if type(tag) ~= "string" or schema.cases[tag] == nil then return false end + schema = schema.cases[tag] + end + if schema.kind == "list" then + local count, largest = 0, 0 + for key, child in next, value do + if math.type(key) ~= "integer" or key < 1 or key > schema.maximum then return false end + count = count + 1 + if key > largest then largest = key end + if not walk(child, schema.item, budget, depth + 1) then return false end + end + if count ~= largest then return false end + else + for key, child in next, value do + local field = schema.fields[key] + if field == nil or not walk(child, field, budget, depth + 1) then return false end + end + for key in next, schema.fields do + if not schema.optional[key] and value[key] == nil then return false end + end + end + budget.active[value] = nil + return true +end + +local text = string_value() +local id = string_value(1, 255) +local profile = string_value(1, 64, "^[a-z][a-z0-9_-]*$") +local i64 = integer(math.mininteger, math.maxinteger) +local proto_enum = record({ number = integer(-2147483648, 2147483647), label = text }, { label = true }) +local payload = record({ + data = string_value(0, 2097152), + metadata = list(record({ key = string_value(0, 8192), value = string_value(0, 8192) }), 32), +}) +local failure_node = record({ message = text, source = text, ["activity-type"] = text }, + { source = true, ["activity-type"] = true }) +local details = { kind = "variant", cases = { + ["workflow-completed"] = record({ tag = text, value = list(payload, 100000) }), + ["workflow-failed"] = record({ tag = text, value = list(failure_node, 16) }), + ["activity-scheduled"] = record({ tag = text, value = text }), + other = record({ tag = text }), +} } +local event = record({ + ["event-id"] = i64, ["task-id"] = i64, + ["event-time"] = record({ seconds = i64, nanos = integer(0, 999999999) }), + ["event-type"] = proto_enum, details = details, +}) +local page_schema = record({ + events = list(event, 4096), ["next-page-token"] = string_value(0, 65536), +}) +local selector_schema = record({ profile = profile, namespace = id, ["workflow-id"] = id }) +local describe_request = record({ + profile = profile, namespace = id, ["workflow-id"] = id, ["timeout-millis"] = integer(1, 10000), +}) +local start_request = record({ + profile = profile, namespace = id, ["workflow-id"] = id, + ["workflow-type"] = id, ["task-queue"] = id, + ["request-id"] = string_value(1, 64, "^[!-~]+$"), + ["timeout-millis"] = integer(1, 10000), payloads = list(payload, 2), +}) +local output_id = string_value(1, 4096) +local describe_response = record({ ["run-id"] = output_id, status = proto_enum }) +local start_response = record({ + ["run-id"] = output_id, status = proto_enum, started = { kind = "boolean" }, effect = text, +}) +local checkpoint_type = { kind = "function" } +local wait_fields = { + max_attempts = integer(1, 180), interval_millis = integer(0, 60000), checkpoint = checkpoint_type, +} +local wait_schema = record(wait_fields, { checkpoint = true }) +local run_schema = record({ + max_attempts = wait_fields.max_attempts, interval_millis = wait_fields.interval_millis, + checkpoint = checkpoint_type, describe_timeout_millis = integer(1, 10000), +}, { checkpoint = true, describe_timeout_millis = true }) +local history_fields = { + filter = text, max_pages = integer(1, 128), max_events = integer(0, 16384), + max_bytes = integer(0, 16777216), timeout_millis = integer(1, 65000), + checkpoint = checkpoint_type, +} +local history_schema = record(history_fields, { checkpoint = true }) +local result_schema = record({ + max_pages = history_fields.max_pages, max_events = history_fields.max_events, + max_bytes = history_fields.max_bytes, timeout_millis = history_fields.timeout_millis, + checkpoint = checkpoint_type, +}, { checkpoint = true }) +local json_schema = record({ max_bytes = integer(0, 2097152) }, { max_bytes = true }) + +local function valid(value, schema, budget) + return walk(value, schema, budget, 1) +end +local function snapshot(value, schema) + local saved = {} + for key in next, schema.fields do saved[key] = value[key] end + return saved +end +local function checkpoint(options) + if options.checkpoint then options.checkpoint() end +end +local function retryable(err) + return plain(err) and err.kind == "server-status" and plain(err.server) + and err.server.code == 5 and err.server.class == "not-found" +end + +local function poll(request, options, operation, budget) + for attempt = 1, options.max_attempts do + checkpoint(options) + local value, err = rpc("describe-workflow-execution", request) + if err ~= nil and not retryable(err) then return nil, err end + checkpoint(options) + if err == nil then + if not walk(value, describe_response, budget, 1) then + return failure(budget.code, operation, attempt) + end + if value.status.number ~= 1 then return value, nil end + end + if attempt == options.max_attempts then + return failure("poll-exhausted", operation, attempt) + end + checkpoint(options) + sigil.sleep(options.interval_millis / 1000) + checkpoint(options) + end +end + +function M.wait_after_start(request, options) + local budget = state() + if not valid(options, wait_schema, budget) or not valid(request, describe_request, budget) then + return failure("invalid-option", "wait_after_start") + end + return poll(snapshot(request, describe_request), snapshot(options, wait_schema), + "wait_after_start", budget) +end + +function M.run(request, options) + local budget = state() + if not valid(options, run_schema, budget) or not valid(request, start_request, budget) or #request.payloads ~= 2 then + return failure("invalid-option", "run") + end + options = snapshot(options, run_schema) + -- Capture the bounded Start before calling user checkpoints. Scalar strings + -- are immutable; copy only the two payload/metadata table shapes, not bytes. + local saved = snapshot(request, start_request) + saved.payloads = {} + for index = 1, 2 do + local input = request.payloads[index] + local entries = {} + for item = 1, #input.metadata do + entries[item] = { key = input.metadata[item].key, value = input.metadata[item].value } + end + saved.payloads[index] = { data = input.data, metadata = entries } + end + request = saved + local describe = { + profile = request.profile, namespace = request.namespace, + ["workflow-id"] = request["workflow-id"], + ["timeout-millis"] = options.describe_timeout_millis or 10000, + } + checkpoint(options) + local started, err = rpc("start-workflow-execution", request) + if err ~= nil then return nil, err end + checkpoint(options) + if not valid(started, start_response, budget) or started.effect ~= "applied" then + return failure(budget.code, "run") + end + return poll(describe, options, "run", budget) +end + +local function traverse(selector, options, filter, operation, budget) + -- Nodes include input validation; max_bytes counts returned page strings only. + budget.bytes = 0 + local events, seen, token = {}, {}, "" + for page_number = 1, options.max_pages do + checkpoint(options) + local page, err = rpc("get-workflow-execution-history", { + profile = selector.profile, namespace = selector.namespace, + ["workflow-id"] = selector["workflow-id"], filter = filter, + ["wait-new-event"] = filter == "close-event", ["skip-archival"] = filter == "close-event", + ["next-page-token"] = token, ["timeout-millis"] = options.timeout_millis, + }) + if err ~= nil then return nil, err end + checkpoint(options) + if not walk(page, page_schema, budget, 1) then + return failure(budget.code, operation, nil, page_number) + end + token = page["next-page-token"] + if token ~= "" and seen[token] then + return failure("repeated-token", operation, nil, page_number) + end + if #events + #page.events > options.max_events then + return failure("event-exhausted", operation, nil, page_number) + end + if budget.bytes > options.max_bytes then + return failure("byte-exhausted", operation, nil, page_number) + end + for index = 1, #page.events do events[#events + 1] = page.events[index] end + if token == "" then + return { events = events, pages = page_number, bytes = budget.bytes }, nil + end + seen[token] = true + if page_number == options.max_pages then + return failure("page-exhausted", operation, nil, page_number) + end + end +end + +local function history_options(selector, options, is_result, budget) + if not valid(selector, selector_schema, budget) + or not valid(options, is_result and result_schema or history_schema, budget) then return false end + local filter = is_result and "close-event" or options.filter + return (filter == "close-event" or filter == "all-events") + and (filter ~= "all-events" or options.timeout_millis <= 10000) +end + +function M.history(selector, options) + local budget = state() + if not history_options(selector, options, false, budget) then + return failure("invalid-option", "history") + end + return traverse(snapshot(selector, selector_schema), snapshot(options, history_schema), + options.filter, "history", budget) +end + +local terminal = { [2] = true, [3] = true, [4] = true, [21] = true, [27] = true, [28] = true } +function M.result(selector, options) + local budget = state() + if not history_options(selector, options, true, budget) then return failure("invalid-option", "result") end + local history, err = traverse(snapshot(selector, selector_schema), snapshot(options, result_schema), + "close-event", "result", budget) + if err ~= nil then return nil, err end + local completion, terminal_count = nil, 0 + for index = 1, #history.events do + local item = history.events[index] + local number, tag = item["event-type"].number, item.details.tag + if (number == 2) ~= (tag == "workflow-completed") then + return failure("malformed-result", "result", nil, history.pages) + end + if terminal[number] then terminal_count = terminal_count + 1 end + if terminal_count > 1 then return failure("malformed-result", "result", nil, history.pages) end + if number == 2 then completion = item end + end + if completion == nil then return { tag = "not-completed", history = history }, nil end + return { + tag = "completed", payloads = completion.details.value, event = completion, history = history, + }, nil +end + +function M.decode_json(value, options) + if options == nil then options = {} end + local budget = state() + if not valid(options, json_schema, budget) then return failure("invalid-option", "decode_json") end + if not walk(value, payload, budget, 1) then + return failure(budget.code, "decode_json") + end + local encoding, count = nil, 0 + for index = 1, #value.metadata do + local entry = value.metadata[index] + if entry.key == "encoding" then encoding, count = entry.value, count + 1 end + end + if count ~= 1 or encoding ~= "json/plain" then + return failure("unsupported-encoding", "decode_json") + end + return sigil.json.decode(value.data, { max_bytes = options.max_bytes or 2097152 }), nil +end + +return M diff --git a/examples/operator-grant.toml b/examples/operator-grant.toml new file mode 100644 index 0000000..46a19df --- /dev/null +++ b/examples/operator-grant.toml @@ -0,0 +1,75 @@ +#:schema https://runsigil.com/schemas/sigil-config.schema.json +# TEMPLATE ONLY. Replace every replace-me value after reviewing your service. +# No endpoint binding, credential, scenario or lock is supplied. Do not use this +# as production authority without reviewing the workflow prefix and budgets. +default_service = "replace-me-service" + +[deploy] +backend = "external" + +[plugins.require] +temporal = "=0.1.0" # Official-source shorthand; the lock records provenance. + +[plugins.trust.capability_allowlist] +grpc-unary = ["github:sigil-plugins/temporal"] +# Omitted capability allowlists inherit broad official-source defaults. +network = [] +secrets = [] +random = [] +entropy = [] +log = [] +sigv4 = [] + +# The outer-call limit must not silently truncate a 65-second close-event call. +# These are project-wide plugin runtime limits. Review other plugins separately. +[plugins.runtime] +max_call_seconds = 70 +max_memory = "256MiB" + +# Routing input for the semantic host, NOT raw network authority for the guest. +[plugins.grants.temporal.network.workflow] +target = "replace-me-temporal.sql:7233" +tls = "direct" +tls_server_name = "replace-me-temporal.example.invalid" +connect_timeout = "5s" +io_timeout = "70s" +max_connections = 1 +# Cumulative bidirectional plaintext HTTP/2 bytes, not a per-RPC allowance. +max_bytes = "16MiB" + +[plugins.grants.temporal.grpc.workflow] +endpoint = "workflow" +transport = "h2-tls" +authority = "replace-me-temporal.example.invalid:7233" +max_request_bytes = "1MiB" +max_response_bytes = "4MiB" +max_call_seconds = 65 +request_metadata = [ + { name = "temporal-namespace", value = "replace-me-namespace" }, + { name = "supported-server-versions", value = ">=1.0.0 <2.0.0" }, + { name = "caller-type", value = "operator" }, + { name = "client-name", value = "sigil-temporal" }, + { name = "client-version", value = "0.1.0" }, +] +response_metadata = [] + +[plugins.grants.temporal.grpc.workflow.request_policy] +kind = "temporal-workflow-v0" +namespace = "replace-me-namespace" +workflow_id_prefix = "replace-me-run-" +workflow_type = "ReplaceMeWorkflow" +task_queue = "replace-me-queue" +# Fixed plugin identity, NOT a placeholder or the release-candidate version. +identity = "sigil-temporal@0.1.0" + +[plugins.grants.temporal.grpc.workflow.rpcs.start] +path = "/temporal.api.workflowservice.v1.WorkflowService/StartWorkflowExecution" +kind = "mutation" + +[plugins.grants.temporal.grpc.workflow.rpcs.describe] +path = "/temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution" +kind = "read" + +[plugins.grants.temporal.grpc.workflow.rpcs.history] +path = "/temporal.api.workflowservice.v1.WorkflowService/GetWorkflowExecutionHistory" +kind = "read" diff --git a/plugin.toml b/plugin.toml index 9c347bb..74ecc3c 100644 --- a/plugin.toml +++ b/plugin.toml @@ -1,7 +1,7 @@ -# Stable release manifest; publication remains an independently reviewed gate. +# Unpublished candidate manifest; publication remains an independently reviewed gate. schema_version = 4 name = "temporal" -version = "0.1.0" +version = "0.1.1-rc.1" description = "Bounded Temporal WorkflowService client" license = "MIT" @@ -10,7 +10,7 @@ file = "target/component/temporal.wasm" entrypoint = "sigil:temporal/client@0.1.0" [requires] -sigil = ">=0.35.0, <0.36.0" +sigil = ">=0.35.0" host_api = "=1.3.0" [capabilities] diff --git a/scripts/check-temporal-host.py b/scripts/check-temporal-host.py new file mode 100644 index 0000000..23519fb --- /dev/null +++ b/scripts/check-temporal-host.py @@ -0,0 +1,46 @@ +"""Real decoder boundary on the pinned host and an existing official project. + +Does not install/acquire plugins, add grants, edit the project, or contact Temporal. +Normal Sigil loading still requires the module's declared wasm.temporal capability. +""" +import argparse +import importlib.util +import json +from pathlib import Path +import subprocess +import sys + +sys.dont_write_bytecode = True + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location("host_identity", ROOT / "scripts/release-identity.py") +identity = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(identity) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sigil", type=Path, required=True) + parser.add_argument("--project", type=Path, required=True) + args = parser.parse_args() + project = args.project.resolve(strict=True) + if not (project / ".sigil/sigil.plugins.lock").is_file(): + parser.error("--project must have a normal, already-synced official Temporal lock") + _, expected = identity.load_spec(ROOT) + + def run(*command): + result = subprocess.run(command, cwd=project, capture_output=True, text=True, check=True) + return result.stdout + + output = identity.run_checked( + args.sigil.resolve(strict=True), expected, run, "run", + str(ROOT / "tests/temporal_host.lua"), "--lib-dir", str(ROOT / "examples/lib"), "--json", + ) + report = json.loads(output) + if report["status"] != "passed" or report["total"] != 1 or report["failed"] != 0: + raise RuntimeError("companion host decoder boundary did not pass") + print(output, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/rebuild-protobuf.sh b/scripts/rebuild-protobuf.sh index a1e70b7..6d09a42 100755 --- a/scripts/rebuild-protobuf.sh +++ b/scripts/rebuild-protobuf.sh @@ -92,11 +92,20 @@ for source in "$STAGE"/responses/source/*.textproto; do start-*) kind=StartWorkflowExecutionResponse ;; describe-*) kind=DescribeWorkflowExecutionResponse ;; history-*) kind=GetWorkflowExecutionHistoryResponse ;; - future-fields) continue ;; + future-fields|started-false) continue ;; *) echo "unknown response fixture $source" >&2; exit 1 ;; esac encode "$source" "$STAGE/responses/$name.pb" "$kind" done +# Official proto3 encoding erases the distinction between omitted and false. +cmp "$STAGE/responses/start-omitted.pb" "$STAGE/responses/start-false.pb" +# A test-only proto2 presence fragment emits the legal explicit-false wire +# representation; it is not an independent oracle for Temporal field numbers. +"$PROTOC" --proto_path=conformance/responses/source \ + --encode=sigil.temporal.conformance.StartedPresence started-presence.proto \ + < conformance/responses/source/started-false.textproto > "$STAGE/started-false.pb" +cat "$STAGE/responses/start-omitted.pb" "$STAGE/started-false.pb" \ + > "$STAGE/responses/start-wire-false.pb" # A separately compiled synthetic future field tests protobuf forward # compatibility. It does not redefine or copy any official message field tag. "$PROTOC" --proto_path=conformance/responses/source \ diff --git a/scripts/release-pack.py b/scripts/release-pack.py index a880842..2c80f3f 100644 --- a/scripts/release-pack.py +++ b/scripts/release-pack.py @@ -25,14 +25,14 @@ def validate_manifest(data): parsed = tomllib.loads(data.decode("utf-8")) version = parsed.get("version") - if not isinstance(version, str) or not re.fullmatch(r"0\.1\.0(?:-rc\.[1-9][0-9]*)?", version): - raise ValueError("release version must be 0.1.0 or 0.1.0-rc.N") - if parsed.get("requires") != {"sigil": ">=0.35.0, <0.36.0", "host_api": "=1.3.0"}: - raise ValueError("release requires supporting stable Sigil 0.35 and Host API 1.3") + if not isinstance(version, str) or not re.fullmatch(r"0\.1\.1(?:-rc\.[1-9][0-9]*)?", version): + raise ValueError("release version must be 0.1.1 or 0.1.1-rc.N") + if parsed.get("requires") != {"sigil": ">=0.35.0", "host_api": "=1.3.0"}: + raise ValueError("release requires Sigil >=0.35.0 and exact Host API 1.3.0") # Reuse the closed identity/path/capability validator, without granting # local manifests any release authority or relaxing their version check. normalized = data.replace(f'version = "{version}"'.encode(), b'version = "0.1.0-dev.1"') - normalized = normalized.replace(b'sigil = ">=0.35.0, <0.36.0"', b'sigil = "=0.34.0"') + normalized = normalized.replace(b'sigil = ">=0.35.0"', b'sigil = "=0.34.0"') local.validate_manifest(normalized) return parsed @@ -70,7 +70,9 @@ def pack(root, output, sigil, source_commit): contracts.check_component(stage / local.COMPONENT, stage) host_identity.run_checked(sigil, binary_sha, contracts.run, "plugin", "validate", str(stage / "plugin.toml")) tar = local.member("plugin.toml", manifest_bytes) + local.member(local.COMPONENT, component_bytes) + bytes(1024) - result = subprocess.run([zstd, "-q", "-10", "--check", "-c"], input=tar, + # Match Sigil's single-threaded streaming encoder. CLI default -T1 + # uses a worker and can emit different bytes for multi-block inputs. + result = subprocess.run([zstd, "--single-thread", "-q", "-10", "--check", "-c"], input=tar, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode: raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr) diff --git a/src/proto.rs b/src/proto.rs index 512329e..ec9e301 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -109,6 +109,49 @@ mod tests { } } + #[test] + fn protoc_start_presence_oracles_preserve_boolean_value() { + // Bind the synthetic proto2 fragment to the descriptor generated from + // the pinned official schema, not to the plugin's request encoder. + let started = super::wire::MESSAGE_SCHEMA[super::wire::START_RESPONSE] + .fields + .iter() + .find(|field| field.name == "started") + .expect("official started field"); + assert_eq!(started.number, 3); + assert_eq!(started.kind, super::wire::Kind::Varint); + assert!(!started.repeated); + + let omitted = include_bytes!("../conformance/responses/start-omitted.pb").as_slice(); + let source_false = include_bytes!("../conformance/responses/start-false.pb").as_slice(); + let wire_false = include_bytes!("../conformance/responses/start-wire-false.pb").as_slice(); + assert_eq!(omitted, source_false, "proto3 omits default false"); + assert_ne!(omitted, wire_false, "fixture must exercise wire presence"); + assert_eq!( + wire_false.strip_prefix(omitted), + Some([0x18, 0x00].as_slice()), + "protoc fragment must encode field 3, varint false" + ); + for (bytes, expected) in [ + (omitted, false), + (source_false, false), + (wire_false, false), + ( + include_bytes!("../conformance/responses/start-success.pb").as_slice(), + true, + ), + ] { + let response = StartWorkflowExecutionResponse::decode(bytes).expect("start oracle"); + assert_eq!(response.started, expected); + assert_eq!(response.status, 1); + assert_eq!(response.run_id, "11111111-2222-3333-4444-555555555555"); + if !expected { + // Generated proto3 API does not preserve wire presence. + assert_eq!(response.encode_to_vec(), omitted); + } + } + } + #[test] fn protoc_start_and_describe_oracles_preserve_status_numbers() { for (bytes, started, status) in [ diff --git a/tests/client_contract.rs b/tests/client_contract.rs index 8f2a438..94b06e5 100644 --- a/tests/client_contract.rs +++ b/tests/client_contract.rs @@ -142,6 +142,45 @@ fn request_bytes_match_independent_protoc_oracles_and_exchange_once() { } } +#[test] +fn start_success_effect_does_not_prove_a_new_execution() { + for (bytes, started) in [ + ( + include_bytes!("../conformance/responses/start-omitted.pb").as_slice(), + false, + ), + ( + include_bytes!("../conformance/responses/start-false.pb").as_slice(), + false, + ), + ( + include_bytes!("../conformance/responses/start-wire-false.pb").as_slice(), + false, + ), + (START_SUCCESS, true), + ( + include_bytes!("../conformance/responses/start-existing.pb").as_slice(), + false, + ), + ] { + let mut host = FakeHost::bytes(bytes); + let response = client::start_workflow_execution(&mut host, start_request()) + .expect("well-formed gRPC success with a run ID"); + assert_eq!(response.started, started); + assert_eq!(response.effect, api::MutationEffect::Applied); + assert_eq!( + host.calls.len(), + 1, + "no replay to resolve an ambiguous boolean" + ); + // This is the CAPI guard from findings #7. It never rejects any + // successful Start result, including the existing-execution fixture. + let capi_duplicate_guard = + !response.started && response.effect != api::MutationEffect::Applied; + assert!(!capi_duplicate_guard); + } +} + #[test] fn start_existing_and_future_status_are_not_reinterpreted() { for (bytes, started, number, known) in [ diff --git a/tests/temporal.lua b/tests/temporal.lua new file mode 100644 index 0000000..7855989 --- /dev/null +++ b/tests/temporal.lua @@ -0,0 +1,417 @@ +-- Offline recording tests, not real-host/server acceptance. +local script = assert(arg[1]) +local cases = 0 +local function check(name, test) + test(); cases = cases + 1; print("PASS " .. name) +end +local function copy(t) + local out = {}; for k, v in next, t do out[k] = v end; return out +end +local selector = { profile = "workflow", namespace = "fixture", ["workflow-id"] = "fixture-1" } +local describe = copy(selector); describe["timeout-millis"] = 10000 +local function payload(data, metadata) + return { data = data or "", metadata = metadata or {{ key = "encoding", value = "json/plain" }} } +end +local start = copy(describe) +start["workflow-type"], start["task-queue"], start["request-id"] = "Fixture", "fixture", "one-id" +start.payloads = { payload("{}"), payload("{}") } +local started = { ["run-id"] = "run-1", status = { number = 1 }, started = true, effect = "applied" } +local running = { ["run-id"] = "run-1", status = { number = 1 } } +local completed = { ["run-id"] = "run-1", status = { number = 2 } } +local not_found = { kind = "server-status", server = { code = 5, class = "not-found" } } +local wait = { max_attempts = 180, interval_millis = 1000 } +local function opts(filter) + return { filter = filter or "all-events", max_pages = 128, max_events = 16384, + max_bytes = 16777216, timeout_millis = filter == "close-event" and 65000 or 10000 } +end +local function result_opts() + local out = opts("close-event"); out.filter = nil; return out +end +local function event(n, details) + return { ["event-id"] = 9007199254740993, ["task-id"] = math.mininteger, + ["event-time"] = { seconds = 1700000000, nanos = 123456789 }, + ["event-type"] = { number = n }, details = details or { tag = "other" } } +end +local function page(events, token) + return { events = events or {}, ["next-page-token"] = token or "" } +end +local function fixture(handlers) + handlers = handlers or {} + local counts = { start = 0, describe = 0, history = 0, sleep = 0, decode = 0, modules = 0 } + local calls, exports = {}, {} + for short, name in next, { start = "start-workflow-execution", describe = "describe-workflow-execution", + history = "get-workflow-execution-history" } do + exports[name] = function(request) + counts[short] = counts[short] + 1; calls[#calls + 1] = { name = short, request = request } + return assert(handlers[short], "unexpected " .. short)(request, counts[short]) + end + end + local env = { + require = function(name) + assert(name == "wasm.temporal"); counts.modules = counts.modules + 1; return exports + end, + sigil = { + sleep = function(seconds) + counts.sleep = counts.sleep + 1; if handlers.sleep then handlers.sleep(seconds) end + end, + json = { decode = function(data, options) + counts.decode = counts.decode + 1 + return assert(handlers.decode, "unexpected decode")(data, options) + end }, + }, + } + setmetatable(env, { __index = _G }) + return assert(loadfile(script, "t", env))(), counts, calls +end +local function denied(value, err, code, operation) + assert(value == nil and err.kind == "companion" and err.code == code, code) + assert(err.operation == operation and err.effect == nil and err.server == nil and err.status == nil) +end +check("exact API and lazy WASM acquisition", function() + local m, calls = fixture(); local n = 0 + for key in next, m do + assert(({ run = true, wait_after_start = true, history = true, result = true, decode_json = true })[key]) + n = n + 1 + end + assert(n == 5 and calls.modules == 0) +end) +check("one Start, exact request, no run-ID selection", function() + local m, calls = fixture({ + start = function(r) + assert(r["request-id"] == "one-id" and r["workflow-id"] == start["workflow-id"]) + assert(r.payloads[1].data == start.payloads[1].data) + assert(r.payloads[2].metadata[1].value == start.payloads[2].metadata[1].value) + return started + end, + describe = function(r) + assert(r["workflow-id"] == selector["workflow-id"] and r["run-id"] == nil) + assert(r["timeout-millis"] == 9000); return completed + end, + }) + local options = copy(wait); options.describe_timeout_millis = 9000 + assert(m.run(start, options) == completed and calls.start == 1 and calls.describe == 1 and calls.sleep == 0) +end) +check("false started and long output run ID preserve success", function() + local response = copy(started); response.started = false; response["run-id"] = string.rep("r", 1024) + local terminal = copy(completed); terminal["run-id"] = response["run-id"] + local m = fixture({ start = function() return response end, describe = function() return terminal end }) + assert(m.run(start, wait) == terminal) +end) +check("Start and Describe stay on captured values despite checkpoint mutation", function() + local request=copy(start); request.payloads={payload("{}"),payload("[]")} + local options=copy(wait) + options.checkpoint=function() + request["workflow-id"]="changed"; request.profile="changed"; request["request-id"]="changed" + request.payloads[1].data="changed"; request.payloads[2].metadata[1].value="changed" + end + local m,calls=fixture({ + start=function(r) + assert(r["workflow-id"]==selector["workflow-id"] and r.profile==selector.profile) + assert(r["request-id"]=="one-id" and r.payloads[1].data=="{}") + assert(r.payloads[2].metadata[1].value=="json/plain"); return started + end, + describe=function(r) + assert(r["workflow-id"]==selector["workflow-id"] and r.profile==selector.profile); return completed + end, + }) + assert(m.run(request,options)==completed and calls.start==1 and calls.describe==1) +end) +check("only RUNNING and exact post-start NOT_FOUND retry", function() + local m, calls = fixture({ describe = function(_, n) + if n == 1 then return nil, not_found end; return n == 2 and running or completed + end, sleep = function(seconds) assert(seconds == 1) end }) + assert(m.wait_after_start(describe, wait) == completed and calls.describe == 3 and calls.sleep == 2) +end) +for _, n in next, { 0, 2, 3, 4, 5, 6, 7, 777 } do + check("terminal/unknown number " .. n, function() + local terminal = { ["run-id"] = "run-1", status = { number = n } } + local m, calls = fixture({ describe = function() return terminal end }) + local options = copy(wait); options.max_attempts = 1 + assert(m.wait_after_start(describe, options) == terminal and calls.describe == 1 and calls.sleep == 0) + end) +end +for _, n in next, { 1, 3, 4, 5, 7, 8, 13, 14, 16, 99 } do + check("nonretryable server identity " .. n, function() + local err = { kind = "server-status", server = { code = n, class = "unknown" } } + local m, calls = fixture({ describe = function() return nil, err end }) + local value, actual = m.wait_after_start(describe, wait) + assert(value == nil and actual == err and calls.describe == 1 and calls.sleep == 0) + end) +end +for _, response in next, { "running", "not-found" } do + for _, limit in next, { 1, 180 } do + check("bounded exhaustion " .. response .. " " .. limit, function() + local m, calls = fixture({ describe = function() + if response == "running" then return running end; return nil, not_found + end }) + local options = copy(wait); options.max_attempts = limit + local value, err = m.wait_after_start(describe, options) + denied(value, err, "poll-exhausted", "wait_after_start") + assert(err.attempts == limit and calls.describe == limit and calls.sleep == limit - 1) + end) + end +end +check("unknown mutation and infrastructure never retry", function() + local err = { kind = "infrastructure", effect = "unknown" } + local m, calls = fixture({ start = function() return nil, err end }) + local value, actual = m.run(start, wait) + assert(value == nil and actual == err and calls.start == 1 and calls.describe == 0) + m, calls = fixture({ describe = function() return nil, err end }) + value, actual = m.wait_after_start(describe, wait) + assert(value == nil and actual == err and calls.describe == 1 and calls.sleep == 0) +end) +for _, boundary in next, { "rpc", "sleep", "before", "after" } do + check("exception propagation " .. boundary, function() + local marker, checkpoints = {}, 0 + local options = copy(wait); options.max_attempts = boundary == "sleep" and 2 or 1 + options.checkpoint = function() + checkpoints = checkpoints + 1 + if boundary == "before" or (boundary == "after" and checkpoints == 2) then error(marker) end + end + local m, calls = fixture({ + describe = function() if boundary == "rpc" then error(marker) end; return running end, + sleep = function() error(marker) end, + }) + local ok, err = pcall(m.wait_after_start, describe, options) + assert(not ok and err == marker and calls.describe <= 1) + end) +end +check("returned plugin error precedes cancellation checkpoint", function() + local err, n = { kind = "infrastructure" }, 0 + local options = copy(wait); options.checkpoint = function() n = n + 1; assert(n == 1) end + local m = fixture({ describe = function() return nil, err end }) + local value, actual = m.wait_after_start(describe, options) + assert(value == nil and actual == err and n == 1) +end) +check("invalid options cause zero RPCs", function() + for key, values in next, { max_attempts = {0,181,1.0,math.huge,false}, + interval_millis = {-1,60001,0.5,"1"}, checkpoint = {true,{}}, + describe_timeout_millis = {0,10001}, unknown = {1} } do + for _, bad in next, values do + local options = copy(wait); options[key] = bad + local m, calls = fixture() + local value, err = m.run(start, options) + denied(value, err, "invalid-option", "run"); assert(calls.start == 0) + end + end + local m = fixture(); local options = copy(wait); options.describe_timeout_millis = 1 + local value, err = m.wait_after_start(describe, options) + denied(value, err, "invalid-option", "wait_after_start") +end) +for _, filter in next, {"all-events","close-event"} do + check("canonical flags and exact binary token " .. filter, function() + local first, second, token = event(777), event(888), "\0token\255" + local m, calls = fixture({ history = function(r,n) + assert(r.filter == filter and r["wait-new-event"] == (filter == "close-event")) + assert(r["skip-archival"] == (filter == "close-event") and r["run-id"] == nil) + assert(r["next-page-token"] == (n == 1 and "" or token)) + assert(r["timeout-millis"] == (filter == "close-event" and 65000 or 10000)) + return n == 1 and page({first},token) or page({second}) + end }) + local value, err = m.history(selector,opts(filter)) + assert(err == nil and value.events[1] == first and value.events[2] == second and calls.history == 2) + assert(value.events[1]["event-id"] == 9007199254740993) + end) +end +check("empty pages and inclusive page limit", function() + for _, limit in next, {1,128} do for _, final in next, {true,false} do + local m,calls = fixture({ history = function(_,n) return page({}, final and n == limit and "" or tostring(n)) end }) + local options = opts(); options.max_pages = limit + local value,err = m.history(selector,options) + if final then assert(value.pages == limit and #value.events == 0) + else denied(value,err,"page-exhausted","history") end + assert(calls.history == limit) + end end +end) +for _, tokens in next, {{"A","A"},{"A","B","A"}} do + check("cycle tokens " .. #tokens, function() + local m,calls = fixture({ history = function(_,n) return page({},tokens[n]) end }) + local options = opts(); options.max_pages = #tokens + local value,err = m.history(selector,options) + denied(value,err,"repeated-token","history"); assert(calls.history == #tokens) + end) +end +check("History never retries NOT_FOUND", function() + local m,calls = fixture({ history = function() return nil,not_found end }) + local value,err = m.history(selector,opts()); assert(value == nil and err == not_found and calls.history == 1) +end) +check("History checkpoint takes precedence after final response", function() + for _,token in next, {"","next"} do + local marker,n = {},0; local options = opts(); options.max_pages = 1 + options.checkpoint = function() n=n+1; if n==2 then error(marker) end end + local m,calls = fixture({ history = function() return page({},token) end }) + local ok,err = pcall(m.history,selector,options) + assert(not ok and err==marker and calls.history==1) + end +end) +check("History options closed, bounded and no invented run selector", function() + for key,values in next, {filter={"unknown",false},max_pages={0,129},max_events={-1,16385}, + max_bytes={-1,16777217},timeout_millis={0,10001,65001}, + ["wait-new-event"]={false},["skip-archival"]={false},["next-page-token"]={""}} do + for _,bad in next,values do + local options=opts(); options[key]=bad; local m,calls=fixture() + local value,err=m.history(selector,options); denied(value,err,"invalid-option","history") + assert(calls.history==0) + end + end + local wrong=copy(selector); wrong["run-id"]="invented"; local m=fixture() + local value,err=m.history(wrong,opts()); denied(value,err,"invalid-option","history") +end) +check("exact byte accounting and aliases preserve every occurrence", function() + local p=payload("\0\255",{{key="encoding",value="raw"},{key="x",value="y"}}) + local done=event(2,{tag="workflow-completed",value={p,p}}); done["event-type"].label="LABEL" + local failed=event(3,{tag="workflow-failed",value={{message="m",source="s",["activity-type"]="a"}}}) + local activity=event(10,{tag="activity-scheduled",value="Act"}) + local expected=#"LABEL"+#"workflow-completed"+2*(2+#"encoding"+#"raw"+2)+#"workflow-failed"+3+#"activity-scheduled"+3 + for _,delta in next,{0,-1} do + local m=fixture({history=function() return page({done,failed,activity}) end}) + local options=opts(); options.max_bytes=expected+delta + local value,err=m.history(selector,options) + if delta==0 then assert(value.bytes==expected and value.events[1]==done and done.details.value[1]==p) + else denied(value,err,"byte-exhausted","history") end + assert(p.data=="\0\255") + end +end) +check("zero and inclusive event/byte bounds", function() + local options=opts(); options.max_events=0; options.max_bytes=0 + local m=fixture({history=function() return page() end}); assert(m.history(selector,options).bytes==0) + m=fixture({history=function() return page({},"x") end}) + local value,err=m.history(selector,options); denied(value,err,"byte-exhausted","history") + m=fixture({history=function() return page({event(777)}) end}); options.max_bytes=100 + value,err=m.history(selector,options); denied(value,err,"event-exhausted","history") + options.max_events=1; assert(#m.history(selector,options).events==1) +end) +local function hostile() error("metamethod executed") end +local malformed={ + sparse=function() return page({[2]=event(777)}) end, + foreign=function() return page({x=event(777)}) end, + oversized=function() return page({[math.maxinteger]=event(777)}) end, + record=function() local p=page(); p.extra=true; return p end, + cycle=function() local p=page(); p.events[1]=p; return p end, + metatable=function() return setmetatable(page(),{__index=hostile,__pairs=hostile}) end, + protected=function() return setmetatable(page(),{__metatable=false}) end, + nested=function() return page({event(2,{tag="workflow-completed",value={setmetatable(payload("x"),{})}})}) end, + payload=function() return page({event(2,{tag="workflow-completed",value=payload("x")})}) end, + variant=function() return page({event(777,{tag="unknown"})}) end, + recursive=function() local n={message="x"}; n.cause=n; return page({event(3,{tag="workflow-failed",value={n}})}) end, + token=function() return page({},string.rep("x",65537)) end, +} +for name,make in next,malformed do + check("malformed " .. name, function() + local m,calls=fixture({history=function() return make() end}) + local value,err=m.history(selector,opts()); denied(value,err,"malformed-result","history") + assert(calls.history==1) + end) +end +check("per-shape event/metadata/failure bounds", function() + for _,kind in next,{"events","metadata","failure"} do for _,extra in next,{0,1} do + local p,values + values={} + if kind=="events" then + for n=1,4096+extra do values[n]=event(777) end; p=page(values) + elseif kind=="metadata" then + for n=1,32+extra do values[n]={key=tostring(n),value=""} end + p=page({event(2,{tag="workflow-completed",value={payload("",values)}})}) + else + for n=1,16+extra do values[n]={message=""} end + p=page({event(3,{tag="workflow-failed",value=values})}) + end + local m=fixture({history=function() return p end}); local value,err=m.history(selector,opts()) + if extra==0 then assert(value and err==nil) else denied(value,err,"malformed-result","history") end + end end +end) +check("aggregate nodes count aliases and stop within pages", function() + local values,shared={},event(777); for n=1,4096 do values[n]=shared end + local m,calls=fixture({history=function(_,n) return page(values,tostring(n)) end}) + local value,err=m.history(selector,opts()); denied(value,err,"node-exhausted","history") + assert(calls.history==3 and err.pages==3) +end) +check("exact aggregate 100000-node boundary includes selector and options", function() + -- Input records:10. Page:3. Completion event:11. Other event:10. + -- Each empty payload:3. 10+3+11+10+33322*3 = 100000. + for _,extra in next,{false,true} do + local values,shared={},payload("",{}); for n=1,33322 do values[n]=shared end + local other=event(777); if extra then other["event-type"].label="" end + local response=page({event(2,{tag="workflow-completed",value=values}),other}) + local m=fixture({history=function() return response end}) + local options=opts(); options.max_pages=1 + local value,err=m.history(selector,options) + if extra then denied(value,err,"node-exhausted","history") + else assert(value and err==nil) end + end +end) +check("checkpoints cannot widen validated limits or change the captured selector", function() + local options=opts(); options.max_pages=1 + local selected=copy(selector) + options.checkpoint=function() + options.max_pages=128; options.max_bytes=math.maxinteger; selected["workflow-id"]="changed" + setmetatable(options,{__index=hostile}) + end + local m,calls=fixture({history=function(r) + assert(r["workflow-id"]==selector["workflow-id"]); return page({},"next") + end}) + local value,err=m.history(selected,options) + denied(value,err,"page-exhausted","history"); assert(calls.history==1) +end) +check("completed zero/one/multiple ordered raw payloads", function() + for _,values in next,{{},{payload("")},{payload("false"),payload("\0\255")}} do + local done=event(2,{tag="workflow-completed",value=values}) + local m=fixture({history=function() return page({done}) end}) + local value,err=m.result(selector,result_opts()) + assert(err==nil and value.tag=="completed" and value.payloads==values and value.event==done) + assert(value.history.events[1]==done) + end +end) +check("late completion versus non-completion", function() + local done=event(2,{tag="workflow-completed",value={}}) + local m,calls=fixture({history=function(_,n) return n==1 and page({},"next") or page({done}) end}) + assert(m.result(selector,result_opts()).tag=="completed" and calls.history==2) + for _,number in next,{3,4,21,27,28,777} do + m=fixture({history=function() return page({event(number)}) end}) + assert(m.result(selector,result_opts()).tag=="not-completed") + end + m=fixture({history=function() return page() end}) + assert(m.result(selector,result_opts()).tag=="not-completed") +end) +check("completion mismatches and multiple terminal events", function() + local done=event(2,{tag="workflow-completed",value={}}) + for _,events in next,{{event(2)},{event(777,{tag="workflow-completed",value={}})}, + {done,done},{done,event(4)},{event(3),event(3)}} do + local m=fixture({history=function() return page(events) end}) + local value,err=m.result(selector,result_opts()); denied(value,err,"malformed-result","result") + end +end) +check("earlier completion does not hide later error or exhaustion", function() + local done=event(2,{tag="workflow-completed",value={}}); local err={kind="infrastructure"} + local m=fixture({history=function(_,n) if n==1 then return page({done},"next") end; return nil,err end}) + local value,actual=m.result(selector,result_opts()); assert(value==nil and actual==err) + m=fixture({history=function() return page({done},"next") end}) + local options=result_opts(); options.max_pages=1 + value,actual=m.result(selector,options); denied(value,actual,"page-exhausted","result") +end) +check("single decode preserves false/raw bytes and acquires no plugin", function() + for _,expected in next,{false,9007199254740993,"JSON-looking text",{}} do + local original=payload('"raw"') + local m,calls=fixture({decode=function(data,options) assert(data==original.data and options.max_bytes==99); return expected end}) + local value,err=m.decode_json(original,{max_bytes=99}) + assert(value==expected and err==nil and calls.decode==1 and calls.modules==0 and original.data=='"raw"') + end +end) +check("unsupported encoding or malformed payload never decodes", function() + for _,metadata in next,{{},{{key="encoding",value="binary/plain"}}, + {{key="encoding",value="json/plain"},{key="encoding",value="json/plain"}}} do + local m,calls=fixture(); local value,err=m.decode_json(payload("{}",metadata)) + denied(value,err,"unsupported-encoding","decode_json"); assert(calls.decode==0) + end + for _,input in next,{setmetatable(payload("{}"),{__metatable=false}), + payload("{}",{[2]={key="encoding",value="json/plain"}}),{data=1,metadata={}}} do + local m,calls=fixture(); local value,err=m.decode_json(input) + denied(value,err,"malformed-result","decode_json"); assert(calls.decode==0) + end +end) +check("decode exception identity, no fallback or second pass", function() + local marker={}; local m,calls=fixture({decode=function() error(marker) end}) + local original=payload("not json"); local ok,err=pcall(m.decode_json,original) + assert(not ok and err==marker and calls.decode==1 and original.data=="not json") +end) +print("PASS " .. cases .. " offline bounded companion cases") diff --git a/tests/temporal_host.lua b/tests/temporal_host.lua new file mode 100644 index 0000000..af8b91b --- /dev/null +++ b/tests/temporal_host.lua @@ -0,0 +1,50 @@ +-- Real Sigil decoder/sandbox boundary, no RPC or live Temporal acceptance. +local temporal = require("lib.temporal") +local function payload(data) + return { data = data, metadata = {{ key = "encoding", value = "json/plain" }} } +end +return { + title = "Temporal companion real JSON and plain-table boundary", + priority = "P0", + budget = { max_seconds = 10 }, + policy = { capabilities = { "wasm.temporal" } }, + run = function() + local value, err = temporal.decode_json(payload('{"n":9007199254740993,"null":null,"a":[],"o":{}}')) + expect(err == nil) + expect(value.n == 9007199254740993) + expect(value.null == sigil.json.null) + expect(sigil.data.kind(value.a) == "sequence") + expect(sigil.data.kind(value.o) == "mapping") + local false_value, false_error = temporal.decode_json(payload("false")) + expect(false_value == false and false_error == nil) + expect(temporal.decode_json(payload("9223372036854775807")) == math.maxinteger) + expect(temporal.decode_json(payload("-9223372036854775808")) == math.mininteger) + expect(temporal.decode_json(payload('"{\\"nested\\":1}"')) == '{"nested":1}') + for _, raw in ipairs({ + "0.5", "1e0", "9223372036854775808", "-9223372036854775809", + '{"x":1,"x":2}', '{} trailing', "", "\255", + }) do + local original = payload(raw) + local ok = pcall(temporal.decode_json, original) + expect(not ok) + expect(original.data == raw) + end + local small_ok = pcall(temporal.decode_json, payload("{}"), { max_bytes = 1 }) + expect(not small_ok) + local at_limit = temporal.decode_json(payload("{}"), { max_bytes = 2 }) + expect(sigil.data.kind(at_limit) == "mapping") + local bad = setmetatable(payload("{}"), { + __index = function() error("should not run") end, + __pairs = function() error("should not run") end, + __metatable = false, + }) + local denied, bad_error = temporal.decode_json(bad) + expect(denied == nil) + expect(bad_error.kind == "companion" and bad_error.code == "malformed-result") + expect(getmetatable(bad) == false) + local sparse = { data = "{}", metadata = { [2] = { key = "encoding", value = "json/plain" } } } + local absent, shape_error = temporal.decode_json(sparse) + expect(absent == nil and shape_error.code == "malformed-result") + expect(rawget == nil and rawset == nil) + end, +} diff --git a/tests/test_release_examples.py b/tests/test_release_examples.py new file mode 100644 index 0000000..5cdd42f --- /dev/null +++ b/tests/test_release_examples.py @@ -0,0 +1,127 @@ +"""Offline operator-template contracts; parser success is not service acceptance.""" + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import tomllib +import unittest + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLE = ROOT / "examples/operator-grant.toml" + + +class OperatorExampleTests(unittest.TestCase): + def test_fixed_values_match_the_frozen_contract(self): + config = tomllib.loads(EXAMPLE.read_text()) + contract = json.loads((ROOT / "conformance/contract.json").read_text()) + plugins = config["plugins"] + grant = plugins["grants"]["temporal"] + profile = grant["grpc"]["workflow"] + endpoint = grant["network"][profile["endpoint"]] + self.assertEqual(profile["rpcs"], { + alias: {key: rpc[key] for key in ("path", "kind")} + for alias, rpc in contract["rpcs"].items() + }) + self.assertEqual(profile["request_policy"]["identity"], contract["authority"]["identity"]) + self.assertEqual(profile["request_policy"]["kind"], contract["authority"]["request_validator"]) + metadata = {item["name"]: item["value"] for item in profile["request_metadata"]} + self.assertEqual(len(metadata), len(profile["request_metadata"])) + self.assertEqual(set(metadata), set(contract["authority"]["frozen_request_metadata"])) + self.assertEqual(metadata["temporal-namespace"], profile["request_policy"]["namespace"]) + close_seconds = contract["rpcs"]["history"]["close_event_timeout_millis"] // 1000 + self.assertEqual(profile["max_call_seconds"], close_seconds) + self.assertGreater(plugins["runtime"]["max_call_seconds"], close_seconds) + self.assertGreater(int(endpoint["io_timeout"].removesuffix("s")), close_seconds) + self.assertEqual(profile["max_response_bytes"], "4MiB") + self.assertEqual(4 * 1024 * 1024, contract["limits"]["response_bytes"]) + self.assertEqual(profile["max_request_bytes"], "1MiB") + self.assertLessEqual(1024 * 1024, contract["limits"]["request_bytes"]) + self.assertEqual(plugins["runtime"]["max_memory"], "256MiB") + # At least one ceiling-sized request and response plus framing fit. + self.assertGreater(int(endpoint["max_bytes"].removesuffix("MiB")), 1 + 4) + + def test_template_has_no_raw_guest_or_third_party_authority(self): + config = tomllib.loads(EXAMPLE.read_text()) + plugins = config["plugins"] + source = json.loads((ROOT / "conformance/contract.json").read_text())["identity"]["source"] + self.assertEqual(plugins["require"]["temporal"], "=0.1.0") + self.assertNotIn("allow_third_party", plugins) + self.assertEqual(plugins["trust"], {"capability_allowlist": { + "grpc-unary": [source], "network": [], "secrets": [], + "random": [], "entropy": [], "log": [], "sigv4": [], + }}) + grant = plugins["grants"]["temporal"] + self.assertEqual(set(grant), {"network", "grpc"}) + profile = grant["grpc"]["workflow"] + self.assertNotIn("bearer_secret", profile) + self.assertEqual(profile["transport"], "h2-tls") + self.assertEqual(profile["response_metadata"], []) + endpoint = grant["network"]["workflow"] + self.assertEqual(endpoint["tls"], "direct") + self.assertTrue(endpoint["tls_server_name"].endswith(".invalid")) + self.assertEqual(profile["authority"], endpoint["tls_server_name"] + ":7233") + self.assertTrue(endpoint["target"].startswith("replace-me-")) + self.assertNotIn("plugin_routes", config.get("eval", {})) + + @unittest.skipUnless(os.environ.get("SIGIL_RELEASE_BINARY"), + "set SIGIL_RELEASE_BINARY for pinned stable-host config parsing") + def test_public_host_policy_defaults_and_endpoint_validation(self): + binary = Path(os.environ["SIGIL_RELEASE_BINARY"]).resolve() + expected = json.loads((ROOT / "scripts/release-tools.json").read_text())["sigil_binary_sha256"] + + def check_binary(): + with binary.open("rb") as handle: + self.assertEqual(hashlib.file_digest(handle, "sha256").hexdigest(), expected) + + with tempfile.TemporaryDirectory(prefix="temporal-grant-test-") as scratch: + root = Path(scratch) + (root / ".sigil").mkdir() + config = root / ".sigil/sigil.toml" + env = {key: value for key, value in os.environ.items() + if not key.startswith("SIGIL_") and key not in ("GH_TOKEN", "GITHUB_TOKEN")} + env.update(SIGIL_DATA_DIR=str(root / "data"), SIGIL_CACHE_DIR=str(root / "cache")) + # Enumerate every capability and its default from this exact host, + # rather than assuming omitted TOML fields mean an empty allowlist. + check_binary() + try: + schema_result = subprocess.run([str(binary), "schema", "config"], cwd=root, + env=env, capture_output=True, text=True, timeout=30) + finally: + check_binary() + self.assertEqual(schema_result.returncode, 0, schema_result.stderr) + capability_schema = json.loads(schema_result.stdout)["$defs"][ + "PluginCapabilityAllowlistConfig"]["properties"] + defaults = {name: field["default"] for name, field in capability_schema.items()} + declared = tomllib.loads(EXAMPLE.read_text())["plugins"]["trust"]["capability_allowlist"] + self.assertEqual(set(declared), set(defaults), "all host capability fields must be explicit") + expected_policy = {name: [] for name in defaults} + expected_policy["grpc-unary"] = ["github:sigil-plugins/temporal"] + self.assertEqual(defaults | declared, expected_policy) + # Negative control: each omitted unused field would broaden the + # effective policy under the pinned host's actual schema defaults. + for name in set(defaults) - {"grpc-unary"}: + omitted = {key: value for key, value in declared.items() if key != name} + self.assertNotEqual(defaults | omitted, expected_policy) + # No --service flag: scenario list must load/validate the config to + # obtain default_service. It neither installs nor executes plugins. + for valid in (True, False): + with self.subTest(valid=valid): + text = EXAMPLE.read_text() + if not valid: + text = text.replace('endpoint = "workflow"', 'endpoint = "missing"') + config.write_text(text) + check_binary() + try: + result = subprocess.run([str(binary), "scenario", "list"], cwd=root, + env=env, capture_output=True, text=True, timeout=30) + finally: + check_binary() + if valid: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("No scenarios found for service: replace-me-service", result.stdout) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn("must name an existing network grant", result.stderr) diff --git a/tests/test_release_pack.py b/tests/test_release_pack.py index efbfb51..2b31c48 100644 --- a/tests/test_release_pack.py +++ b/tests/test_release_pack.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path +import re import subprocess import tempfile import unittest @@ -19,10 +20,11 @@ class ReleaseContractTests(unittest.TestCase): def test_release_manifest_keeps_local_and_official_versions_separate(self): data = (ROOT / "plugin.toml").read_bytes() - self.assertEqual(release.validate_manifest(data)["version"], "0.1.0") - version_line = b'version = "0.1.0"' - release.validate_manifest(data.replace(version_line, b'version = "0.1.0-rc.1"')) - for version in (b"0.1.0-dev.1", b"0.1.0-rc.0", b"0.1.0-rc.01", b"0.1.0+build", b"0.2.0"): + self.assertEqual(release.validate_manifest(data)["version"], "0.1.1-rc.1") + version_line = b'version = "0.1.1-rc.1"' + release.validate_manifest(data.replace(version_line, b'version = "0.1.1"')) + for version in (b"0.1.0", b"0.1.0-rc.1", b"0.1.0-dev.1", b"0.1.1-dev.1", + b"0.1.1-rc.0", b"0.1.1-rc.01", b"0.1.1+build", b"0.1.2", b"0.2.0"): with self.subTest(version=version), self.assertRaises(ValueError): release.validate_manifest(data.replace(version_line, b'version = "' + version + b'"')) with self.assertRaises(ValueError): @@ -32,8 +34,14 @@ def test_release_manifest_keeps_local_and_official_versions_separate(self): def test_release_manifest_requires_exact_support_and_capability(self): data = (ROOT / "plugin.toml").read_bytes() - for old, new in ((b">=0.35.0, <0.36.0", b">=0.34.0"), + self.assertEqual(release.validate_manifest(data)["requires"], + {"sigil": ">=0.35.0", "host_api": "=1.3.0"}) + for old, new in ((b">=0.35.0", b">=0.34.0"), + (b">=0.35.0", b">=0.35.0, <0.36.0"), + (b">=0.35.0", b"*"), + (b">=0.35.0", b"invalid"), (b"=1.3.0", b"=1.2.0"), + (b"=1.3.0", b"^1.3.0"), (b"grpc-unary = true", b"grpc-unary = true\nnetwork = true")): with self.subTest(new=new), self.assertRaises(ValueError): release.validate_manifest(data.replace(old, new)) @@ -49,7 +57,49 @@ def test_publication_workflow_is_exact_candidate_main_only_no_build(self): self.assertIn(required, publish) for forbidden in ("cargo build", "cargo install", "just release-dist", "pull_request_target"): self.assertNotIn(forbidden, publish) - self.assertIn('^0\\.1\\.0(-rc\\.[1-9][0-9]*)?$', publish) + self.assertIn('^0\\.1\\.1(-rc\\.[1-9][0-9]*)?$', publish) + for required in ('existing tag burns', 'existing release burns', + 'preexisting-attestations.json', 'test "$status" = 404', '--draft', + 'sha256sum --check --strict'): + self.assertIn(required, publish) + + def test_publisher_and_packer_admit_only_the_new_version_family(self): + publish = (ROOT / ".github/workflows/publish-release.yml").read_text() + rules = re.findall(r'^\s*(\[\[ "\$VERSION" =~ .* \]\])$', publish, re.MULTILINE) + self.assertEqual(len(rules), 1) + data = (ROOT / "plugin.toml").read_bytes() + for version in ("0.1.1", "0.1.1-rc.1", "0.1.1-rc.23", "0.1.0", "0.1.0-rc.1", + "0.1.1-dev.1", "0.1.1-rc.0", "0.1.1-rc.01", "0.1.1+build", + "0.1.1-rc.1+build", "v0.1.1", "0.1.2", "0.2.0", "0.1.1\n"): + with self.subTest(version=version): + # Execute only the workflow's version predicate, never its publisher. + admitted = subprocess.run(["bash", "-c", rules[0]], + env={**os.environ, "VERSION": version}, + capture_output=True, timeout=5).returncode == 0 + candidate = data.replace(b'version = "0.1.1-rc.1"', + f'version = "{version}"'.encode()) + if version in ("0.1.1", "0.1.1-rc.1", "0.1.1-rc.23"): + self.assertTrue(admitted) + self.assertEqual(release.validate_manifest(candidate)["version"], version) + else: + self.assertFalse(admitted) + with self.assertRaises(ValueError): + release.validate_manifest(candidate) + + def test_package_patch_keeps_the_frozen_client_and_minimum_validator(self): + manifest = release.validate_manifest((ROOT / "plugin.toml").read_bytes()) + crate = release.tomllib.loads((ROOT / "Cargo.toml").read_text()) + self.assertEqual(crate["package"]["version"], manifest["version"]) + contract = json.loads((ROOT / "conformance/contract.json").read_text()) + self.assertEqual(manifest["component"]["entrypoint"], contract["identity"]["entrypoint"]) + self.assertEqual(manifest["schema_version"], contract["identity"]["manifest_schema"]) + self.assertIn(f'const IDENTITY: &str = "{contract["authority"]["identity"]}";', + (ROOT / "src/client.rs").read_text()) + self.assertIn('package sigil:temporal@0.1.0;', (ROOT / "wit/temporal.wit").read_text()) + self.assertIn('import sigil:host/grpc-unary@1.3.0;', (ROOT / "wit/temporal.wit").read_text()) + host = json.loads((ROOT / "scripts/release-tools.json").read_text()) + self.assertEqual(host["sigil_version"], "0.35.0") + release.host_identity.validate_spec(host) @unittest.skipUnless(os.environ.get("SIGIL_RELEASE_BINARY"), @@ -104,6 +154,45 @@ def test_exact_canonical_identity_and_no_overwrite(self): with self.assertRaises(FileExistsError): release.pack(self.root, first.parent, self.sigil, self.commit) + def test_multiblock_component_matches_the_public_canonical_writer(self): + def leb(value): + encoded = bytearray() + while value >= 128: + encoded.append((value & 127) | 128) + value >>= 7 + encoded.append(value) + return bytes(encoded) + + component = self.root / release.local.COMPONENT + original = component.read_bytes() + # An inert custom section makes this a valid, multi-block component. + # Its deterministic mixed content exercises compression choices that + # the small dummy fixture does not. It adds no guest code or imports. + name = b"compression-parity-fixture" + padding = b"".join( + f"function-{index % 4001}: shared-prefix {index % 23} " + f"shared-suffix-{index % 1999}\n".encode() + for index in range(12000)) + section = leb(len(name)) + name + padding + enlarged = original + b"\x00" + leb(len(section)) + section + self.assertGreater(len(enlarged), 4 * 128 * 1024) + component.write_bytes(enlarged) + try: + first = release.pack(self.root, self.root / "dist-large-first", self.sigil, self.commit) + second = release.pack(self.root, self.root / "dist-large-second", self.sigil, self.commit) + self.assertEqual(first.read_bytes(), second.read_bytes()) + _, expected = release.host_identity.load_spec(self.root) + release.host_identity.run_checked(self.sigil.resolve(), expected, release.contracts.run, + "plugin", "pack", str(self.root / "plugin.toml"), + "--output-dir", str(self.root / "dist-large-canonical")) + canonical = self.root / "dist-large-canonical" / first.name + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), + hashlib.sha256(canonical.read_bytes()).hexdigest()) + with self.assertRaises(FileExistsError): + release.pack(self.root, first.parent, self.sigil, self.commit) + finally: + component.write_bytes(original) + def test_wrong_source_dirty_tree_and_old_host_fail_before_output(self): output = self.root / "dist-rejected" with self.assertRaises(ValueError):