feat(events): add FAV subscriber shadow without live writes
ObserveShadow computes intended outcome only and refuses FAV_DEGRADED_READ; HandleLease and Register(fav) stay fail-closed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,9 +32,13 @@ func main() {
|
||||
"full recover-plan / recover-apply / gc-preflight / destroy",
|
||||
"server ingest hooks (OnFinalArtifact*)",
|
||||
"durable fav event Register after separate approval",
|
||||
"live staging → artifact → publication (each ≥7d observation)",
|
||||
},
|
||||
"shadow_observe": true,
|
||||
"would_stage": false,
|
||||
"would_publish": false,
|
||||
"source_branch": "feature/final-artifact-vault",
|
||||
"note": "do not enable FAV_DEGRADED_READ or fav consumer without approval",
|
||||
"note": "do not enable FAV_DEGRADED_READ or fav consumer without approval; subscriber shadow is read-only",
|
||||
})
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
|
||||
@@ -8,6 +8,8 @@ package fav
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/events"
|
||||
@@ -24,6 +26,8 @@ type EventConsumer struct {
|
||||
// Enabled must stay false by default. Even when true, events.Registry
|
||||
// still rejects Register(ConsumerFAV) until the hard gate is lifted.
|
||||
Enabled bool
|
||||
// AllowDegradedRead must stay false. Shadow refuses if this is true.
|
||||
AllowDegradedRead bool
|
||||
}
|
||||
|
||||
// HandleLease refuses work while the subscriber is disabled (default).
|
||||
@@ -44,3 +48,46 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
|
||||
func DefaultEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IntendedOutcome is a read-only shadow calculation. It must never be treated
|
||||
// as a write receipt; WouldStage/WouldPublish stay false in this slice.
|
||||
type IntendedOutcome struct {
|
||||
EventName string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
LogicalResource string
|
||||
WouldStage bool
|
||||
WouldPublish bool
|
||||
DegradedReadUsed bool
|
||||
}
|
||||
|
||||
// ObserveShadow computes the intended FAV outcome without business side effects.
|
||||
// FAV_DEGRADED_READ must stay false; live staging/artifact/publication is not opened.
|
||||
func (c *EventConsumer) ObserveShadow(lease events.Lease) (IntendedOutcome, error) {
|
||||
out := IntendedOutcome{
|
||||
EventName: lease.EventName,
|
||||
AggregateType: lease.AggregateType,
|
||||
AggregateID: lease.AggregateID,
|
||||
LogicalResource: strings.TrimSpace(lease.AggregateType) + ":" + strings.TrimSpace(lease.AggregateID),
|
||||
}
|
||||
if c == nil {
|
||||
return out, fmt.Errorf("%w: fav", events.ErrDisabledConsumer)
|
||||
}
|
||||
if c.AllowDegradedRead || envTruthy("FAV_DEGRADED_READ") {
|
||||
return out, fmt.Errorf("fav shadow refuses FAV_DEGRADED_READ")
|
||||
}
|
||||
out.WouldStage = false
|
||||
out.WouldPublish = false
|
||||
out.DegradedReadUsed = false
|
||||
_ = c.Publisher // never Drain / Stage / Publish here
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func envTruthy(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,36 @@ func TestFavSubscriberStillDisabledWhenFlagOnWithoutRegistryApproval(t *testing.
|
||||
t.Fatal("Registry.Register(fav) must remain fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavSubscriberShadowComputesIntendedOutcomeWithoutWrites(t *testing.T) {
|
||||
t.Setenv("FAV_DEGRADED_READ", "false")
|
||||
t.Setenv("FAV_PUBLISHER_ENABLED", "false")
|
||||
c := &fav.EventConsumer{Enabled: false, Env: "dev"}
|
||||
out, err := c.ObserveShadow(events.Lease{
|
||||
EventName: "document.finalized", AggregateType: "document", AggregateID: "doc_1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.WouldStage || out.WouldPublish || out.DegradedReadUsed {
|
||||
t.Fatalf("shadow must not claim writes: %+v", out)
|
||||
}
|
||||
if out.LogicalResource != "document:doc_1" {
|
||||
t.Fatalf("logical=%s", out.LogicalResource)
|
||||
}
|
||||
err = c.HandleLease(context.Background(), events.Lease{}, nil)
|
||||
if !errors.Is(err, events.ErrDisabledConsumer) {
|
||||
t.Fatalf("HandleLease still disabled: %v", err)
|
||||
}
|
||||
|
||||
c.AllowDegradedRead = true
|
||||
if _, err := c.ObserveShadow(events.Lease{}); err == nil {
|
||||
t.Fatal("shadow must refuse degraded read")
|
||||
}
|
||||
|
||||
c.AllowDegradedRead = false
|
||||
t.Setenv("FAV_DEGRADED_READ", "true")
|
||||
if _, err := c.ObserveShadow(events.Lease{AggregateType: "document", AggregateID: "doc_2"}); err == nil {
|
||||
t.Fatal("shadow must refuse process env FAV_DEGRADED_READ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,11 @@ func (s *Server) handleVaultStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"degraded_read": degraded,
|
||||
"store_configured": storeReady,
|
||||
"subscriber_default": false,
|
||||
"note": "fav consumer remains OFF until separate approval; full FAV branch not merged",
|
||||
"shadow_observe": true,
|
||||
"would_stage": false,
|
||||
"would_publish": false,
|
||||
"live_not_opened": true,
|
||||
"note": "fav consumer remains OFF until separate approval; shadow computes intended outcome only; full FAV branch not merged",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ func TestVaultStatusReportsOff(t *testing.T) {
|
||||
if body["degraded_read"] != false {
|
||||
t.Fatalf("degraded_read=%v", body["degraded_read"])
|
||||
}
|
||||
if body["shadow_observe"] != true {
|
||||
t.Fatalf("shadow_observe=%v", body["shadow_observe"])
|
||||
}
|
||||
if body["would_stage"] != false || body["would_publish"] != false {
|
||||
t.Fatalf("shadow must not claim writes: %+v", body)
|
||||
}
|
||||
if body["live_not_opened"] != true {
|
||||
t.Fatalf("live_not_opened=%v", body["live_not_opened"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIndependentVaultRejectsDataRoot(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user