Phase 2 P2 (design §4.1). Locks the condition_expr grammar to:
CondExpr := { "flag": "<known_flag>" }
| { "op": "and"|"or", "args": [<CondExpr>, ...] }
Where <known_flag> must exist in paliad.scenario_flag_catalog (today:
with_ccr / with_amend / with_cci; editorial adds via the catalog
table as needed).
Wire-time validation in RuleEditorService.Create and UpdateDraft —
the rule editor surfaces a 400 with a friendly message before the row
hits the DB. Empty / JSON null inputs pass through (the "no gate"
shape; stored as NULL column).
The validator:
* walks the JSON tree once, collecting every leaf flag name
* rejects mutually-exclusive shapes (leaf + composite in one node)
* rejects empty args, bad op values, empty flag strings
* does ONE batch lookup of the collected leaf names against the
catalog (regardless of expression depth)
Tests:
* 9 shape-only unit tests covering every reject path (no DB needed)
* TestValidateConditionExpr_LiveCatalog covers 6 good shapes + 2
unknown-flag cases against the live catalog
* TestConditionExpr_AllLiveRowsValidate runs the validator over
every active+published condition_expr in paliad.sequencing_rules
to enforce the §4.1 invariant on every deploy (today's 18 rows
all conform — verified via Supabase MCP pre-flight)
Live-DB tests skip cleanly when TEST_DATABASE_URL is unset (same
posture as sibling live tests in this package).
Design: docs/design-deadline-system-revision-2026-05-27.md §4.1
(grammar formalisation). t-paliad-331.
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// condition_expr grammar per design §4.1 (m/paliad#149 Phase 2 P2):
|
|
//
|
|
// CondExpr := { "flag": "<known_flag>" }
|
|
// | { "op": "and"|"or", "args": [<CondExpr>, <CondExpr>, ...] }
|
|
//
|
|
// Leaf nodes reference a flag in paliad.scenario_flag_catalog by key.
|
|
// Composite nodes are recursive — and/or take ≥1 arg each. JSON null
|
|
// (or empty bytes) is also accepted — that's the "no gate" shape and
|
|
// stores as a NULL column.
|
|
//
|
|
// The validator is called from RuleEditorService.Create and
|
|
// UpdateDraft before the row is written. Surfaces friendly errors
|
|
// wrapping ErrInvalidInput so the handler maps cleanly to 400.
|
|
|
|
// ValidateConditionExpr parses the bytes as a CondExpr and verifies
|
|
// every leaf flag is present in the scenario_flag_catalog (one DB
|
|
// lookup, regardless of expression depth). Empty/null input is OK —
|
|
// caller stores NULL.
|
|
func ValidateConditionExpr(ctx context.Context, db *sqlx.DB, raw json.RawMessage) error {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil
|
|
}
|
|
var parsed condExprNode
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return fmt.Errorf("%w: condition_expr is not valid JSON: %v", ErrInvalidInput, err)
|
|
}
|
|
flagNames := map[string]struct{}{}
|
|
if err := walkCondExpr(&parsed, flagNames); err != nil {
|
|
return err
|
|
}
|
|
if len(flagNames) == 0 {
|
|
// Empty leaf set is impossible for a valid CondExpr — walkCondExpr
|
|
// would have rejected it. Defensive belt-and-braces.
|
|
return fmt.Errorf("%w: condition_expr resolved to zero leaf flags", ErrInvalidInput)
|
|
}
|
|
keys := make([]string, 0, len(flagNames))
|
|
for k := range flagNames {
|
|
keys = append(keys, k)
|
|
}
|
|
known, err := loadCatalogFlagKeys(ctx, db, keys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, k := range keys {
|
|
if _, ok := known[k]; !ok {
|
|
return fmt.Errorf("%w: condition_expr references unknown flag %q (not in paliad.scenario_flag_catalog)", ErrInvalidInput, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// condExprNode is the loose-typed parse target. Either Flag is set
|
|
// (leaf) or Op + Args (composite); the validator below enforces
|
|
// mutual exclusivity.
|
|
type condExprNode struct {
|
|
Flag *string `json:"flag,omitempty"`
|
|
Op *string `json:"op,omitempty"`
|
|
Args []condExprNode `json:"args,omitempty"`
|
|
// Extra catches stray keys so we can reject typos like "fla" or
|
|
// "operator" loudly instead of silently treating them as composite.
|
|
Extra map[string]json.RawMessage `json:"-"`
|
|
}
|
|
|
|
// walkCondExpr descends the tree, collecting flag names and validating
|
|
// every node's shape.
|
|
func walkCondExpr(n *condExprNode, flagNames map[string]struct{}) error {
|
|
hasFlag := n.Flag != nil
|
|
hasOp := n.Op != nil
|
|
hasArgs := n.Args != nil
|
|
|
|
if hasFlag && (hasOp || hasArgs) {
|
|
return fmt.Errorf("%w: condition_expr node has both 'flag' and 'op'/'args' — leaf and composite shapes are mutually exclusive", ErrInvalidInput)
|
|
}
|
|
if !hasFlag && !hasOp {
|
|
return fmt.Errorf("%w: condition_expr node must carry either 'flag' (leaf) or 'op'+'args' (composite)", ErrInvalidInput)
|
|
}
|
|
|
|
if hasFlag {
|
|
if *n.Flag == "" {
|
|
return fmt.Errorf("%w: condition_expr leaf has empty flag", ErrInvalidInput)
|
|
}
|
|
flagNames[*n.Flag] = struct{}{}
|
|
return nil
|
|
}
|
|
|
|
// Composite — op must be "and" or "or"; args must be non-empty.
|
|
op := *n.Op
|
|
if op != "and" && op != "or" {
|
|
return fmt.Errorf("%w: condition_expr op=%q must be 'and' or 'or'", ErrInvalidInput, op)
|
|
}
|
|
if len(n.Args) == 0 {
|
|
return fmt.Errorf("%w: condition_expr composite op=%q has empty args", ErrInvalidInput, op)
|
|
}
|
|
for i := range n.Args {
|
|
if err := walkCondExpr(&n.Args[i], flagNames); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadCatalogFlagKeys returns the subset of `flagKeys` present in
|
|
// paliad.scenario_flag_catalog. One round-trip regardless of how many
|
|
// keys the expression carries.
|
|
func loadCatalogFlagKeys(ctx context.Context, db *sqlx.DB, flagKeys []string) (map[string]struct{}, error) {
|
|
if len(flagKeys) == 0 {
|
|
return map[string]struct{}{}, nil
|
|
}
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
|
pq.Array(flagKeys))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup scenario_flag_catalog: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]struct{}{}
|
|
for rows.Next() {
|
|
var k string
|
|
if err := rows.Scan(&k); err != nil {
|
|
return nil, err
|
|
}
|
|
out[k] = struct{}{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|