Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
265 lines
9.0 KiB
Go
265 lines
9.0 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var keyPattern = regexp.MustCompile(`^[a-z0-9]+$`)
|
|
|
|
// statePattern is a hygiene format for lifecycle state names. It is deliberately
|
|
// permissive (allows underscores, e.g. a future `quota_locked`) because the state
|
|
// vocabulary is per-provider, not a closed global enum.
|
|
var statePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
|
|
var validProviderKinds = map[ProviderKind]bool{
|
|
KindPayment: true,
|
|
KindProvisioning: true,
|
|
KindNotification: true,
|
|
KindTax: true,
|
|
}
|
|
|
|
var validOperations = map[Operation]bool{
|
|
OpCreate: true,
|
|
OpSetStatus: true,
|
|
OpDelete: true,
|
|
OpList: true,
|
|
OpDescribe: true,
|
|
}
|
|
|
|
// validateManifest enforces the parts of the provider contract that are
|
|
// checkable from the manifest alone: key hygiene, a known kind, known verbs,
|
|
// and key-prefix ownership of every declared resource key.
|
|
func validateManifest(m Manifest) error {
|
|
if !keyPattern.MatchString(m.Key) {
|
|
return fmt.Errorf("provider key %q must match ^[a-z0-9]+$ (no underscores)", m.Key)
|
|
}
|
|
if !validProviderKinds[m.Kind] {
|
|
return fmt.Errorf("provider %q: unknown provider_kind %q", m.Key, m.Kind)
|
|
}
|
|
declaresSetStatus := false
|
|
for _, op := range m.Operations {
|
|
if !validOperations[op] {
|
|
return fmt.Errorf("provider %q: unknown operation %q", m.Key, op)
|
|
}
|
|
if op == OpSetStatus {
|
|
declaresSetStatus = true
|
|
}
|
|
}
|
|
// State-set agreement: a provider declaring set_status must declare the
|
|
// implicit `active` baseline plus at least one non-active state; a provider
|
|
// without set_status must declare no states. (See provider-registry spec.)
|
|
hasActive, hasNonActive := false, false
|
|
for _, s := range m.States {
|
|
if !statePattern.MatchString(string(s)) {
|
|
return fmt.Errorf("provider %q: state %q must match ^[a-z][a-z0-9_]*$", m.Key, s)
|
|
}
|
|
if s == StateActive {
|
|
hasActive = true
|
|
} else {
|
|
hasNonActive = true
|
|
}
|
|
}
|
|
if declaresSetStatus {
|
|
if !hasActive {
|
|
return fmt.Errorf("provider %q: declares set_status but does not declare the `active` state", m.Key)
|
|
}
|
|
if !hasNonActive {
|
|
return fmt.Errorf("provider %q: declares set_status but declares no non-active state", m.Key)
|
|
}
|
|
} else if len(m.States) > 0 {
|
|
return fmt.Errorf("provider %q: declares lifecycle states but does not declare the set_status operation", m.Key)
|
|
}
|
|
prefix := m.Key + "_"
|
|
owned := make(map[string]bool, len(m.ResourceKeys))
|
|
for _, key := range m.ResourceKeys {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
return fmt.Errorf("provider %q: owned resource key %q must be prefixed %q", m.Key, key, prefix)
|
|
}
|
|
owned[key] = true
|
|
}
|
|
for key, ol := range m.OverLimit {
|
|
if !owned[key] {
|
|
return fmt.Errorf("provider %q: over-limit declaration for %q, a key it does not own", m.Key, key)
|
|
}
|
|
switch ol.Behavior {
|
|
case OverLimitDenyNew, OverLimitPark, OverLimitReclaim:
|
|
default:
|
|
return fmt.Errorf("provider %q: over-limit behavior %q for %q is not deny_new, park or reclaim", m.Key, ol.Behavior, key)
|
|
}
|
|
if ol.Behavior != OverLimitDenyNew && strings.TrimSpace(ol.Consequence) == "" {
|
|
return fmt.Errorf("provider %q: over-limit behavior %q for %q needs its consequence sentence", m.Key, ol.Behavior, key)
|
|
}
|
|
if len(ol.Consequence) > 255 {
|
|
return fmt.Errorf("provider %q: over-limit consequence for %q exceeds 255 characters", m.Key, key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// leadingToken returns the substring before the first underscore (the whole
|
|
// string if there is none).
|
|
func leadingToken(key string) string {
|
|
if i := strings.IndexByte(key, '_'); i >= 0 {
|
|
return key[:i]
|
|
}
|
|
return key
|
|
}
|
|
|
|
// checkKeyNesting enforces that the provider-key namespace and the platform
|
|
// (unprefixed) resource-key namespace do not collide: no provider key may be
|
|
// the leading underscore-delimited token of a platform key, which would let one
|
|
// provider's "<key>_" prefix swallow a platform-owned key. (Provider-key vs
|
|
// provider-key nesting is already impossible because provider keys carry no
|
|
// underscore.)
|
|
func checkKeyNesting(providerKeys map[string]bool, platformKeys []string) error {
|
|
for _, key := range platformKeys {
|
|
tok := leadingToken(key)
|
|
if providerKeys[tok] {
|
|
return fmt.Errorf("provider key %q nests under platform resource key %q", tok, key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterProviders reconciles the provider registry from code at boot,
|
|
// parallel to how migrations are assembled from MigrationSources. It validates
|
|
// every manifest, enforces key hygiene against existing platform resource
|
|
// keys, then upserts provider + operation rows and stamps
|
|
// core.resource_keys.provider for each owned key — all in one
|
|
// transaction. It is idempotent: re-running with the same sources only
|
|
// refreshes capability fields and reconciles the declared operation set.
|
|
// Operational state (provider status) is never clobbered.
|
|
//
|
|
// NOTE: the contract's "every declared operation resolves to an implemented
|
|
// activity" check is only partially realizable here. The manifest is the
|
|
// declaration and verb validity is enforced by validateManifest; binding each
|
|
// declared verb to its registered Temporal activity lands with the
|
|
// outbox→activity dispatch layer (M8c and later).
|
|
func RegisterProviders(ctx context.Context, db *sql.DB, sources []ProviderSource) error {
|
|
manifests := make([]Manifest, 0, len(sources))
|
|
providerKeys := make(map[string]bool, len(sources))
|
|
for _, src := range sources {
|
|
m := src.ProviderManifest()
|
|
if err := validateManifest(m); err != nil {
|
|
return err
|
|
}
|
|
if providerKeys[m.Key] {
|
|
return fmt.Errorf("duplicate provider key %q", m.Key)
|
|
}
|
|
providerKeys[m.Key] = true
|
|
manifests = append(manifests, m)
|
|
}
|
|
|
|
owned := make(map[string]bool)
|
|
for _, m := range manifests {
|
|
for _, k := range m.ResourceKeys {
|
|
owned[k] = true
|
|
}
|
|
}
|
|
|
|
q := New(db)
|
|
platformKeys, err := q.ListPlatformResourceKeys(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("listing platform resource keys: %w", err)
|
|
}
|
|
// A provider's own keys may still be NULL-provider at this point (freshly
|
|
// renamed into place, or about to be stamped below), so they surface as
|
|
// platform-owned. Exclude declared-owned keys: only genuinely platform-owned
|
|
// keys participate in the key-nesting check.
|
|
platform := platformKeys[:0]
|
|
for _, k := range platformKeys {
|
|
if !owned[k] {
|
|
platform = append(platform, k)
|
|
}
|
|
}
|
|
if err := checkKeyNesting(providerKeys, platform); err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
qtx := q.WithTx(tx)
|
|
|
|
for _, m := range manifests {
|
|
if _, err := qtx.UpsertProvider(ctx, UpsertProviderParams{
|
|
// The registry column is `provider` (migration 00014, upstream's
|
|
// ratified name); the manifest field is the Go contract's Key.
|
|
Provider: m.Key,
|
|
ProviderKind: string(m.Kind),
|
|
DisplayName: m.DisplayName,
|
|
OperatorSurfacePath: sql.NullString{String: m.OperatorSurfacePath, Valid: m.OperatorSurfacePath != ""},
|
|
}); err != nil {
|
|
return fmt.Errorf("upsert provider %q: %w", m.Key, err)
|
|
}
|
|
|
|
ops := make([]string, 0, len(m.Operations))
|
|
for _, op := range m.Operations {
|
|
ops = append(ops, string(op))
|
|
if err := qtx.AddProviderOperation(ctx, AddProviderOperationParams{
|
|
Provider: m.Key,
|
|
Operation: string(op),
|
|
}); err != nil {
|
|
return fmt.Errorf("add operation %q for %q: %w", op, m.Key, err)
|
|
}
|
|
}
|
|
// Reconcile: drop any operation this provider no longer declares.
|
|
if err := qtx.DeleteProviderOperationsNotIn(ctx, DeleteProviderOperationsNotInParams{
|
|
Provider: m.Key,
|
|
Operations: ops,
|
|
}); err != nil {
|
|
return fmt.Errorf("reconcile operations for %q: %w", m.Key, err)
|
|
}
|
|
|
|
states := make([]string, 0, len(m.States))
|
|
for _, s := range m.States {
|
|
states = append(states, string(s))
|
|
if err := qtx.AddProviderState(ctx, AddProviderStateParams{
|
|
Provider: m.Key,
|
|
State: string(s),
|
|
}); err != nil {
|
|
return fmt.Errorf("add state %q for %q: %w", s, m.Key, err)
|
|
}
|
|
}
|
|
// Reconcile: drop any state this provider no longer declares (an empty
|
|
// declared set removes all rows — correct for a provider that dropped
|
|
// set_status).
|
|
if err := qtx.DeleteProviderStatesNotIn(ctx, DeleteProviderStatesNotInParams{
|
|
Provider: m.Key,
|
|
States: states,
|
|
}); err != nil {
|
|
return fmt.Errorf("reconcile states for %q: %w", m.Key, err)
|
|
}
|
|
|
|
for _, key := range m.ResourceKeys {
|
|
ol := m.OverLimit[key]
|
|
if ol.Behavior == "" {
|
|
ol.Behavior = OverLimitDenyNew
|
|
}
|
|
n, err := qtx.SetResourceKeyProvider(ctx, SetResourceKeyProviderParams{
|
|
ResourceKey: key,
|
|
Provider: sql.NullString{String: m.Key, Valid: true},
|
|
OverLimitBehavior: string(ol.Behavior),
|
|
OverLimitConsequence: sql.NullString{String: ol.Consequence, Valid: ol.Consequence != ""},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("stamp resource key %q for %q: %w", key, m.Key, err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("provider %q declares resource key %q, but no such row exists in core.resource_keys", m.Key, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|