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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ state.db-wal
aiops-architecture-diagram.py
draftyard
/npm/bin/

.build/
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.9.0

- Isolate pipeline token and cost totals and serialize model admission against settled daily usage.
- Persist daily usage by UTC date and preserve uncertain provider calls across restarts.
- Govern every engine model request, including classification and rewrites, and charge responses before output policy review.
- Require durable approval and receipt writes before releasing pipeline, model, or tool actions.
- Record immutable, action-bound external execution outcomes with authenticated completion and polling.
- Apply expiration and current policy checks consistently to live and recovered tool permits.
- Add authenticated revocation of pending and allowed tool actions with conditional transitions against consumption.
- Validate structured output and configured scalar schemas with exact numeric comparisons and explicit integer semantics.
- Bound provider response reads and stop automatic retries when billing is uncertain.

`draftcat budget status` inspects daily usage and unsettled calls; `draftcat budget reconcile` records verified provider usage after an interrupted call. See the [budget and lifecycle guide](docs/governance-lifecycle.md) for upgrade behavior, recovery, and caller-attested outcome semantics.

## 0.8.0

- Add durable, pipeline-scoped `Idempotency-Key` webhook retries. Matching bodies return the original admission; changed bodies are rejected.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Sometimes a customer, auditor, or partner needs evidence that a human approved a

This is an **experimental cryptographic preview**, not a production compliance claim. It uses an embedded BN254/Groth16 circuit and a development single-party setup; the circuit has not received an independent audit. Use it to evaluate the disclosure model, then replace the setup through a ceremony before relying on it in production. See [zero-knowledge approval proofs](docs/zk-approval-proofs.md) for the trust model, exact statement, and limitations.

