diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ea7b67b..d81b8b4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -165,6 +165,7 @@ jobs: sdk/rust examples/data-migration examples/library/rust + examples/observation/rust - run: npm ci --ignore-scripts - name: Run every example fixture run: | @@ -185,6 +186,7 @@ jobs: "$RUNNER_TEMP/yskill" test examples/data-migration YSKILL="$RUNNER_TEMP/yskill" "$RUNNER_TEMP/yskill" test examples/convert-skill YSKILL="$RUNNER_TEMP/yskill" bash ./examples/library/test-all.sh + bash ./examples/observation/test-all.sh validate: name: Release authority and full validation diff --git a/docs/examples.md b/docs/examples.md index 37f6dc8..7f83ceb 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -33,6 +33,21 @@ Run all forty fixtures: The included commands produce harmless evidence so the examples run in this repository. Replace them with project commands before adopting a skill workflow. +## Observation readers + +The [RunReceipt reader examples](../examples/observation/) show how Go, +TypeScript, Python, and Rust consume the same already-materialized canonical +receipt. Each reader verifies the closed schema, canonical bytes, and receipt +digest before emitting an identical privacy-safe summary. + +These are standalone post-run consumers, not skill workflows. They do not read +journals, project receipts, participate in replay, or appear in the generated +forty-example skill library. + +```bash +bash ./examples/observation/test-all.sh +``` + ## Complete walkthroughs These examples show longer programs with a thin `SKILL.md` and scripted diff --git a/docs/reference/run-receipts.md b/docs/reference/run-receipts.md index cdaac93..896c7bd 100644 --- a/docs/reference/run-receipts.md +++ b/docs/reference/run-receipts.md @@ -69,6 +69,10 @@ profile, and verifies the embedded SHA-256 receipt digest. The SDKs share one Go-generated golden fixture. They do not read journals, project receipts, write receipt storage, or participate in replay. +The [four-language reader examples](../../examples/observation/) accept one +canonical receipt-object file and emit the same privacy-safe summary in Go, +TypeScript, Python, and Rust. + ## Privacy boundary Receipts do not contain prompts, instructions, model responses, user answers, diff --git a/examples/observation/README.md b/examples/observation/README.md new file mode 100644 index 0000000..2926147 --- /dev/null +++ b/examples/observation/README.md @@ -0,0 +1,44 @@ +# RunReceipt readers + +These standalone examples read and verify one already-materialized +`yield.observation.v1` receipt in Go, TypeScript, Python, and Rust. They are +post-run consumers, not skill workflows: the Go supervisor projects the +receipt after a foreground stopping point, and the language readers never read +the journal or participate in replay. + +Pass the exact bytes of a content-addressed receipt object. The input must be +canonical JSON with no transport newline or other trailing data. + +```bash +RECEIPT=/path/to/.yield/receipts/objects/sha256/ab/abcdef.json + +go run ./examples/observation/go "$RECEIPT" +node examples/observation/typescript/main.ts "$RECEIPT" +PYTHONDONTWRITEBYTECODE=1 python3 examples/observation/python/main.py "$RECEIPT" +cargo run --quiet --manifest-path examples/observation/rust/Cargo.toml -- "$RECEIPT" +``` + +Every example verifies the closed schema, canonical encoding, and embedded +SHA-256 digest before writing output. They emit the same privacy-safe subset: +the schema and receipt digest, run and skill identity, lifecycle phase, +terminal disposition, and operation summaries. Invalid input produces no +partial summary. + +The repository examples import the adjacent SDK sources so they always test +the current checkout. In an installed application, use the public packages: + +- Go: `github.com/operatorstack/yield/observation` +- TypeScript: `@operatorstack/yield` +- Python: `yieldskill` +- Rust: `yieldskill` + +Run all four readers, including strict rejection cases, from the repository +root: + +```bash +bash ./examples/observation/test-all.sh +``` + +The test harness removes the single JSONL framing newline from the shared +golden fixture before invoking the readers. The readers themselves never +normalize receipt bytes. diff --git a/examples/observation/go/main.go b/examples/observation/go/main.go new file mode 100644 index 0000000..c10f06a --- /dev/null +++ b/examples/observation/go/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/operatorstack/yield/observation" +) + +type receiptSummary struct { + Schema string `json:"schema"` + ReceiptDigest string `json:"receipt_digest"` + RunID string `json:"run_id"` + Skill string `json:"skill"` + Phase string `json:"phase"` + TerminalDisposition *string `json:"terminal_disposition"` + OperationSummaries []observation.OperationSummary `json:"operation_summaries"` +} + +func main() { + if len(os.Args) != 2 { + fail(fmt.Errorf("usage: go run ./examples/observation/go ")) + } + raw, err := os.ReadFile(os.Args[1]) + if err != nil { + fail(err) + } + receipt, err := observation.Parse(raw) + if err != nil { + fail(err) + } + + var disposition *string + if receipt.Outcome.TerminalDisposition != "" { + disposition = &receipt.Outcome.TerminalDisposition + } + summary := receiptSummary{ + Schema: receipt.Schema, + ReceiptDigest: receipt.ReceiptDigest, + RunID: receipt.Run.ID, + Skill: receipt.Skill.Name, + Phase: receipt.Outcome.Phase, + TerminalDisposition: disposition, + OperationSummaries: receipt.OperationSummaries, + } + if err := json.NewEncoder(os.Stdout).Encode(summary); err != nil { + fail(err) + } +} + +func fail(err error) { + fmt.Fprintf(os.Stderr, "receipt example: %v\n", err) + os.Exit(1) +} diff --git a/examples/observation/python/main.py b/examples/observation/python/main.py new file mode 100644 index 0000000..64c2aa8 --- /dev/null +++ b/examples/observation/python/main.py @@ -0,0 +1,38 @@ +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "sdk" / "python")) + +from yieldskill import parse_run_receipt # noqa: E402 + + +def main() -> int: + if len(sys.argv) != 2: + print( + "usage: python3 examples/observation/python/main.py ", + file=sys.stderr, + ) + return 1 + + try: + receipt = parse_run_receipt(Path(sys.argv[1]).read_bytes()) + except (OSError, ValueError) as error: + print(f"receipt example: {error}", file=sys.stderr) + return 1 + + summary = { + "schema": receipt["schema"], + "receipt_digest": receipt["receipt_digest"], + "run_id": receipt["run"]["id"], + "skill": receipt["skill"]["name"], + "phase": receipt["outcome"]["phase"], + "terminal_disposition": receipt["outcome"].get("terminal_disposition"), + "operation_summaries": receipt["operation_summaries"], + } + print(json.dumps(summary, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/observation/rust/Cargo.lock b/examples/observation/rust/Cargo.lock new file mode 100644 index 0000000..ed3241c --- /dev/null +++ b/examples/observation/rust/Cargo.lock @@ -0,0 +1,207 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "yield-observation-example" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "yieldskill", +] + +[[package]] +name = "yieldskill" +version = "0.1.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/observation/rust/Cargo.toml b/examples/observation/rust/Cargo.toml new file mode 100644 index 0000000..09fa19f --- /dev/null +++ b/examples/observation/rust/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "yield-observation-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +yieldskill = { path = "../../../sdk/rust" } diff --git a/examples/observation/rust/src/main.rs b/examples/observation/rust/src/main.rs new file mode 100644 index 0000000..e6020eb --- /dev/null +++ b/examples/observation/rust/src/main.rs @@ -0,0 +1,53 @@ +use serde::Serialize; +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::process::ExitCode; +use yieldskill::observation::{OperationSummary, RunReceipt, TerminalDisposition}; + +#[derive(Serialize)] +struct ReceiptSummary<'a> { + schema: &'a str, + receipt_digest: &'a str, + run_id: &'a str, + skill: &'a str, + phase: &'a yieldskill::observation::LifecyclePhase, + terminal_disposition: &'a Option, + operation_summaries: &'a [OperationSummary], +} + +fn run() -> Result<(), Box> { + let arguments: Vec = env::args().collect(); + if arguments.len() != 2 { + return Err( + "usage: cargo run --manifest-path examples/observation/rust/Cargo.toml -- " + .into(), + ); + } + + let canonical = fs::read(&arguments[1])?; + let receipt = RunReceipt::parse_and_verify(&canonical)?; + let summary = ReceiptSummary { + schema: &receipt.schema, + receipt_digest: &receipt.receipt_digest, + run_id: &receipt.run.id, + skill: &receipt.skill.name, + phase: &receipt.outcome.phase, + terminal_disposition: &receipt.outcome.terminal_disposition, + operation_summaries: &receipt.operation_summaries, + }; + + serde_json::to_writer(io::stdout().lock(), &summary)?; + io::stdout().write_all(b"\n")?; + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("receipt example: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/examples/observation/test-all.sh b/examples/observation/test-all.sh new file mode 100755 index 0000000..0b32172 --- /dev/null +++ b/examples/observation/test-all.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +example_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$example_dir/../.." && pwd)" +fixture="$repo_root/ir/yield.observation.v1/testdata/run-receipt.canonical.jsonl" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +canonical="$work_dir/receipt.json" +trailing="$work_dir/receipt-trailing.json" +mismatch="$work_dir/receipt-mismatch.json" +unknown="$work_dir/receipt-unknown.json" +secret="DO_NOT_PRINT_OBSERVATION_SECRET" + +node --input-type=module - "$fixture" "$canonical" "$trailing" "$mismatch" "$unknown" "$secret" <<'NODE' +import { readFileSync, writeFileSync } from "node:fs" + +const [, , fixture, canonicalPath, trailingPath, mismatchPath, unknownPath, secret] = + process.argv +const framed = readFileSync(fixture) +if (framed.length < 2 || framed.at(-1) !== 0x0a || framed.subarray(0, -1).includes(0x0a)) { + throw new Error("shared receipt fixture must contain one JSON record and one LF") +} + +const canonical = framed.subarray(0, -1) +const document = JSON.parse(canonical.toString("utf8")) +writeFileSync(canonicalPath, canonical) +writeFileSync(trailingPath, Buffer.concat([canonical, Buffer.from("\n")])) +writeFileSync( + mismatchPath, + canonical.toString("utf8").replace(document.receipt_digest, `sha256:${"0".repeat(64)}`), +) +document.prompt = secret +writeFileSync(unknownPath, JSON.stringify(document)) +NODE + +run_example() { + local language="$1" + local receipt="$2" + case "$language" in + go) + (cd "$repo_root" && go run ./examples/observation/go "$receipt") + ;; + typescript) + node "$example_dir/typescript/main.ts" "$receipt" + ;; + python) + PYTHONDONTWRITEBYTECODE=1 python3 "$example_dir/python/main.py" "$receipt" + ;; + rust) + cargo run --quiet --manifest-path "$example_dir/rust/Cargo.toml" -- "$receipt" + ;; + *) + echo "unknown language: $language" >&2 + return 1 + ;; + esac +} + +expected='{"operation_summaries":[{"completed":1,"kind":"agent_task","requested":1,"total_elapsed_ms":3000}],"phase":"terminal","receipt_digest":"sha256:a0995d74d51e472f61dc2001f82b93fd8086f9c2f2816a22bb7e5c7fa3c01cdd","run_id":"run_fixture","schema":"yield.observation.v1","skill":"fixture","terminal_disposition":"completed"}' + +for language in go typescript python rust; do + output="$(run_example "$language" "$canonical")" + normalized="$(jq -ceS . <<<"$output")" + if [[ "$normalized" != "$expected" ]]; then + echo "$language emitted an unexpected receipt summary" >&2 + exit 1 + fi + + for invalid in "$trailing" "$mismatch" "$unknown"; do + stderr="$work_dir/${language}-$(basename "$invalid").stderr" + if output="$(run_example "$language" "$invalid" 2>"$stderr")"; then + echo "$language accepted invalid receipt bytes" >&2 + exit 1 + fi + if [[ -n "$output" ]]; then + echo "$language emitted a partial summary for invalid receipt bytes" >&2 + exit 1 + fi + if grep -Fq "$secret" "$stderr"; then + echo "$language exposed a private unknown-field value" >&2 + exit 1 + fi + done + + echo "validated observation reader: $language" +done diff --git a/examples/observation/typescript/main.ts b/examples/observation/typescript/main.ts new file mode 100644 index 0000000..c0f23f8 --- /dev/null +++ b/examples/observation/typescript/main.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs" +import { argv, stderr, stdout } from "node:process" + +import { parseRunReceipt } from "../../../sdk/typescript/src/index.ts" + +const args = argv.slice(2) +if (args.length !== 1) { + stderr.write("usage: node examples/observation/typescript/main.ts \n") + process.exit(1) +} + +try { + const receipt = parseRunReceipt(readFileSync(args[0])) + const summary = { + schema: receipt.schema, + receipt_digest: receipt.receipt_digest, + run_id: receipt.run.id, + skill: receipt.skill.name, + phase: receipt.outcome.phase, + terminal_disposition: receipt.outcome.terminal_disposition ?? null, + operation_summaries: receipt.operation_summaries, + } + stdout.write(`${JSON.stringify(summary)}\n`) +} catch (error) { + const message = error instanceof Error ? error.message : "receipt verification failed" + stderr.write(`receipt example: ${message}\n`) + process.exit(1) +}