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
409 changes: 209 additions & 200 deletions handoff.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions internal/audit/run/adaptive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,55 @@ func TestUnit_Adaptive_RedactionHoldsEndToEnd(t *testing.T) {
// The run's activity ledger must not carry it either.
_ = time.Now
}

// TestUnit_Adaptive_TheReductionKeepsWhatTheCreateNeeded: a field the API
// demanded at createMinimal is not something the maximal reduction may drop.
//
// The reduction protects the body the entity creates by. That body is the one
// the run got accepted, not the one the plan started from — the plan's is the
// document's guess, and the guess is what needed healing. Protecting the guess
// leaves the healed field droppable, and a reduction that drops it can never
// reach an accepted create at all.
func TestUnit_Adaptive_TheReductionKeepsWhatTheCreateNeeded(t *testing.T) {
t.Parallel()
s := quirkserver.New(t, quirkserver.Quirks{
// Demanded by the API, absent from the document, and named in a
// sentence the loop can act on.
RequiredButUndeclared: []string{"serial"},
NamesRefusedFieldInProse: true,
// One optional field the maximal carries and the API will not take,
// so the maximal is refused and has to be reduced.
RejectsDocumentedValue: map[string]string{"colour": "bad-colour"},
})

p := &plan.Plan{
Entities: []plan.EntityPlan{{
Entity: "thing", Role: "resource", Budget: plan.Budget{Requests: 60},
Steps: []plan.Step{
{Kind: plan.StepCreateMinimal, Method: "POST", Path: "/things",
Body: map[string]any{"name": "tfpfgen-<runid>-thing-name"}},
{Kind: plan.StepCreateMaximal, Method: "POST", Path: "/things",
Body: map[string]any{"name": "tfpfgen-<runid>-thing-name", "colour": "bad-colour"}},
},
}},
Budget: plan.RunBudget{Requests: 300, Objects: 10, Duration: "1m"},
}

_, sum := mustRun(t, testOptions(t, s, p, testEnv(), nil))

var recorded *observe.AcceptedBody
for _, b := range sum.Bodies {
if b.Entity == "thing" {
recorded = b.Maximal
}
}
if recorded == nil {
t.Fatal("no maximal create was ever accepted: the reduction dropped what the create needed")
}
if _, kept := recorded.Request["serial"]; !kept {
t.Errorf("the reduction dropped the field the API demanded: %#v", recorded.Request)
}
if _, dropped := recorded.Request["colour"]; dropped {
t.Errorf("the reduction kept the field the API refused: %#v", recorded.Request)
}
}
6 changes: 6 additions & 0 deletions internal/audit/run/steps_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
if err != nil {
return err
}
// The recipe learns the body that worked. Everything downstream
// replays it — re-creating this entity as another's parent, narrowing
// a refused maximal, cleaning up at the end — and the body the plan
// started from is the document's guess, which is what needed healing
// in the first place.
ent.recipe.minimalBody = cloneAnyMap(rr.body)
r.registry[ent.plan.Entity] = rr.obj
ent.createdAt = time.Now()
ent.ev.sent = sent
Expand Down Expand Up @@ -430,7 +436,7 @@
// one removes. What survives is the fullest create this run could get taken,
// which is what a generated maximal configuration has to be — every field in
// it is one the API demonstrably tolerates alongside the others.
func (r *runner) reduceMaximal(ctx context.Context, ent *entityState, step *plan.Step, body map[string]any, refusal *httpResult) error {

Check failure on line 439 in internal/audit/run/steps_create.go

View workflow job for this annotation

GitHub Actions / 🔍 Lint and repo hygiene

(*runner).reduceMaximal - step is unused (unparam)
minimal := ent.recipe.minimalBody
allowance := searchAllowance(len(body))
last := refusal
Expand Down
21 changes: 12 additions & 9 deletions internal/audit/strategy/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,6 @@ func buildProgram(createBody *specmodel.Schema, gates []Gate, variants []Variant
gatedStep(stepReadConsecutive, v),
)
}
// The widest valid body, per variant.
for _, v := range variants {
prog = append(prog, gatedStep(stepCreateMaximal, v))
}
// One update per writable field, capped.
for i, name := range fieldNames(fields) {
if i == maxUpdateFields {
Expand Down Expand Up @@ -166,11 +162,18 @@ func buildProgram(createBody *specmodel.Schema, gates []Gate, variants []Variant
// prose hypotheses the gate loop did not already cover.
prog = append(prog, perValueSteps(gates, hyps)...)

// Teardown.
prog = append(prog,
Step{Kind: stepDeleteWithConfirmation},
Step{Kind: stepCleanupDelete},
)
// Teardown, and the widest valid body inside it.
//
// The maximal create makes a second object and deletes it again, so it
// runs once the first is gone: an API that keys an object on fields the
// two bodies share — both are synthesised from the same document —
// answers the second create with a conflict, and a conflict says nothing
// about how wide a valid body is.
prog = append(prog, Step{Kind: stepDeleteWithConfirmation})
for _, v := range variants {
prog = append(prog, gatedStep(stepCreateMaximal, v))
}
prog = append(prog, Step{Kind: stepCleanupDelete})
return prog
}

Expand Down
32 changes: 32 additions & 0 deletions internal/audit/strategy/strategy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,35 @@
t.Fatal("resource with no create body should error")
}
}

