diff --git a/cmd/artifact-vault/main.go b/cmd/artifact-vault/main.go index 771a992d..63ce2467 100644 --- a/cmd/artifact-vault/main.go +++ b/cmd/artifact-vault/main.go @@ -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)", }, - "source_branch": "feature/final-artifact-vault", - "note": "do not enable FAV_DEGRADED_READ or fav consumer without approval", + "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; subscriber shadow is read-only", }) case "help", "-h", "--help": usage() diff --git a/internal/app/fav/subscriber.go b/internal/app/fav/subscriber.go index 84c38715..1552c851 100644 --- a/internal/app/fav/subscriber.go +++ b/internal/app/fav/subscriber.go @@ -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 + } +} diff --git a/internal/app/fav/subscriber_test.go b/internal/app/fav/subscriber_test.go index a08485ca..69d35be9 100644 --- a/internal/app/fav/subscriber_test.go +++ b/internal/app/fav/subscriber_test.go @@ -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") + } +} diff --git a/internal/server/vault.go b/internal/server/vault.go index ef015028..d9c1d3ee 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -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", }) } diff --git a/internal/server/vault_test.go b/internal/server/vault_test.go index c6b7afe2..de02ed4d 100644 --- a/internal/server/vault_test.go +++ b/internal/server/vault_test.go @@ -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) {