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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/public-observation-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@operatorstack/yield": minor
---

Publish the Go receipt projection, verification, durable store, and local
report aggregation as `github.com/operatorstack/yield/observation`. Add strict
typed receipt readers and digest verification to the TypeScript, Python, and
Rust SDKs without changing the observation schema, canonical bytes, storage
layout, or CLI.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ir/yield.observation.v1/testdata/*.jsonl text eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
cd "$smoke_dir"
npm init -y >/dev/null
npm install "$tarball" >/dev/null
node --input-type=module -e 'import { defineSkill } from "@operatorstack/yield"; if (typeof defineSkill !== "function") process.exit(1)'
node --input-type=module -e 'import { defineSkill, parseRunReceipt } from "@operatorstack/yield"; if (typeof defineSkill !== "function" || typeof parseRunReceipt !== "function") process.exit(1)'

python:
name: Python SDK
Expand Down
6 changes: 3 additions & 3 deletions cmd/yskill/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import (
"github.com/operatorstack/yield/internal/engine"
"github.com/operatorstack/yield/internal/outbox"
"github.com/operatorstack/yield/internal/protocol"
"github.com/operatorstack/yield/internal/receipt"
"github.com/operatorstack/yield/internal/runlog"
receipt "github.com/operatorstack/yield/observation"
)

const usage = `yskill — run and resume skill workflows
Expand Down Expand Up @@ -559,12 +559,12 @@ func cmdReport(args []string) error {
if err != nil {
return err
}
store := receipt.StoreForRunsDir(e.RunsDir)
store := receipt.NewStore(filepath.Dir(e.RunsDir))
ids, err := store.ListRuns()
if err != nil {
return err
}
receipts := make([]*receipt.RunReceipt, 0, len(ids))
receipts := make([]receipt.RunReceipt, 0, len(ids))
for _, id := range ids {
r, _, loadErr := store.LoadRun(id)
if loadErr != nil {
Expand Down
57 changes: 57 additions & 0 deletions docs/reference/run-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,63 @@ operation kinds and timing, typed rejection and requirement outcomes,
divergence digests, terminal disposition, and optional experiment identifiers.
All four SDKs use the same supervisor projection.

## Go API

Go hosts can project, verify, store, and aggregate receipts without invoking
the CLI. The public package is
[`github.com/operatorstack/yield/observation`](https://pkg.go.dev/github.com/operatorstack/yield/observation).

```go
receipt, err := observation.Project(journalPrefix)
if err != nil {
return err
}
canonical, err := observation.CanonicalBytes(receipt)
if err != nil {
return err
}

store := observation.NewStore(yieldDir)
if err := store.Put(receipt, canonical); err != nil {
return err
}
```

`Project` accepts one exact, complete `.yield/runs/<run-id>.jsonl` prefix and
performs no I/O. It rejects partial lines, malformed ordering, unknown event
types, and unknown journal-envelope fields. `Parse` accepts only canonical
receipt bytes whose digest verifies. These checks make the journal prefix the
explicit input and keep receipt files derived, rather than authoritative.

## SDK readers

The Go supervisor remains the only receipt projector. TypeScript, Python, and
Rust expose strict readers for the same canonical receipt bytes:

```typescript
import { parseRunReceipt } from "@operatorstack/yield"

const receipt = parseRunReceipt(bytes)
```

```python
from yieldskill import parse_run_receipt

receipt = parse_run_receipt(data)
```

```rust
use yieldskill::RunReceipt;

let receipt = RunReceipt::parse_and_verify(bytes)?;
```

Each reader exports schema-bound v1 types, rejects unknown fields and invalid
enum values, reproduces Yield's integer-only RFC 8785 canonicalization
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.

## Privacy boundary

Receipts do not contain prompts, instructions, model responses, user answers,
Expand Down
12 changes: 6 additions & 6 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import (
"github.com/gofrs/flock"
"github.com/operatorstack/yield/internal/guard"
"github.com/operatorstack/yield/internal/protocol"
"github.com/operatorstack/yield/internal/receipt"
"github.com/operatorstack/yield/internal/runlog"
receipt "github.com/operatorstack/yield/observation"
)

// Engine binds a skill directory to a runs directory.
Expand Down Expand Up @@ -393,31 +393,31 @@ func (e *Engine) MaterializeReceipt(runID string) (*receipt.RunReceipt, []byte,
if err != nil {
return nil, nil, err
}
if err := receipt.StoreForRunsDir(e.RunsDir).Put(r, raw); err != nil {
if err := receipt.NewStore(filepath.Dir(e.RunsDir)).Put(*r, raw); err != nil {
return nil, nil, err
}
return r, raw, nil
}

func (e *Engine) project(runID string) (*receipt.RunReceipt, []byte, error) {
l, rawJournal, err := runlog.OpenSnapshot(e.RunsDir, runID)
_, rawJournal, err := runlog.OpenSnapshot(e.RunsDir, runID)
if err != nil {
return nil, nil, err
}
r, err := receipt.Project(receipt.Snapshot{Bytes: rawJournal, Events: l.Events()})
r, err := receipt.Project(rawJournal)
if err != nil {
return nil, nil, err
}
raw, err := receipt.CanonicalBytes(r)
return r, raw, err
return &r, raw, err
}

func (e *Engine) materialize(runID string) error {
r, raw, err := e.project(runID)
if err != nil {
return err
}
return receipt.StoreForRunsDir(e.RunsDir).Put(r, raw)
return receipt.NewStore(filepath.Dir(e.RunsDir)).Put(*r, raw)
}

// ListRuns returns known run IDs, newest last.
Expand Down
8 changes: 4 additions & 4 deletions internal/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
"github.com/operatorstack/yield/internal/guard"
"github.com/operatorstack/yield/internal/outbox"
"github.com/operatorstack/yield/internal/protocol"
"github.com/operatorstack/yield/internal/receipt"
"github.com/operatorstack/yield/internal/runlog"
receipt "github.com/operatorstack/yield/observation"
)

// testEngine points at a testdata skill but keeps run logs in a temp dir,
Expand All @@ -38,7 +38,7 @@ func TestStartRunMaterializesReceiptBeforeReturn(t *testing.T) {
if err != nil {
t.Fatal(err)
}
r, _, err := receipt.StoreForRunsDir(e.RunsDir).LoadRun(p.RunID)
r, _, err := receipt.NewStore(filepath.Dir(e.RunsDir)).LoadRun(p.RunID)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -85,7 +85,7 @@ func TestInitializationFailureHasRunIDJournalAndReceipt(t *testing.T) {
if got := l.Events(); len(got) != 2 || got[0].Type != runlog.RunOpened || got[1].Type != runlog.RunInitializationFailed {
t.Fatalf("unexpected initialization journal: %+v", got)
}
r, _, loadErr := receipt.StoreForRunsDir(e.RunsDir).LoadRun(runErr.RunID)
r, _, loadErr := receipt.NewStore(filepath.Dir(e.RunsDir)).LoadRun(runErr.RunID)
if loadErr != nil {
t.Fatal(loadErr)
}
Expand Down Expand Up @@ -131,7 +131,7 @@ func main() { fmt.Println("{\"type\":\"terminal\",\"terminal\":{\"status\":\"com
if progress.Terminal == nil || progress.Terminal.Status != protocol.StatusCompleted {
t.Fatalf("workspace Rust skill did not reach completion: %+v", progress)
}
r, _, loadErr := receipt.StoreForRunsDir(runsDir).LoadRun(progress.RunID)
r, _, loadErr := receipt.NewStore(filepath.Dir(runsDir)).LoadRun(progress.RunID)
if loadErr != nil {
t.Fatal(loadErr)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/outbox/outbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"time"

"github.com/gofrs/flock"
"github.com/operatorstack/yield/internal/receipt"
receipt "github.com/operatorstack/yield/observation"
)

type Manager struct {
Expand Down Expand Up @@ -71,7 +71,7 @@ func (m *Manager) Enqueue(sinkID string, r *receipt.RunReceipt, raw []byte) erro
if err := r.Validate(); err != nil {
return err
}
if err := receipt.VerifyCanonical(r, raw); err != nil {
if err := receipt.VerifyCanonical(*r, raw); err != nil {
return err
}
path, err := m.pendingPath(sinkID, r.ReceiptDigest)
Expand Down Expand Up @@ -551,7 +551,7 @@ func verifyReceipt(raw []byte, digest string) error {
if r.ReceiptDigest != digest {
return fmt.Errorf("outbox: pending receipt digest does not match its name")
}
return receipt.VerifyCanonical(&r, raw)
return receipt.VerifyCanonical(r, raw)
}

type digestWriter struct {
Expand Down
31 changes: 19 additions & 12 deletions internal/outbox/outbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,36 @@ import (
"testing"
"time"

"github.com/operatorstack/yield/internal/receipt"
"github.com/operatorstack/yield/internal/protocol"
"github.com/operatorstack/yield/internal/runlog"
receipt "github.com/operatorstack/yield/observation"
)

func testReceipt(t *testing.T) (*receipt.RunReceipt, []byte) {
t.Helper()
r := &receipt.RunReceipt{
Schema: receipt.Schema, Kind: receipt.Kind,
Journal: receipt.JournalBinding{RunID: "run_1", HeadSequence: 1, HeadDigest: digestBytes([]byte("journal"))},
Run: receipt.RunIdentity{ID: "run_1"},
Skill: receipt.SkillIdentity{Name: "test", BindingDigest: digestBytes([]byte("skill"))},
Timing: receipt.TimingSummary{StartedAt: "2026-08-20T10:00:00Z", LastObservedAt: "2026-08-20T10:00:00Z"},
Operations: []receipt.OperationObservation{}, OperationSummaries: []receipt.OperationSummary{},
Outcome: receipt.OutcomeSummary{Phase: "advancing"}, Requirements: []receipt.RequirementOutcome{},
ResponseRejections: []receipt.ResponseRejectionSummary{}, Divergences: []receipt.DivergenceOutcome{},
event := runlog.Event{
Seq: 1,
Type: runlog.RunStarted,
At: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC),
}
event.Data, _ = json.Marshal(map[string]any{
"run_id": "run_1",
"skill": protocol.SkillRef{Name: "test", Digest: digestBytes([]byte("skill"))},
})
journal, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
if err := receipt.Seal(r); err != nil {
journal = append(journal, '\n')
r, err := receipt.Project(journal)
if err != nil {
t.Fatal(err)
}
raw, err := receipt.CanonicalBytes(r)
if err != nil {
t.Fatal(err)
}
return r, raw
return &r, raw
}

func TestEnqueueIsByteIdempotent(t *testing.T) {
Expand Down
41 changes: 41 additions & 0 deletions internal/runlog/runlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -113,6 +114,46 @@ func OpenSnapshot(runsDir, runID string) (*Log, []byte, error) {
return l, raw, nil
}

// ParseSnapshot verifies and decodes one exact, complete JSONL journal prefix.
func ParseSnapshot(raw []byte) ([]Event, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("run log is empty")
}
if raw[len(raw)-1] != '\n' {
return nil, fmt.Errorf("run log ends with a partial event")
}
var events []Event
sc := bufio.NewScanner(bytes.NewReader(raw))
sc.Buffer(make([]byte, 0, 1024*1024), 16*1024*1024)
line := 0
for sc.Scan() {
line++
if len(sc.Bytes()) == 0 {
return nil, fmt.Errorf("corrupt run log at line %d: blank event", line)
}
decoder := json.NewDecoder(bytes.NewReader(sc.Bytes()))
decoder.DisallowUnknownFields()
var event Event
if err := decoder.Decode(&event); err != nil {
return nil, fmt.Errorf("corrupt run log at line %d: %w", line, err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return nil, fmt.Errorf("corrupt run log at line %d: trailing content: %w", line, err)
}
if event.Seq != len(events)+1 {
return nil, fmt.Errorf("run log sequence broken at line %d: got seq %d, want %d", line, event.Seq, len(events)+1)
}
events = append(events, event)
}
if err := sc.Err(); err != nil {
return nil, err
}
return events, nil
}

func parse(path string, raw []byte) (*Log, error) {
l := &Log{Path: path}
sc := bufio.NewScanner(bytes.NewReader(raw))
Expand Down
9 changes: 7 additions & 2 deletions ir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ This directory contains Yield's language-neutral schemas.
- `yield.observation.v1` is the portable observation boundary projected by
the Go supervisor from an append-only run journal. SDKs do not implement it.

The Go reference types and schema tests keep both boundaries aligned with the
runtime.
The public Go package
[`observation`](https://pkg.go.dev/github.com/operatorstack/yield/observation)
provides the reference receipt types, pure journal projection, strict parsing,
durable local store, and report aggregation. Schema tests keep that package
aligned with the language-neutral boundary. The TypeScript, Python, and Rust
SDKs provide strict typed readers and digest verification over the same
Go-generated canonical fixture; they do not duplicate projection or storage.

## Files

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"divergences":[],"experiment":{"baseline_variant_id":"baseline-a","cohort_id":"cohort-a","experiment_id":"exp-1","parent_skill_version":"1.2.2","role":"candidate","variant_id":"candidate-a"},"journal":{"head_digest":"sha256:bac3f7194c5baf064fc036f50651123dc882e2672f8de90c438b5160118ddc0e","head_sequence":7,"run_id":"run_fixture"},"kind":"run_receipt","operation_summaries":[{"completed":1,"kind":"agent_task","requested":1,"total_elapsed_ms":3000}],"operations":[{"completed_at":"2026-08-20T10:00:05Z","elapsed_ms":3000,"kind":"agent_task","operation_key_digest":"sha256:05c048343bf1e65d79dc8c423ca8906d6572ada664fbf29b3118aad48849f900","requested_at":"2026-08-20T10:00:02Z","result_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444","sequence":1}],"outcome":{"phase":"terminal","result_digest":"sha256:4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93","terminal_cause":"completed","terminal_disposition":"completed"},"receipt_digest":"sha256:a0995d74d51e472f61dc2001f82b93fd8086f9c2f2816a22bb7e5c7fa3c01cdd","requirements":[{"claim_digest":"sha256:b8cb5510ac0e4abd6b1ca23d6d2cb6902dd6cd3d4078a061ef65464f5a431cb0","evidence_digest":"sha256:5555555555555555555555555555555555555555555555555555555555555555","outcome":"passed"}],"response_rejections":[{"count":1,"reason":"duplicate-response"}],"run":{"id":"run_fixture","input_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111"},"runtime":{"compatible":true,"required_version":"0.7.0","supervisor_version":"0.7.0"},"schema":"yield.observation.v1","skill":{"binding_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","name":"fixture","source_digest":{"profile":"yield.skill-source.v1","value":"sha256:3333333333333333333333333333333333333333333333333333333333333333"},"version":"1.2.3"},"timing":{"elapsed_ms":8000,"ended_at":"2026-08-20T10:00:08Z","last_observed_at":"2026-08-20T10:00:08Z","started_at":"2026-08-20T10:00:00Z"}}
30 changes: 30 additions & 0 deletions observation/golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package observation

import (
"bytes"
"os"
"path/filepath"
"testing"
)

func TestSharedCanonicalReceiptFixture(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "ir", "yield.observation.v1", "testdata", "run-receipt.canonical.jsonl"))
if err != nil {
t.Fatal(err)
}
canonical := bytes.TrimSuffix(raw, []byte("\n"))
if len(canonical) == len(raw) || bytes.Contains(canonical, []byte("\n")) {
t.Fatal("shared receipt fixture must be one canonical JSON line followed by one file newline")
}
receipt, err := Parse(canonical)
if err != nil {
t.Fatal(err)
}
encoded, err := CanonicalBytes(receipt)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(encoded, canonical) {
t.Fatal("shared receipt fixture differs from Go canonical bytes")
}
}
6 changes: 3 additions & 3 deletions internal/receipt/ir_test.go → observation/ir_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package receipt
package observation

import (
"bytes"
Expand All @@ -15,7 +15,7 @@ import (

func observationSchema(t *testing.T) *jsonschema.Schema {
t.Helper()
path := filepath.Join("..", "..", "ir", "yield.observation.v1", "run-receipt.schema.json")
path := filepath.Join("..", "ir", "yield.observation.v1", "run-receipt.schema.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -44,7 +44,7 @@ func TestGoReceiptValidatesAgainstObservationIR(t *testing.T) {
event(t, 2, runlog.RunStarted, t0, map[string]any{"run_id": "run_schema", "skill": skill, "input_digest": digest, "supervisor_version": "1.0.0", "required_yield_version": "1.0.0", "source_digest_profile": protocol.SkillSourceProfileV1, "source_digest": digest}),
event(t, 3, runlog.RunCompleted, t0.Add(time.Second), map[string]any{"result": json.RawMessage(`{"ok":true}`)}),
}
r, err := Project(Snapshot{Events: events, Bytes: journalBytes(t, events)})
r, err := Project(journalBytes(t, events))
if err != nil {
t.Fatal(err)
}
Expand Down
Loading