> **New in v0.9.0:** daily model usage persists in SQLite, parallel pipelines keep separate budget totals, and every engine model call passes one admission gate. Approval records commit before work is released. Unconsumed tool actions can be revoked, consumed actions accept durable caller-reported outcomes, and live or recovered permits share expiration and policy checks. Exact scalar output validation and bounded provider reads complete the release. See the [budget and lifecycle guide](docs/governance-lifecycle.md).
>
> **New in v0.8.0:** webhook retries can carry a durable `Idempotency-Key`, signed replay identities are claimed atomically, and tool permits recheck their policy before execution. Requests reject oversized or ambiguous data and retain exact numbers. Older state stores upgrade safely; completed and failed runs carry exact approval identities. Audit commands read without modifying the database, and `draftcat receipts verify` checks exported JSONL offline. See the [upgrade and reliability guide](docs/reliability.md).
>
> **New in v0.7.0:** execution decisions now carry their proof. Every tool-gate route is authenticated, each request has a stable action identity and exact policy binding, and an allowed decision becomes an atomic consume-once permit before the side effect runs. Webhook acceptance is durable before HTTP 202 and can be polled after handoff. Versioned receipts bind action, payload, policy, and expiry, with `draftcat receipts list|show|export` for verification-ready JSONL. Ordered `model_policy` rules can deny or send matching model input/output to a human, while `/healthz` and `/readyz` give orchestrators a safe listener contract.
Expand Down Expand Up @@ -295,7 +297,7 @@ An approval step can narrow who may decide it:
approvers: [111111, 222222] # which ones (subset of allowed_users)
```

Cost caps are checked between calls: a call is refused once spend has reached the cap. Pair them with `per_step_tokens` to bound the size of any single call. A transient provider failure (429, 408, 5xx) is retried with backoff — honouring `Retry-After` — before it fails a step; `provider.max_retries` sets the budget.
Cost caps are checked between calls: a call is refused once spend has reached the cap. Pair them with `per_step_tokens` to bound the size of any single call. Explicit rate-limit rejections (HTTP 429 without declared usage) retry with backoff and honour `Retry-After`; `provider.max_retries` sets the attempt limit. Transport errors, timeouts, server errors, and responses with uncertain billing halt the call and require usage reconciliation before another dispatch. See the [budget recovery guide](docs/governance-lifecycle.md).

The tool-call gate is configured the same way, per tool:

Expand Down
18 changes: 18 additions & 0 deletions approval_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"fmt"
"os"

statestore "github.com/renezander030/draftcat/internal/state"
)

func persistApprovalReceipt(envelope statestore.ApprovalEnvelope) error {
if state == nil {
return nil
}
if os.Getenv("DRAFTCAT_APPROVAL_SECRET") != "" && (envelope.Nonce == "" || envelope.Signature == "") {
return fmt.Errorf("signed approval receipt could not be created")
}
return state.RecordApprovalV2(envelope)
}
95 changes: 95 additions & 0 deletions approval_storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"context"
"path/filepath"
"strings"
"testing"
"time"

"github.com/renezander030/draftcat/internal/config"
statestore "github.com/renezander030/draftcat/internal/state"
)

type storageApprovalChannel struct {
stubApprovalChannel
prompts, sends int
}

func (s *storageApprovalChannel) Send(string) error { s.sends++; return nil }
func (s *storageApprovalChannel) SendForApproval(ctx context.Context, draft string, approvers []int64) (OperatorDecision, error) {
s.prompts++
return s.stubApprovalChannel.SendForApproval(ctx, draft, approvers)
}

func TestPipelineStorageFailureStopsRelease(t *testing.T) {
for _, table := range []string{"pending_approvals", "action_approvals"} {
t.Run(table, func(t *testing.T) {
st, err := statestore.OpenStateStore(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
old := state
state = st
t.Cleanup(func() { state = old; _ = st.Close() })
_, err = st.DB().ExecContext(context.Background(), "CREATE TRIGGER reject_storage BEFORE INSERT ON "+table+" BEGIN SELECT RAISE(ABORT,'storage unavailable'); END")
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Timeouts: config.TimeoutConfig{OperatorApproval: "1s"}}
ch := &storageApprovalChannel{stubApprovalChannel: stubApprovalChannel{action: "approve", id: 7}}
p := config.PipelineConfig{Name: "pipeline", Steps: []config.StepConfig{{Name: "review", Type: "approval"}, {Name: "notify", Type: "deterministic", Action: "notify"}}}
err = runPipeline(cfg, p, &BudgetTracker{dayStart: time.Now()}, ch, nil, nil)
if err == nil || !strings.Contains(err.Error(), "unavailable") {
t.Fatalf("failure must stop release: %v", err)
}
if ch.sends != 0 {
t.Fatal("pipeline executed after storage failure")
}
if table == "pending_approvals" && ch.prompts != 0 {
t.Fatal("prompt sent before durable pending write")
}
})
}
}

func TestAutomaticApprovalRequiresReceiptStorage(t *testing.T) {
st, err := statestore.OpenStateStore(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
old := state
state = st
t.Cleanup(func() { state = old; _ = st.Close() })
_, err = st.DB().ExecContext(context.Background(), "CREATE TRIGGER reject_receipts BEFORE INSERT ON action_approvals BEGIN SELECT RAISE(ABORT,'storage unavailable'); END")
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Policy: config.ApprovalPolicy{AutoApprove: []config.AutoApproveRule{{Risk: config.RiskLow}}}}
ch := &storageApprovalChannel{}
p := config.PipelineConfig{Name: "pipeline", Steps: []config.StepConfig{{Name: "review", Type: "approval", Risk: config.RiskLow}, {Name: "notify", Type: "deterministic", Action: "notify"}}}
if err := runPipeline(cfg, p, &BudgetTracker{dayStart: time.Now()}, ch, nil, nil); err == nil {
t.Fatal("automatic approval ignored receipt failure")
}
if ch.sends != 0 {
t.Fatal("automatic approval released next step")
}
}

func TestModelReviewRequiresReceiptStorage(t *testing.T) {
st, err := statestore.OpenStateStore(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
old, oldCh := state, opChan
state, opChan = st, &stubApprovalChannel{action: "approve", id: 7}
t.Cleanup(func() { state, opChan = old, oldCh; _ = st.Close() })
_, err = st.DB().ExecContext(context.Background(), "CREATE TRIGGER reject_receipts BEFORE INSERT ON action_approvals BEGIN SELECT RAISE(ABORT,'storage unavailable'); END")
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Timeouts: config.TimeoutConfig{OperatorApproval: "1s"}, ModelPolicy: config.ModelPolicyConfig{Rules: []config.ModelPolicyRule{{ID: "review", Phase: "output", Pattern: "claim", Action: "review"}}}}
if err := enforceModelPolicy(context.Background(), cfg, "drafter", "output", "claim"); err == nil {
t.Fatal("model output released without durable approval receipt")
}
}
Loading
Loading