Add science control APIs and the hypothesis, experiment and promote CLI
Some checks failed
ci / build (push) Has been cancelled
Some checks failed
ci / build (push) Has been cancelled
Completes FLUID-WP-0006. The loop now runs end to end from the command line: two competing presentation hypotheses, an experiment that issues a routing policy rather than touching traffic, an amendment, a stop that returns traffic to the default, a confirmed outcome, a resolved competition, and a promotion the gate can refuse. Starting or stopping an experiment returns the routing policy for the operator to install rather than installing it. Blueprint 17 keeps the controller out of the traffic path, and installing from the handler would put it straight back in; emitting the document keeps the separation visible instead of implied. `fluid audit trace` now answers the section 25 questions from events rather than summary records, and names the rivals a hypothesis beat: an audit asking which hypotheses were considered is not answered by naming only the winner. Two fixes found by driving the CLI rather than only the tests. Go's flag package stops at the first positional, so ids given after flags silently swallowed them; ids are now taken before parsing. And there was no way to attach a revision to the hypothesis that produced it, which left `audit trace` unable to say why a revision existed -- `hypothesis attach` closes that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
This commit is contained in:
parent
634807a0cb
commit
3407b9cf85
9 changed files with 1006 additions and 29 deletions
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/tegwick/fluid-core/internal/intent"
|
||||
"github.com/tegwick/fluid-core/internal/policy"
|
||||
"github.com/tegwick/fluid-core/internal/publish"
|
||||
"github.com/tegwick/fluid-core/internal/science"
|
||||
"github.com/tegwick/fluid-core/internal/signing"
|
||||
)
|
||||
|
||||
|
|
@ -451,10 +452,10 @@ func runTelemetry(ctx context.Context, g globals, args []string) error {
|
|||
|
||||
// runAudit reconstructs the chain behind a revision.
|
||||
//
|
||||
// This is the command that has to answer the eleven questions in
|
||||
// FluidAPIStandards.md section 25. It is deliberately built from events rather
|
||||
// than from summary records: the records say what is true now, the events say
|
||||
// how it came to be.
|
||||
// This command has to answer the eleven questions in FluidAPIStandards.md
|
||||
// section 25. It is built from events rather than from summary records: the
|
||||
// records say what is true now, the events say how it came to be, and only the
|
||||
// second can settle a question about a decision taken months ago.
|
||||
func runAudit(ctx context.Context, g globals, args []string) error {
|
||||
if len(args) < 2 || args[0] != "trace" {
|
||||
return errors.New("usage: fluid audit trace <revision>")
|
||||
|
|
@ -481,29 +482,96 @@ func runAudit(ctx context.Context, g globals, args []string) error {
|
|||
|
||||
fmt.Printf("Audit trace for %s\n\n", target)
|
||||
|
||||
// Which intent governed it, and under what authority.
|
||||
is := intent.New(store, contract.InterfaceID(iface))
|
||||
if v, err := is.GoverningIntent(ctx, contract.RevisionID(target)); err == nil {
|
||||
fmt.Printf("Governed by %s (%s), authority mode %s\n\n", v.Version, v.Digest, v.Mode)
|
||||
fmt.Printf("Governed by %s (%s) at authority mode %s\n\n", v.Version, v.Digest, v.Mode)
|
||||
} else {
|
||||
fmt.Printf("Governed by: UNKNOWN — no intent binding recorded\n\n")
|
||||
fmt.Printf("Governed by: UNKNOWN - no intent binding recorded\n\n")
|
||||
}
|
||||
|
||||
// What evidence triggered it, and which hypotheses were considered.
|
||||
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
|
||||
if err := printOrigins(ctx, store, hs, contract.RevisionID(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// What happened, in order, and who authorized it.
|
||||
fmt.Println("Lifecycle")
|
||||
w := out()
|
||||
fmt.Fprintln(w, "WHEN\tEVENT\tACTOR\tINPUTS\tREASON")
|
||||
fmt.Fprintln(w, " WHEN\tEVENT\tACTOR\tINPUTS\tREASON")
|
||||
for _, ev := range events {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s:%s\t%s\t%s\n",
|
||||
fmt.Fprintf(w, " %s\t%s\t%s:%s\t%s\t%s\n",
|
||||
ev.OccurredAt.Format(time.RFC3339), ev.EventType,
|
||||
ev.Actor.Type, ev.Actor.ID,
|
||||
strings.Join(ev.Inputs, ","), oneLine(ev.Reason))
|
||||
strings.Join(ev.Inputs, ","), truncate(oneLine(ev.Reason), 64))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
|
||||
// What happened after deployment.
|
||||
telemetry, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
|
||||
if err == nil {
|
||||
fmt.Printf("\n%d telemetry event(s) recorded against %s\n", len(count), target)
|
||||
errorCount := 0
|
||||
for _, ev := range telemetry {
|
||||
if ev.Error != nil {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nObserved\n %d telemetry event(s), %d of them errors\n", len(telemetry), errorCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printOrigins reports the hypotheses and pressure behind a revision.
|
||||
func printOrigins(ctx context.Context, store *evidence.SQLStore, hs *science.HypothesisStore, rev contract.RevisionID) error {
|
||||
all, err := hs.List(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var origins []contract.FluidHypothesis
|
||||
for _, h := range all {
|
||||
for _, candidate := range h.CandidateRevisionRefs {
|
||||
if candidate == rev {
|
||||
origins = append(origins, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
fmt.Println("Origins\n no hypothesis claims this revision")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Origins")
|
||||
w := out()
|
||||
fmt.Fprintln(w, " HYPOTHESIS\tSTATE\tGROUP\tCLAIM")
|
||||
for _, h := range origins {
|
||||
group := ""
|
||||
if h.Competition != nil {
|
||||
group = h.Competition.GroupID
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\n",
|
||||
h.ID, h.State, group, truncate(oneLine(h.Explanation.Claim), 56))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rivals matter: an audit asking which hypotheses were considered is not
|
||||
// answered by naming only the one that won.
|
||||
for _, h := range origins {
|
||||
if h.Competition == nil || len(h.Competition.Alternatives) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s competed against %v in %s\n",
|
||||
h.ID, h.Competition.Alternatives, h.Competition.GroupID)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -592,3 +660,20 @@ func loadSigner(keyID, keyFile string, ephemeral bool) (*signing.Signer, error)
|
|||
}
|
||||
return nil, errors.New("no signing key: pass --key-file, or --ephemeral-key for development")
|
||||
}
|
||||
|
||||
// yamlUnmarshal is a small indirection so science.go does not import yaml
|
||||
// directly; the parser choice stays in one place.
|
||||
func yamlUnmarshal(raw []byte, into any) error { return yaml.Unmarshal(raw, into) }
|
||||
|
||||
// governingMode resolves the authority mode governing a revision.
|
||||
func governingMode(ctx context.Context, store *evidence.SQLStore, iface contract.InterfaceID, rev contract.RevisionID) (intent.AuthorityMode, error) {
|
||||
is := intent.New(store, iface)
|
||||
if v, err := is.GoverningIntent(ctx, rev); err == nil {
|
||||
return v.Mode, nil
|
||||
}
|
||||
v, err := is.Active(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("no intent governs %s and none is active: %w", rev, err)
|
||||
}
|
||||
return v.Mode, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue