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
18 changes: 18 additions & 0 deletions cmd/gc/bead_policy_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ type beadPolicyGraphStore struct {
var (
_ beads.ConditionalAssignmentReleaser = (*beadPolicyStore)(nil)
_ beads.ConditionalWritesResolveTargeter = (*beadPolicyStore)(nil)

// The session front door recognizes this wrapper structurally: without the
// marker it cannot tell a policy-applying store from a store that dropped
// the policy, and it warns and stamps its own hardcoded class instead of
// deferring to the configured one. A structural interface has no compiler
// coupling of its own, so state it here — renaming either side is then a
// build failure, not a silent behavior change.
_ session.StoragePolicySelfApplying = (*beadPolicyStore)(nil)
_ session.StoragePolicySelfApplying = (*beadPolicyGraphStore)(nil)
)

// ConditionalWritesResolveTarget declares the wrapped store as the
Expand All @@ -50,6 +59,15 @@ var (
// *beadPolicyStore.
func (s *beadPolicyStore) ConditionalWritesResolveTarget() beads.Store { return s.Store }

// AppliesBeadStoragePolicy marks this wrapper as applying the bead storage
// policy inside its own Create (policyForCreate -> createWithStoragePolicy).
// CreateWithStorage is deliberately not forwarded — an out-of-band class from a
// caller would override the class this layer resolves from city config — so a
// caller that finds no beads.StorageCreateStore here has NOT lost the policy.
// It implements session.StoragePolicySelfApplying; see the compile-time
// assertion above.
func (s *beadPolicyStore) AppliesBeadStoragePolicy() {}

var (
_ beads.BatchDeleter = (*beadPolicyStore)(nil)
_ beads.BatchDeleter = (*beadPolicyGraphStore)(nil)
Expand Down
205 changes: 205 additions & 0 deletions cmd/gc/session_storage_policy_wiring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package main

import (
"bytes"
"context"
"io"
"os"
"strings"
"testing"

"github.com/gastownhall/gascity/internal/beads"
"github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/session"
)

// capableBackingStore is a backend that DOES implement the optional
// StorageCreateStore capability, like *beads.BdStore. It stands in for the
// deployments where session beads already route to the wisps table.
type capableBackingStore struct {
beads.Store
created []beads.Bead
}

func (s *capableBackingStore) Create(b beads.Bead) (beads.Bead, error) {
s.created = append(s.created, b)
return s.Store.Create(b)
}

func (s *capableBackingStore) CreateWithStorage(b beads.Bead, storage beads.StorageClass) (beads.Bead, error) {
b.Ephemeral = storage == beads.StorageEphemeral
b.NoHistory = storage == beads.StorageNoHistory
s.created = append(s.created, b)
return s.Store.Create(b)
}

// incapableBackingStore is a backend that does NOT implement
// StorageCreateStore — the shape *beads.NativeDoltStore had before this change,
// and the shape any future or third-party backend may have, since the
// capability is optional by design.
//
// It records the bead exactly as it arrives, so the test can see whether the
// no-history storage class survived the trip. Routing to the wisps table is
// decided downstream by the beads library on issue.NoHistory
// (beads internal/storage/dolt/issues.go:26), so a bead that arrives with
// NoHistory=false lands in the committed issues table and costs a DOLT_COMMIT.
type incapableBackingStore struct {
beads.Store
created []beads.Bead
}

func (s *incapableBackingStore) Create(b beads.Bead) (beads.Bead, error) {
s.created = append(s.created, b)
return s.Store.Create(b)
}

func (s *incapableBackingStore) lastCreated(t *testing.T) beads.Bead {
t.Helper()
if len(s.created) == 0 {
t.Fatal("no create reached the backing store")
}
return s.created[len(s.created)-1]
}

func sessionCreateSpec() session.CreateSpec {
return session.CreateSpec{
Title: "voxist.planner",
AgentName: "voxist.planner",
Metadata: map[string]string{"state": "start_pending"},
}
}

// controllerCityStore composes a backing store exactly as the controller does
// (cmd/gc/api_state.go): openStoreResultAtForCityWithMode policy-wraps the
// opened store, then wrapWithCachingStore unwraps it, inserts the CachingStore
// and re-wraps the policy layer on the outside.
func controllerCityStore(t *testing.T, backing beads.Store) beads.Store {
t.Helper()
cfg := &config.City{}
cityStore := wrapWithCachingStore(context.Background(), wrapStoreWithBeadPolicies(backing, cfg), nil, false)
return beads.SessionStore{Store: resolveSessionStore(cityStore, cfg, t.TempDir(), nil)}.Store
}

// TestSessionCreateRoutesToNoHistoryOnCapableBackend pins the behavior that
// already works, so the pair of tests localizes the defect rather than just
// reporting one. On a backend that implements StorageCreateStore the session
// policy survives end to end.
func TestSessionCreateRoutesToNoHistoryOnCapableBackend(t *testing.T) {
backing := &capableBackingStore{Store: beads.NewMemStore()}

if _, err := sessionFrontDoor(controllerCityStore(t, backing)).CreateSessionInfo(sessionCreateSpec()); err != nil {
t.Fatalf("CreateSessionInfo: %v", err)
}
if len(backing.created) == 0 {
t.Fatal("no create reached the backing store")
}
if got := backing.created[len(backing.created)-1]; !got.NoHistory {
t.Fatalf("session create on a capable backend: NoHistory = false, want true")
}
}

// TestSessionCreateRoutesToNoHistoryOnIncapableBackend is the vp-ia76 guard.
//
// A backend that cannot honor a storage class must not cause the class to be
// discarded: CachingStore.CreateWithStorage stamps it onto the bead's own
// fields instead (internal/beads/caching_store_writes.go). Discarding it would
// be ADR-0043 Cause 1 — an unsupported capability coerced into the quiet
// default, with no error, no warning, and no signal of any kind.
//
// The consequence is measurable on the live fleet, not theoretical. Measured on
// hq 2026-07-31: 727 of 730 session beads created in 24h landed in the
// committed issues table; 21,911 of 22,427 rows in hq.issues (97.7%) are
// session beads; and over a 6h window 2,885 of 3,363 Dolt commits were
// "bd: update <session-id>". Scratch-store measurement (bd 1.1.0, server mode,
// the fleet's configuration): a session bead in issues costs one Dolt commit on
// create and one on every update; the same bead in wisps costs zero for both.
//
// It asserts the bead the backend actually receives — the value the beads
// library routes on — not that some wrapper was called.
func TestSessionCreateRoutesToNoHistoryOnIncapableBackend(t *testing.T) {
backing := &incapableBackingStore{Store: beads.NewMemStore()}

if _, err := sessionFrontDoor(controllerCityStore(t, backing)).CreateSessionInfo(sessionCreateSpec()); err != nil {
t.Fatalf("CreateSessionInfo: %v", err)
}
got := backing.lastCreated(t)
if !got.NoHistory {
t.Fatal("session create reached a storage-class-incapable backend with NoHistory = false, want true: " +
"the no-history policy was dropped, so the bead lands in the committed issues table " +
"and costs a DOLT_COMMIT on create and on every subsequent update")
}
if got.Ephemeral {
t.Fatal("session create reached the backend with Ephemeral = true, want false: " +
"ephemeral beads are GC/TTL-eligible and are declared incompatible for sessions")
}
}

// TestPolicyStoreCompositionCreatesSessionsSilently is the guard for the
// storage-policy MARKER contract.
//
// internal/session recognizes cmd/gc's policy wrapper structurally: if the
// marker method disappears from either side, the front door stops recognizing
// the wrapper and falls through to the last-resort path, which warns on stderr
// and imposes its own hardcoded class over the configured one. Nothing else
// fails — the bead is still created, the storage class still ends up
// no_history by coincidence of the default, and every other test in this
// package stays green. The observable symptom is the warning, so that is what
// this asserts: stderr SILENCE through the REAL composition
// (wrapStoreWithBeadPolicies + wrapWithCachingStore + the session front door).
//
// Its compile-time half lives in bead_policy_store.go
// (`var _ session.StoragePolicySelfApplying = (*beadPolicyStore)(nil)`), which
// catches a rename on either side at build time. This catches the wiring: a
// composition that never puts the marked wrapper where the front door looks.
func TestPolicyStoreCompositionCreatesSessionsSilently(t *testing.T) {
// The warning ledger is process-wide and keyed by store TYPE, and the
// sibling tests above create sessions through this very type. Without the
// reset, a broken marker contract would warn once for them and then stay
// quiet here — this guard would pass for the wrong reason.
session.ResetStorageWarningsForTest()
t.Cleanup(session.ResetStorageWarningsForTest)

backing := &incapableBackingStore{Store: beads.NewMemStore()}
store := controllerCityStore(t, backing)

if _, ok := store.(session.StoragePolicySelfApplying); !ok {
t.Fatalf("the controller's session store composition is %T, which the session "+
"front door cannot recognize as policy-self-applying; it will warn and "+
"impose its own storage class instead of the configured one", store)
}

r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer r.Close() //nolint:errcheck
old := os.Stderr
os.Stderr = w
restored := false
restore := func() {
if restored {
return
}
restored = true
os.Stderr = old
w.Close() //nolint:errcheck
}
// Deferred, so a t.Fatalf below cannot strand os.Stderr on a dead pipe.
defer restore()

if _, err := sessionFrontDoor(store).CreateSessionInfo(sessionCreateSpec()); err != nil {
t.Fatalf("CreateSessionInfo: %v", err)
}

restore()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("read captured stderr: %v", err)
}
if strings.Contains(buf.String(), "session storage policy NOT applied") {
t.Fatalf("creating a session through the real policy composition warned that the "+
"storage policy was not applied; the marker contract between "+
"beadPolicyStore.AppliesBeadStoragePolicy and "+
"session.StoragePolicySelfApplying is broken. got: %q", buf.String())
}
}
19 changes: 19 additions & 0 deletions internal/beads/bdstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,25 @@ func effectiveStorageFlags(b Bead, storage StorageClass) (ephemeral bool, noHist
}
}

// beadWithStorageClass returns b with its Ephemeral/NoHistory fields set to the
// storage class. It is the field-carrying form of a storage class, for the
// create paths that cannot pass the class out of band as a flag: every backend
// routes a bead to the wisps table on these fields, so stamping them is what
// makes a policy-selected class survive a store that does not implement the
// optional StorageCreateStore capability. StorageDefault leaves b untouched.
func beadWithStorageClass(b Bead, storage StorageClass) (Bead, error) {
ephemeral, noHistory, err := effectiveStorageFlags(b, storage)
if err != nil {
return Bead{}, err
}
if ephemeral && noHistory {
return Bead{}, fmt.Errorf("ephemeral and no-history storage are mutually exclusive")
}
b.Ephemeral = ephemeral
b.NoHistory = noHistory
return b, nil
}

// Get retrieves a bead by ID via bd show.
func (s *BdStore) Get(id string) (Bead, error) {
// Read via the transient-retry wrapper so a Get that races a managed-Dolt
Expand Down
50 changes: 44 additions & 6 deletions internal/beads/caching_store_writes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,52 @@ func (c *CachingStore) Create(b Bead) (Bead, error) {

// CreateWithStorage passes through a policy-selected storage class to backing
// stores that support table-specific creates, then updates the cache.
//
// A backing store without the optional StorageCreateStore capability does NOT
// cause the class to be discarded: the class is stamped onto the bead's own
// Ephemeral/NoHistory fields and the plain Create carries it. Every storage
// backend routes on those fields (the beads library sends Ephemeral or
// NoHistory issues to the dolt_ignore'd wisps table and skips DOLT_COMMIT for
// them), so the policy still lands. Dropping the class here instead — the
// previous behavior — silently created every session bead in the committed
// issues table on any deployment whose backend lacks the capability, costing
// one Dolt commit per create and per subsequent update (vp-ia76: 727 of 730
// session beads in 24h on the live hq city).
//
// BLAST RADIUS. This is not a session-only change. cmd/gc's policy layer routes
// six policy names through CreateWithStorage (bead_policy_store.go:
// policyNameForBead -> effectiveBeadStorage), so on a backing that lacks the
// capability EVERY one of them starts carrying its class where it previously
// carried none:
//
// - session, wait, nudge, order_tracking: no_history under both semantics.
// NoHistory does not change query visibility (ListQuery.matchesTier only
// filters on Ephemeral), so these gain wisps-table routing and lose nothing.
// - workflow: no_history under bd-105 ready semantics, history otherwise.
// - wisp: EPHEMERAL under bd-105 ready semantics. This is the one to look at
// twice — an ephemeral bead is GC/TTL-eligible AND is dropped by the default
// TierIssues read. Reads through the policy layer are safe (it expands
// TierIssues to TierBoth), but a raw un-wrapped read of the same store now
// misses newly created wisps on such a deployment.
//
// A capable backing (BdStore) already behaved this way — the class was forwarded
// and honored — so this converges incapable backings onto the behavior capable
// ones already had, rather than inventing a new one.
//
// An explicit class also OVERRIDES fields the caller stamped by hand:
// StorageHistory clears Ephemeral/NoHistory. Only StorageDefault leaves the
// incoming bead alone.
func (c *CachingStore) CreateWithStorage(b Bead, storage StorageClass) (Bead, error) {
storageBacking, ok := c.backing.(StorageCreateStore)
if !ok {
return c.Create(b)
if storageBacking, ok := c.backing.(StorageCreateStore); ok {
return c.createWith(func() (Bead, error) {
return storageBacking.CreateWithStorage(b, storage)
})
}
return c.createWith(func() (Bead, error) {
return storageBacking.CreateWithStorage(b, storage)
})
staged, err := beadWithStorageClass(b, storage)
if err != nil {
return Bead{}, fmt.Errorf("caching store create: %w", err)
}
return c.Create(staged)
}

func (c *CachingStore) createWith(create func() (Bead, error)) (Bead, error) {
Expand Down
Loading
Loading