// TestUnit_Strategy_TheMaximalCreateFollowsTheDelete pins the one ordering the
// maximal create depends on.
//
// It makes a second object and deletes it again, so the first has to be gone
// first: an API that keys an object on fields both bodies carry — and both are
// synthesised from the same document — answers the second create with a
// conflict, and a conflict says nothing about how wide a valid body is.
func TestUnit_Strategy_TheMaximalCreateFollowsTheDelete(t *testing.T) {
s := compile(t, flatSpec, "widget", defaultCfg())

posOf := func(kind string) int {
for i, st := range s.Program {
if string(st.Kind) == kind {
return i
}
}
t.Fatalf("the program has no %s step: %+v", kind, s.Program)
return -1
}

create := posOf("createMinimal")
del := posOf("deleteWithConfirmation")
maximal := posOf("createMaximal")
cleanup := posOf("cleanupDelete")

if !(create < del && del < maximal && maximal < cleanup) {

Check failure on line 778 in internal/audit/strategy/strategy_test.go

View workflow job for this annotation

GitHub Actions / 🔍 Lint and repo hygiene

QF1001: could apply De Morgan's law (staticcheck)
t.Errorf("program order is createMinimal=%d delete=%d createMaximal=%d cleanup=%d; "+
"the maximal create must sit between the delete and the cleanup",
create, del, maximal, cleanup)
}
}
9 changes: 9 additions & 0 deletions internal/quirkserver/behaviour.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ func (s *Server) normalise(field string, v any) any {
return v
}

// refusedFieldDetail renders the missing field as the quirk asks: a bare name,
// or a sentence an adjustment loop can read.
func (s *Server) refusedFieldDetail(field string) string {
if s.quirks.NamesRefusedFieldInProse {
return "field " + field + " is required"
}
return field
}

func (s *Server) missingRequired(body map[string]any) string {
for _, field := range s.quirks.RequiredButUndeclared {
if _, ok := body[field]; !ok {
Expand Down
26 changes: 26 additions & 0 deletions internal/quirkserver/exhibit_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,32 @@ var writeExhibits = map[string]func(*testing.T){
}
},

"NamesRefusedFieldInProse": func(t *testing.T) {
t.Parallel()

// The same missing field, said two ways. Only the sentence is one an
// adjustment loop can act on; the bare name beside a generic title
// could be about anything, which is why it stays unhealable.
bare := New(t, Quirks{RequiredButUndeclared: []string{"key"}})
_, body := post(t, bare.CollectionURL(), map[string]any{"value": "v"})
if detail, _ := body["detail"].(string); detail != "key" {
t.Errorf("without the quirk the detail should be the bare name, got %v", body)
}

prose := New(t, Quirks{RequiredButUndeclared: []string{"key"}, NamesRefusedFieldInProse: true})
status, body := post(t, prose.CollectionURL(), map[string]any{"value": "v"})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", status)
}
if detail, _ := body["detail"].(string); detail != "field key is required" {
t.Errorf("the refusal should spell the field into a sentence, got %v", body)
}

if status, _ := post(t, prose.CollectionURL(), map[string]any{"key": "k"}); status != http.StatusCreated {
t.Errorf("supplying it should succeed, got %d", status)
}
},

"ConditionallyRequired": func(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 1 addition & 1 deletion internal/quirkserver/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
// Naming the field is what lets the auditor write the requirement down
// as observed rather than guessed: an error that does not name it
// could have been about anything.
s.fail(w, http.StatusBadRequest, "missing required field", missing)
s.fail(w, http.StatusBadRequest, "missing required field", s.refusedFieldDetail(missing))
return
}

Expand Down
10 changes: 10 additions & 0 deletions internal/quirkserver/quirks.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ type Quirks struct {
// EventuallyConsistentReads makes the first N reads of a new object 404.
EventuallyConsistentReads int

// NamesRefusedFieldInProse makes a refusal spell the missing field into a
// sentence — "field serial is required" — rather than leaving the bare
// name in the detail beside a generic title.
//
// Both are real. The bare form is deliberately unhealable, because a name
// alone beside a generic title could be about anything; this is the form
// an adjustment loop can act on, and an API that writes it is one whose
// refusals teach the auditor what the create needs.
NamesRefusedFieldInProse bool

// ErrorEnvelope selects which shape errors take. Defaults to problem+json.
ErrorEnvelope Envelope

Expand Down
Loading