fluid-core/cmd/fluid/main.go

165 lines
4.1 KiB
Go
Raw Normal View History

// Command fluid is the FLUID operator CLI.
//
// It reads and writes the evidence store directly rather than going through the
// control-plane API, so that an operator can still inspect an interface's
// history when the control plane is down. Auditability must not depend on the
// availability of the thing being audited.
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
const usage = `fluid FLUID operator CLI
Usage:
fluid [--store PATH] [--interface ID] <command> [arguments]
Commands:
intent put Record an interface evolution intent version
intent activate Make a recorded version the governing intent
intent show Show the active or a named intent version
revision publish Publish a signed revision descriptor
revision list List published revisions
revision show Show one revision descriptor
policy put Install a routing policy
policy show Show the current routing policy
Add telemetry ingest, feedback collector, pressure API and insight CLI Completes FLUID-WP-0005. Normalization and redaction live on one path, shared by the in-process emitter and the ingest endpoint: two paths with two normalizations would eventually disagree, and the disagreement would surface as a pressure finding that is really a pipeline bug. Telemetry kind is inferred from event shape rather than defaulting to "request", since an error filed as a request understates the interface's failure rate. A malformed event in a batch does not discard the rest. Feedback is stored as evidence and creates no pressure and no hypothesis on its own, per API Standards 15, with the consumer recorded as the actor so their untrusted status stays visible in the audit trail. The feedback endpoint is the only consumer-reachable part of the control plane. The observation endpoints are not served at all when no pseudonymization salt is configured, rather than served with a generated one: a salt that changed per run would make the same consumer look new every time and every cohort count wrong. Adds an end-to-end test driving real traffic through the gateway and confirming it becomes a classified pressure record, with no raw consumer identity reaching the store. 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
2026-09-04 03:19:06 +02:00
pressure list List recorded interface pressure
pressure analyze Classify recent telemetry into pressure records
pressure dismiss Record that a pressure will not be acted on
cohort Summarize cohort populations
fitness compare Compare a candidate revision against its control
Add science control APIs and the hypothesis, experiment and promote CLI 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
2026-09-04 06:44:47 +02:00
hypothesis Create, advance and compete hypotheses
experiment Design, start, stop and finalize experiments
promote Record a promotion decision
events Show audit events
telemetry Show recorded telemetry
audit trace Reconstruct the history behind a revision
Global flags:
--store PATH Evidence store path (default $FLUID_STORE or ./fluid.db)
--interface ID Interface identifier (default $FLUID_INTERFACE)
`
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
fmt.Fprintln(os.Stderr, "fluid:", err)
os.Exit(1)
}
}
type globals struct {
store string
iface string
remaining []string
}
func parseGlobals(args []string) (globals, error) {
g := globals{
store: envOr("FLUID_STORE", "fluid.db"),
iface: os.Getenv("FLUID_INTERFACE"),
}
i := 0
for i < len(args) {
switch args[i] {
case "--store":
if i+1 >= len(args) {
return g, errors.New("--store needs a value")
}
g.store, i = args[i+1], i+2
case "--interface":
if i+1 >= len(args) {
return g, errors.New("--interface needs a value")
}
g.iface, i = args[i+1], i+2
case "-h", "--help", "help":
fmt.Print(usage)
return g, flag.ErrHelp
default:
if strings.HasPrefix(args[i], "--") {
return g, fmt.Errorf("unknown global flag %q", args[i])
}
g.remaining = args[i:]
return g, nil
}
}
g.remaining = nil
return g, nil
}
func run(args []string) error {
g, err := parseGlobals(args)
if err != nil {
return err
}
if len(g.remaining) == 0 {
fmt.Print(usage)
return nil
}
ctx := context.Background()
cmd, rest := g.remaining[0], g.remaining[1:]
switch cmd {
case "intent":
return runIntent(ctx, g, rest)
case "revision":
return runRevision(ctx, g, rest)
case "policy":
return runPolicy(ctx, g, rest)
case "events":
return runEvents(ctx, g, rest)
case "telemetry":
return runTelemetry(ctx, g, rest)
Add telemetry ingest, feedback collector, pressure API and insight CLI Completes FLUID-WP-0005. Normalization and redaction live on one path, shared by the in-process emitter and the ingest endpoint: two paths with two normalizations would eventually disagree, and the disagreement would surface as a pressure finding that is really a pipeline bug. Telemetry kind is inferred from event shape rather than defaulting to "request", since an error filed as a request understates the interface's failure rate. A malformed event in a batch does not discard the rest. Feedback is stored as evidence and creates no pressure and no hypothesis on its own, per API Standards 15, with the consumer recorded as the actor so their untrusted status stays visible in the audit trail. The feedback endpoint is the only consumer-reachable part of the control plane. The observation endpoints are not served at all when no pseudonymization salt is configured, rather than served with a generated one: a salt that changed per run would make the same consumer look new every time and every cohort count wrong. Adds an end-to-end test driving real traffic through the gateway and confirming it becomes a classified pressure record, with no raw consumer identity reaching the store. 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
2026-09-04 03:19:06 +02:00
case "pressure":
return runPressure(ctx, g, rest)
case "cohort":
return runCohort(ctx, g, rest)
case "fitness":
return runFitness(ctx, g, rest)
Add science control APIs and the hypothesis, experiment and promote CLI 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
2026-09-04 06:44:47 +02:00
case "hypothesis":
return runHypothesis(ctx, g, rest)
case "experiment":
return runExperiment(ctx, g, rest)
case "promote":
return runPromote(ctx, g, rest)
case "audit":
return runAudit(ctx, g, rest)
default:
return fmt.Errorf("unknown command %q (try `fluid help`)", cmd)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// requireInterface fails clearly rather than silently operating on the wrong
// interface's history.
func (g globals) requireInterface() (string, error) {
if g.iface == "" {
return "", errors.New("no interface selected: pass --interface or set FLUID_INTERFACE")
}
return g.iface, nil
}
func (g globals) storePath() string {
if abs, err := filepath.Abs(g.store); err == nil {
return abs
}
return g.store
}