Skip to content
61 changes: 61 additions & 0 deletions pkg/query/signals/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Writing signal READMEs

Each signal package has its own `README.md`. Follow the structure below and the
existing [context-field stability](contextfield/README.md),
[result size](resultsize/README.md), [historical result size](historicalresultsize/README.md),
and [branch outcomes](branchoutcome/README.md) examples.

## Required layout

```markdown
# Human-readable signal name

## Summary

One short sentence describing what the signal tracks.

- **Revision scope:** Exact datastore revision, or across datastore revisions.
- **Aging:** Whether evidence ages; include the default decay and retention when applicable.

## Description

Explain the signal's purpose and how it can help an optimization, in plain language.

## Example

Show a summarized schema, a summarized request, and scenarios describing what the
signal learns or stores.

## Details

Document the provider-specific technical behavior and limits.
```

## Writing conventions

- Keep the Summary to one quick sentence and use the same field names above in
every signal README. Make the revision scope explicit; do not imply that the
signal chooses or quantizes datastore revisions.
- Write the Description for someone learning what the signal is useful for.
Prefer concrete explanations over implementation terminology. Describe the
general behavior first; a named example such as `region` must not make a signal
that handles arbitrary context fields sound specific to that field.
- Put a Zed schema block and request alongside the examples. A scenario table
should explain normal observations, incomplete or missing observations, and
relevant changes in revision, time, or schema. State when rows start with an
empty history instead of implying that independent scenarios accumulate.
- Keep explicit technical details in the final Details section: population keys,
observation eligibility, estimate fields, weighting, defaults, configuration,
bounds, schema compatibility, and unusual cases. Include the details that are
relevant to this provider rather than repeating every topic mechanically.
- Distinguish predictions from permission proofs, incomplete work from measured
zeroes, and a lack of evidence from a negative result. Describe current behavior;
do not imply that a signal already drives an optimization when that wiring is
still a subsequent change.
- Link to related signals where they are introduced. In particular, a historical
or derived signal should link to the README of the signal it builds on near the
start of its Description.
- Shared registration and access conventions belong in the
[parent README](README.md). Do not repeat boilerplate such as "registered through
the explicit signal catalog" or "access with typed Ref/Input values" in each
signal README.
17 changes: 17 additions & 0 deletions pkg/query/signals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,20 @@ builders for provider-specific tests. Numeric distribution helpers are tested in
`signal` without depending on a built-in provider.

Time-sensitive tests use `testing/synctest` to control time without a custom clock.

## Built-in providers

`NewDefaultRegistry` registers the following providers explicitly;
`NewDefaultOwner` constructs their isolated bounded store.

| Provider | Scope | Observation |
| --- | --- | --- |
| [Context fields](contextfield/README.md) | Exact revision | Presence and value fingerprints by context field name |
| [Result size](resultsize/README.md) | Exact revision | Completed enumeration path counts |
| [Historical result size](historicalresultsize/README.md) | Cross revision | Aged result-size observations with configurable windows |
| [Branch outcomes](branchoutcome/README.md) | Exact revision | Completed Check grants, denials and conditional outcomes |

Each provider package includes examples, unit tests and Rapid properties. These
providers estimate observed populations; their predictions are never permission
proofs. They do not issue datastore queries. Collecting observations during checks
and applying the estimates to optimization decisions are subsequent layers.
95 changes: 95 additions & 0 deletions pkg/query/signals/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package signals_test

import (
"testing"

"github.com/authzed/spicedb/internal/datastore/revisions"
"github.com/authzed/spicedb/pkg/query/signals"
"github.com/authzed/spicedb/pkg/query/signals/resultsize"
"github.com/authzed/spicedb/pkg/query/signals/signal"
)

func BenchmarkSignalLookup(b *testing.B) {
r, err := signals.NewDefaultRegistry()
if err != nil {
b.Fatal(err)
}

o, err := signals.NewOwner(r, signals.Options{})
if err != nil {
b.Fatal(err)
}

v, err := o.Bind(stored("a"), signal.Scope{SchemaHash: "a", Revision: revisions.NewForTransactionID(1)})
if err != nil {
b.Fatal(err)
}

k := signal.ExecutionKey{Identity: "x", Operation: signal.IterSubjects}
if err := signal.Observe(v, resultsize.Input, k, resultsize.Observation{Count: 10}); err != nil {
b.Fatal(err)
}

for _, memo := range []bool{false, true} {
name := "snapshot"
var reader signal.ReadView = v

if memo {
name = "memoized"
reader = v.ForPlan()
}

b.Run(name, func(b *testing.B) {
b.ReportAllocs()

for b.Loop() {
if _, err := signal.Lookup(reader, resultsize.Ref, k); err != nil {
b.Fatal(err)
}
}
})
}
}

func BenchmarkSchemaBinding(b *testing.B) {
r, _ := signals.NewDefaultRegistry()
o, _ := signals.NewOwner(r, signals.Options{})
s := stored("a")
scope := signal.Scope{SchemaHash: "a", Revision: revisions.NewForTransactionID(1)}
b.ReportAllocs()

for b.Loop() {
if _, err := o.Bind(s, scope); err != nil {
b.Fatal(err)
}
}
}

func BenchmarkColdSchemaTransfer(b *testing.B) {
r, err := signals.NewDefaultRegistry()
if err != nil {
b.Fatal(err)
}

a, z := stored("a"), stored("z")
scope := signal.Scope{SchemaHash: "a", Revision: revisions.NewForTransactionID(1)}
b.ReportAllocs()

for b.Loop() {
o, err := signals.NewOwner(r, signals.Options{})
if err != nil {
b.Fatal(err)
}

if _, err = o.Bind(a, scope); err != nil {
b.Fatal(err)
}

next := scope
next.SchemaHash = "z"

if _, err = o.Bind(z, next); err != nil {
b.Fatal(err)
}
}
}
83 changes: 83 additions & 0 deletions pkg/query/signals/branchoutcome/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Branch outcomes

## Summary

Tracks grants, denials, and conditional outcomes for attempted checks.

- **Revision scope:** Exact datastore revision.
- **Aging:** No time-based aging; storage limits apply.

## Description

When a permission has several ways to grant access, some paths may succeed much
more often than others. This signal records what happens when each part of a check
actually runs: it grants access, denies access, or returns a result that still
needs caveat context.

That history can help an optimization choose what to try first. It also records
unfinished work separately: an error or cancellation is not a denial, and a branch
that was never tried tells us nothing about whether it would have succeeded.

## Example

Summarized schema:

```zed
definition user {}
caveat enabled(flag bool) { flag }
definition document {
relation direct: user | user with enabled
relation fallback: user
permission view = direct + fallback
}
```

A request checks whether Alice can view `document:report`, optionally supplying
`{ "flag": true }`. The signal can record what happens when the `direct` part of
that check runs. Requests for different documents or users can contribute to the
same history when they execute the same part of the plan in the same way.

Each row starts with an empty history at revision `R1`:

| What happens to `direct` | What the signal records |
| --- | --- |
| It finds no match | One attempted check and one denial |
| It grants access without a caveat | One attempted check and one grant |
| It needs more caveat context | One attempted check and one conditional result |
| It fails or is canceled | One attempt, with no completed outcome |
| `fallback` finishes the request before `direct` runs | No observation for `direct` |
| A batch of eight inputs fails without individual results | One unresolved batch, with at most eight attempts; no assumed outcomes |

One denial, one grant, one conditional result and one error produce four attempts
but only three completed outcomes. Each completed outcome accounts for one-third
of those results. A request at a different datastore revision starts a separate
history.

The order in which branches run affects what we get to observe. A branch tried
only after another branch fails has a different history from one always tried
first. Consumers need to account for that when using these frequencies.

## Details

`New` declares `Policy: signal.RevisionBound`. The store keys evidence by schema,
exact `scope.Revision.String()`, signal name, and execution population. Requests
selecting the same datastore quantization revision can share evidence. This
provider neither chooses the revision nor carries counters to another revision.

The execution key distinguishes subtree identity, Check filters, invocation mode,
strategy, and sender/receiver role. It does not identify individual object IDs.
Batch modes have separate populations from single checks. Only attempted Check
subtrees contribute; this is not an unbiased probability that a branch would win.

The estimate tracks `Attempted`, `Completed`, `Denied`, `Granted`, and
`Conditional`. Fractions divide their outcome counters by `Completed`. The combined
example above gives `Attempted=4`, `Completed=3`, and each outcome fraction `1/3`.
Errors and cancellation increase known attempts but never completed-outcome counts.

Completed batches supply per-element outcomes. When only a whole-batch error is
available, `UnresolvedBatches` and `UnresolvedAttemptUpperBound` record uncertainty
without adding known per-element attempts or outcomes. For eight inputs, those
counters increase by 1 and 8 respectively.

Unit tests and Rapid properties compare the estimate with an independent counting
model. Run `go test -race ./pkg/query/signals/branchoutcome`.
43 changes: 43 additions & 0 deletions pkg/query/signals/branchoutcome/property_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package branchoutcome_test

import (
"testing"

"github.com/ccoveille/go-safecast/v2"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"

"github.com/authzed/spicedb/internal/querysignalstest"
"github.com/authzed/spicedb/pkg/query/signals/branchoutcome"
"github.com/authzed/spicedb/pkg/query/signals/signal"
)

func TestOutcomeModel(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
v := querysignalstest.View(t, querysignalstest.Owner(t, branchoutcome.New()), "a", 1)
k := signal.ExecutionKey{Identity: "x", Operation: signal.Check}
counts := [3]uint64{}
n := rapid.IntRange(1, 100).Draw(rt, "attempts")

for range n {
x := rapid.IntRange(0, 4).Draw(rt, "outcome")
o := branchoutcome.Observation{}

if x < 3 {
o.Outcome = safecast.MustConvert[branchoutcome.Outcome](x)
counts[x]++
} else {
o.Status = signal.Canceled
}

require.NoError(rt, signal.Observe(v, branchoutcome.Input, k, o))
}

e, err := signal.Lookup(v, branchoutcome.Ref, k)
require.NoError(rt, err)
require.Equal(rt, safecast.MustConvert[uint64](n), e.Value.Attempted)
require.Equal(rt, counts[0], e.Value.Denied)
require.Equal(rt, counts[1], e.Value.Granted)
require.Equal(rt, counts[2], e.Value.Conditional)
})
}
Loading
Loading