@voltro/plugin-flags 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,10 +1,118 @@
1
1
  import { ColumnBuilder } from '@voltro/database';
2
+ import { DataStore } from '@voltro/database';
2
3
  import { Effect } from 'effect';
3
4
  import { FieldDefinitions } from '@voltro/database';
4
5
  import { Schema } from 'effect';
5
6
  import { Table } from '@voltro/database';
6
7
  import { VoltroPlugin } from '@voltro/protocol';
7
8
 
9
+ /** `TypedFlag` with the value type erased — what the plugin and the registry
10
+ * hold. Sound because `TypedFlag` is covariant in `A` (see the annotation). */
11
+ export declare type AnyTypedFlag = TypedFlag<unknown>;
12
+
13
+ /**
14
+ * The boot-time form: throw with every problem at once.
15
+ *
16
+ * Loud and at CONSTRUCTION rather than a warning at first evaluation, because
17
+ * the failure this prevents produces a number an operator will act on. An
18
+ * experiment reporting the wrong split is worse than one reporting nothing.
19
+ */
20
+ export declare const assertFlagExperimentLinks: (flags: ReadonlyArray<AnyTypedFlag>, experiments: ReadonlyArray<LinkedExperimentLike>) => void;
21
+
22
+ declare interface BufferEntry {
23
+ readonly flag: string;
24
+ readonly source: FlagUsageSource;
25
+ lastAt: number;
26
+ }
27
+
28
+ /**
29
+ * Check every typed flag that names an experiment against the experiments the
30
+ * app declared. Pure — returns the problems rather than throwing, so the same
31
+ * rules are unit-testable and the caller decides the cost.
32
+ */
33
+ export declare const checkFlagExperimentLinks: (flags: ReadonlyArray<AnyTypedFlag>, experiments: ReadonlyArray<LinkedExperimentLike>) => ReadonlyArray<FlagExperimentLinkProblem>;
34
+
35
+ /**
36
+ * Classify a flag from its definition alone. Pure, total, and independent of
37
+ * every observation — this is the half that is a proof.
38
+ */
39
+ export declare const classifyShape: (value: FlagValue, now: number) => {
40
+ readonly shape: FlagShape;
41
+ readonly reason: string;
42
+ };
43
+
44
+ /** Adapt the framework `DataStore` to a {@link FlagUsageStore}. */
45
+ export declare const dataStoreFlagUsageStore: (store: DataStore) => FlagUsageStore;
46
+
47
+ /** Default flush interval. Five minutes rather than one: the report's
48
+ * resolution is a DAY, so a tighter interval buys nothing and costs one write
49
+ * per active flag per replica. */
50
+ export declare const DEFAULT_FLAG_USAGE_FLUSH_MS = 300000;
51
+
52
+ /**
53
+ * Declare a flag with a value type.
54
+ *
55
+ * ```ts
56
+ * // apps/api/lib/flags.ts — browser-safe, imported by app.config.ts AND by the UI
57
+ * import { Schema } from 'effect'
58
+ * import { defineFlag } from '@voltro/plugin-flags'
59
+ *
60
+ * export const checkoutButton = defineFlag({
61
+ * key: 'checkout.button',
62
+ * value: Schema.Literal('blue', 'green'),
63
+ * default: 'blue',
64
+ * variants: [{ name: 'control', value: 'blue' }, { name: 'green', value: 'green' }],
65
+ * experiment: 'checkout-button-color',
66
+ * })
67
+ *
68
+ * export const pageSize = defineFlag({
69
+ * key: 'search.pageSize',
70
+ * value: Schema.Number,
71
+ * default: 20,
72
+ * // default: 'twenty', ← Type 'string' is not assignable to type 'number'
73
+ * })
74
+ * ```
75
+ *
76
+ * Validation that CANNOT wait for a type error runs here, at declaration, so a
77
+ * malformed flag fails at import rather than at the first evaluation:
78
+ * a duplicate variant name, an `offVariant` naming no variant, a non-positive
79
+ * weight, a rollout outside 0..100.
80
+ */
81
+ export declare const defineFlag: <S extends Schema.Schema.AnyNoContext>(spec: DefineFlagSpec<S>) => TypedFlag<Schema.Schema.Type<S>>;
82
+
83
+ export declare interface DefineFlagSpec<S extends Schema.Schema.AnyNoContext> {
84
+ /** The flag key — `dotted.camelCase`, matching the `flags: { … }` record. */
85
+ readonly key: string;
86
+ /** The value Schema. Everything else in this spec is checked against it. */
87
+ readonly value: S;
88
+ /**
89
+ * The value served when the flag does not resolve to a variant.
90
+ *
91
+ * A wrong value here is a COMPILE ERROR. `NoInfer` is one of three reasons
92
+ * why and, measured, the redundant one — see the header for the other two and
93
+ * for why it is kept anyway.
94
+ */
95
+ readonly default: NoInfer<Schema.Schema.Type<S>>;
96
+ /** The value arms, each checked against the Schema. */
97
+ readonly variants?: ReadonlyArray<TypedFlagVariant<NoInfer<Schema.Schema.Type<S>>>>;
98
+ /** Variant name treated as the "off" state for boolean resolution. */
99
+ readonly offVariant?: string;
100
+ /** Master switch. `false` ⇒ off for everyone. Default true. */
101
+ readonly enabled?: boolean;
102
+ /** Percentage rollout 0..100. Omit ⇒ 100. */
103
+ readonly rollout?: number;
104
+ /** OR-of-rules targeting. */
105
+ readonly targeting?: ReadonlyArray<FlagTargetingRule>;
106
+ /** Bucket on the tenant id instead of the subject id. */
107
+ readonly rolloutBy?: 'subject' | 'tenant';
108
+ /** Time-boxed / ramping schedule. */
109
+ readonly schedule?: FlagSchedule;
110
+ /** Surfaced in the dashboard. */
111
+ readonly description?: string;
112
+ /** IN-11 — the `defineExperiment` name this flag's arms report uplift for. */
113
+ readonly experiment?: string;
114
+ }
115
+
8
116
  /** Convert a resolved flag to a dashboard/inspect row. Surfaces the variant
9
117
  * names + a `scheduled` flag so the panel can badge multivariate / time-boxed
10
118
  * flags without re-deriving the definition. */
@@ -73,6 +181,12 @@ export declare const featureFlagsTable: Table<"_voltro_feature_flags", FieldDefi
73
181
  readonly updatedAt: ColumnBuilder<Date, "timestamp", true>;
74
182
  }>, true, never>;
75
183
 
184
+ /** What the report structurally cannot see. Shipped in the payload. */
185
+ export declare const FLAG_LIFECYCLE_LIMITS: ReadonlyArray<string>;
186
+
187
+ /** The observation table name. */
188
+ export declare const FLAG_USAGE_TABLE = "_voltro_feature_flag_usage";
189
+
76
190
  /** One kill-switch audit row (who flipped what, when, old→new). */
77
191
  export declare interface FlagAuditRow {
78
192
  readonly flag: string;
@@ -97,6 +211,17 @@ export declare const flagAuditTable: Table<"_voltro_feature_flag_audit", FieldDe
97
211
  readonly at: ColumnBuilder<Date, "timestamp", true>;
98
212
  }>, true, "byFlagAuditFlag" | "byFlagAuditAt">;
99
213
 
214
+ /** The result of decoding an UNTRUSTED value (a DB override, a dashboard edit)
215
+ * against a flag's Schema. Deliberately not an exception: the caller's correct
216
+ * response is to keep the code-declared value and say so, not to unwind. */
217
+ export declare type FlagDecodeResult<A> = {
218
+ readonly ok: true;
219
+ readonly value: A;
220
+ } | {
221
+ readonly ok: false;
222
+ readonly reason: string;
223
+ };
224
+
100
225
  export declare interface FlagDefinition {
101
226
  /** Master switch. `false` → off for everyone regardless of rollout/targeting. Default true. */
102
227
  readonly enabled?: boolean;
@@ -135,6 +260,70 @@ declare const FlagDisabled_base: Schema.TaggedErrorClass<FlagDisabled, "FlagDisa
135
260
  flag: typeof Schema.String;
136
261
  }>;
137
262
 
263
+ export declare interface FlagExperimentLinkProblem {
264
+ readonly flag: string;
265
+ readonly experiment: string;
266
+ readonly problem: string;
267
+ }
268
+
269
+ export declare interface FlagLifecycleFinding {
270
+ readonly key: string;
271
+ readonly shape: FlagShape;
272
+ /** Plain-language reason for `shape`, quoting the fields that decided it. */
273
+ readonly shapeReason: string;
274
+ readonly usage: FlagUsageVerdict;
275
+ /** ISO instant of the newest TARGETED evaluation in the window, or null. */
276
+ readonly lastTargetedAt: string | null;
277
+ /** ISO instant of the newest BULK delivery in the window, or null. A flag
278
+ * that only ever appears here is one a client was HANDED, not one a client
279
+ * was measured reading. */
280
+ readonly lastBulkAt: string | null;
281
+ /** Targeted evaluations seen by THIS process since it started. Not persisted
282
+ * and not summed across replicas — a live pulse, not a total. */
283
+ readonly liveTargetedEvaluations: number;
284
+ /**
285
+ * True only when something here is PROVEN: a constant/expired shape, or a
286
+ * `stale` usage verdict over a sufficient window. `neverObserved` never sets
287
+ * it, because a flag declared yesterday looks exactly the same.
288
+ */
289
+ readonly removalCandidate: boolean;
290
+ /** The evidence, in the order it was applied. */
291
+ readonly why: ReadonlyArray<string>;
292
+ }
293
+
294
+ export declare interface FlagLifecycleInput {
295
+ /** The live flag set — the same record the registry holds. */
296
+ readonly flags: Record<string, FlagValue>;
297
+ /** Persisted observations. Empty is meaningful (nothing observed yet), which
298
+ * is why `tracking` is a separate input rather than inferred from length. */
299
+ readonly usage: ReadonlyArray<FlagUsageRow>;
300
+ /** Is recording on? `false` ⇒ every verdict is `untracked`. */
301
+ readonly tracking: boolean;
302
+ /** Targeted evaluations seen by this process, per flag. */
303
+ readonly liveCounts?: ReadonlyMap<string, number>;
304
+ /** A flag with no targeted evaluation in this many days is `stale`. */
305
+ readonly staleAfterDays: number;
306
+ /** The observation retention TTL, in days. The window ceiling. */
307
+ readonly retentionDays: number;
308
+ readonly now?: number;
309
+ }
310
+
311
+ export declare interface FlagLifecycleReport {
312
+ readonly coverage: FlagUsageCoverage;
313
+ readonly findings: ReadonlyArray<FlagLifecycleFinding>;
314
+ /** What this report structurally cannot see. Shipped WITH the data, not in a
315
+ * docs page nobody reads next to the number they are about to act on. */
316
+ readonly limits: ReadonlyArray<string>;
317
+ }
318
+
319
+ /**
320
+ * Build the lifecycle report.
321
+ *
322
+ * Pure: definitions + rows + a clock in, a report out. Nothing here reads a
323
+ * store, so every rule above is unit-testable at a chosen instant.
324
+ */
325
+ export declare const flagLifecycleReport: (input: FlagLifecycleInput) => FlagLifecycleReport;
326
+
138
327
  /** A persisted override row (`_voltro_feature_flags`). `targeting` is JSON. */
139
328
  declare interface FlagOverrideRow {
140
329
  readonly key: string;
@@ -181,18 +370,67 @@ export declare interface FlagSchedule {
181
370
  };
182
371
  }
183
372
 
373
+ /** What the DEFINITION alone proves about a flag. */
374
+ export declare type FlagShape =
375
+ /** Serves the same answer to everyone, forever: `rollout` ≥ 100, no
376
+ * targeting, no variants, no live schedule. The flag is a constant `true`. */
377
+ 'constantOn'
378
+ /** `enabled: false`, or a rollout of 0 — a constant `false`. The guarded path
379
+ * is unreachable THROUGH THIS FLAG (which is not the same as unreachable). */
380
+ | 'constantOff'
381
+ /** A schedule whose `deactivateAt` has passed. It can never be on again. */
382
+ | 'expired'
383
+ /** A schedule whose `activateAt` is still in the future. Not dead — pending. */
384
+ | 'notYetActive'
385
+ /** Genuinely does work: a partial rollout, targeting, variants, or a ramp. */
386
+ | 'conditional';
387
+
184
388
  export declare const flagsPlugin: (options?: FlagsPluginOptions) => VoltroPlugin;
185
389
 
186
390
  export declare interface FlagsPluginOptions {
187
391
  /** Flags as code. The baseline; a postgres store overlays runtime toggles. */
188
392
  readonly flags?: Record<string, FlagValue>;
393
+ /**
394
+ * Flags declared with `defineFlag()` — a per-flag VALUE Schema, so a wrong
395
+ * default is a compile error and a runtime override that does not match is
396
+ * refused instead of served. Their keys join the same namespace as `flags`;
397
+ * a key in both is refused at construction.
398
+ */
399
+ readonly typedFlags?: ReadonlyArray<AnyTypedFlag>;
400
+ /**
401
+ * The `defineExperiment()` definitions any typed flag links to via
402
+ * `experiment:`. Required when at least one does — the linkage is validated
403
+ * at construction (see `experimentLink.ts` for why a name alone is not
404
+ * enough), and an unvalidated link reports uplift for a split nobody served.
405
+ */
406
+ readonly experiments?: ReadonlyArray<LinkedExperimentLike>;
407
+ /** Evaluation observation + dead-flag reporting. */
408
+ readonly usage?: FlagUsageOptions;
189
409
  /** `'memory'` (config-as-code, default) | `'postgres'` (runtime-toggleable
190
410
  * via `_voltro_feature_flags`, overlaid on the config baseline). */
191
411
  readonly store?: 'memory' | 'postgres';
192
412
  /** Declarative gate: map an rpc tag (or regex) → flag key. A call to a gated
193
413
  * tag fails `FlagDisabled` BEFORE the handler runs when the flag is off. */
194
414
  readonly gatedBy?: Record<string, string>;
195
- /** Disambiguates multiple instances. */
415
+ /**
416
+ * Namespace for this plugin's rpc tags + inspect endpoints. Default `flags`.
417
+ *
418
+ * Set it when your app already publishes under that name — an exact tag
419
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
420
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
421
+ * installations within it.
422
+ *
423
+ * The cost, stated because nothing else states it: the local and cloud
424
+ * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
425
+ * install keeps working while its dashboard panel 404s. Alias to escape a
426
+ * collision, not for taste.
427
+ */
428
+ readonly alias?: string;
429
+ /**
430
+ * Discriminator for a SECOND installation of this plugin, when one app runs
431
+ * two (`@voltro/plugin-flags#analytics`). Not a rename — for that use
432
+ * `alias`.
433
+ */
196
434
  readonly name?: string;
197
435
  }
198
436
 
@@ -239,9 +477,154 @@ export declare interface FlagTargetingRule {
239
477
  readonly metadata?: Record<string, string>;
240
478
  }
241
479
 
480
+ /**
481
+ * Process-wide evaluation buffer.
482
+ *
483
+ * A module-level singleton for the same reason `registry` is one: the guards
484
+ * (`isFlagEnabled`, `requireFlag`) are free functions a handler calls without
485
+ * reaching the plugin instance, so the recorder has to be reachable the same
486
+ * way. Recording is OFF until the plugin turns it on — an app on the memory
487
+ * tier, or one that opted out, pays nothing but a boolean check.
488
+ */
489
+ declare class FlagUsageBuffer {
490
+ private on;
491
+ private readonly pending;
492
+ /** Since-process-start counters, surfaced live in the inspect payload. Never
493
+ * persisted — see the header on why a persisted count would be a lie. */
494
+ private readonly liveCounts;
495
+ enable(): void;
496
+ disable(): void;
497
+ get enabled(): boolean;
498
+ record(flag: string, source: FlagUsageSource, at?: number): void;
499
+ /** Take + clear the pending set. The caller owns writing it. */
500
+ drain(): ReadonlyArray<BufferEntry>;
501
+ /** Put drained entries back after a failed flush — a store hiccup must not
502
+ * silently erase the observation that keeps a flag classified `active`. */
503
+ restore(entries: ReadonlyArray<BufferEntry>): void;
504
+ /** Targeted evaluations this PROCESS has seen, per flag. */
505
+ liveTargetedCounts(): ReadonlyMap<string, number>;
506
+ /** Test seam — resets everything, including the live counters. */
507
+ reset(): void;
508
+ }
509
+
510
+ export declare const flagUsageBuffer: FlagUsageBuffer;
511
+
512
+ export declare interface FlagUsageCoverage {
513
+ /** Is evaluation being recorded at all? `false` ⇒ every `usage` is `untracked`. */
514
+ readonly tracking: boolean;
515
+ /** ISO day of the earliest retained observation, or null when there is none. */
516
+ readonly observedSince: string | null;
517
+ /** Whole days between `observedSince` and now. 0 when nothing is observed. */
518
+ readonly observedDays: number;
519
+ /** The retention TTL in days — the hard ceiling on `observedDays`. */
520
+ readonly retentionDays: number;
521
+ /** The threshold a `stale` verdict is measured against. */
522
+ readonly staleAfterDays: number;
523
+ }
524
+
525
+ export declare interface FlagUsageFlushHandle {
526
+ /** Write whatever is buffered right now. */
527
+ readonly flushNow: () => Promise<void>;
528
+ /** Stop the timer AND write the last window — a shutdown that drops it is how
529
+ * a flag evaluated only in the final minutes reads as stale. */
530
+ readonly stop: () => Promise<void>;
531
+ }
532
+
533
+ export declare interface FlagUsageFlushOptions {
534
+ readonly store: FlagUsageStore;
535
+ /** How often the buffer is written. Default 5 min. */
536
+ readonly intervalMs?: number;
537
+ readonly log?: {
538
+ readonly warn?: (message: string, fields?: Record<string, unknown>) => void;
539
+ };
540
+ }
541
+
542
+ /** Tunables for evaluation observation + the dead-flag report. Every number the
543
+ * framework would otherwise choose on your behalf is a field with a default. */
544
+ export declare interface FlagUsageOptions {
545
+ /**
546
+ * Record which flags are evaluated, so the lifecycle report can answer "not
547
+ * consulted since when".
548
+ *
549
+ * Default: ON when `store: 'postgres'`, OFF otherwise — the memory tier has
550
+ * no store to write to. Setting `true` on the memory tier is refused at
551
+ * construction rather than silently ignored: a dead-flag report that
552
+ * quietly observed nothing is the failure mode this whole feature is about.
553
+ */
554
+ readonly track?: boolean;
555
+ /** How often buffered observations are written. Default 5 min — the report's
556
+ * resolution is a DAY, so a tighter interval buys nothing. */
557
+ readonly flushIntervalMs?: number;
558
+ /** A flag with no server-side read in this many days is reported `stale`.
559
+ * Default 30. Must be ≤ `retentionDays`, or staleness is never provable. */
560
+ readonly staleAfterDays?: number;
561
+ /** How long observations are kept. Default 90 days; also settable with
562
+ * `VOLTRO_FLAG_USAGE_TTL_HOURS`, which wins. This is the report's hard
563
+ * observation ceiling, not just a disk bound. */
564
+ readonly retentionDays?: number;
565
+ }
566
+
567
+ /** One persisted observation, as the report reads it. */
568
+ export declare interface FlagUsageRow {
569
+ readonly flag: string;
570
+ readonly day: string;
571
+ readonly source: FlagUsageSource;
572
+ readonly lastAt: Date;
573
+ }
574
+
575
+ export declare const flagUsageRowId: (flag: string, day: string, source: FlagUsageSource) => string;
576
+
577
+ /** Which kind of read produced the observation. See the header for why the
578
+ * distinction is load-bearing rather than bookkeeping. */
579
+ export declare type FlagUsageSource = 'targeted' | 'bulk';
580
+
581
+ export declare interface FlagUsageStore {
582
+ readonly upsertMany: (entries: ReadonlyArray<{
583
+ readonly flag: string;
584
+ readonly source: FlagUsageSource;
585
+ readonly lastAt: Date;
586
+ }>) => Promise<void>;
587
+ readonly load: () => Promise<ReadonlyArray<FlagUsageRow>>;
588
+ }
589
+
590
+ /**
591
+ * One (flag, UTC day, source) observation.
592
+ *
593
+ * `lastAt` is last-writer-wins across replicas, which is exactly right at this
594
+ * grain — every writer's value falls inside the same day, and the day is the
595
+ * resolution the report uses.
596
+ */
597
+ export declare const flagUsageTable: Table<"_voltro_feature_flag_usage", FieldDefinitions<{
598
+ readonly id: ColumnBuilder<string, "id", boolean>;
599
+ readonly flag: ColumnBuilder<string, "text", boolean>;
600
+ /** UTC calendar day, `YYYY-MM-DD`. */
601
+ readonly day: ColumnBuilder<string, "text", boolean>;
602
+ /** `targeted` | `bulk`. */
603
+ readonly source: ColumnBuilder<string, "text", boolean>;
604
+ /** The most recent evaluation seen in this bucket. Also the retention clock. */
605
+ readonly lastAt: ColumnBuilder<Date, "timestamp", boolean>;
606
+ }>, true, "byFlagUsageLastAt" | "byFlagUsageFlag">;
607
+
608
+ /** What the OBSERVATIONS prove about a flag. Only `stale` is a proof. */
609
+ export declare type FlagUsageVerdict = 'evaluated' | 'stale' | 'neverObserved' | 'untracked';
610
+
242
611
  /** A flag value as authored: bare boolean (kill-switch) OR a rich definition. */
243
612
  export declare type FlagValue = boolean | FlagDefinition;
244
613
 
614
+ /**
615
+ * The typed value of a `defineFlag()` flag for the caller.
616
+ *
617
+ * Returns `A`, not `unknown` and not a union — the payoff of declaring a value
618
+ * Schema. The value comes from the LIVE registry (so a postgres-tier override
619
+ * is reflected), decoded against the flag's own Schema, falling back to the
620
+ * declared `default`.
621
+ *
622
+ * ```ts
623
+ * const size: number = flagValue(ctx, pageSize)
624
+ * ```
625
+ */
626
+ export declare const flagValue: <A>(ctx: GuardCtx, flag: TypedFlag<A>) => A;
627
+
245
628
  /** One multivariate variant: a named value with an optional rollout weight. */
246
629
  export declare interface FlagVariant {
247
630
  /** Stable variant name (the resolution returns this). */
@@ -253,6 +636,19 @@ export declare interface FlagVariant {
253
636
  readonly weight?: number;
254
637
  }
255
638
 
639
+ /**
640
+ * The NAME of the variant served to the caller — the arm to persist on a row so
641
+ * a `defineExperiment({ variantFrom })` can measure it (IN-11).
642
+ *
643
+ * ```ts
644
+ * await ctx.store.insert('orders', { …, checkoutArm: flagVariant(ctx, checkoutButton) })
645
+ * ```
646
+ *
647
+ * Returns `null` when the flag is not in the registry — writing a guessed arm
648
+ * would put rows in a bucket the flag never served.
649
+ */
650
+ export declare const flagVariant: (ctx: GuardCtx, flag: AnyTypedFlag) => string | null;
651
+
256
652
  /** A concrete variant value — string / number / boolean / JSON. Kept browser-safe. */
257
653
  export declare type FlagVariantValue = string | number | boolean | null | ReadonlyArray<unknown> | Record<string, unknown>;
258
654
 
@@ -261,6 +657,27 @@ export declare type FlagVariantValue = string | number | boolean | null | Readon
261
657
  * companion for callers that don't need the old value. */
262
658
  export declare const flipFlagEnabled: (key: string, enabled: boolean) => boolean | null;
263
659
 
660
+ /**
661
+ * Gate a merged flag definition for a TYPED flag against its Schema.
662
+ *
663
+ * Returns the definition when every variant value decodes, or a refusal.
664
+ * `mergeOverrides` has already produced one definition from (config baseline ⊕
665
+ * DB row) by the time this runs, so the question is not "is the row valid" but
666
+ * "can this flag serve what the merged definition says it serves".
667
+ *
668
+ * A partial acceptance is not on offer, and that is the decision worth
669
+ * defending: dropping the ONE bad variant re-normalises the weights of the rest,
670
+ * so a typo in a 5% arm silently reallocates the other 95% — a change nobody
671
+ * asked for, applied to every subject, reported nowhere.
672
+ */
673
+ export declare const gateOverride: (flag: AnyTypedFlag, merged: FlagDefinition) => {
674
+ readonly ok: true;
675
+ readonly definition: FlagDefinition;
676
+ } | {
677
+ readonly ok: false;
678
+ readonly refusal: OverrideRefusal;
679
+ };
680
+
264
681
  /** Loose ctx shape: the framework `AppContext` (`ctx.request.subject`) OR a
265
682
  * bare `{ subject }`. */
266
683
  declare type GuardCtx = {
@@ -274,15 +691,66 @@ declare type GuardCtx = {
274
691
  /** `true` if the flag is on for the caller. */
275
692
  export declare const isFlagEnabled: (ctx: GuardCtx, key: string) => boolean;
276
693
 
694
+ export declare const isTypedFlag: (value: unknown) => value is AnyTypedFlag;
695
+
696
+ /** The part of a `defineExperiment` result this check needs. An
697
+ * `ExperimentDefinition` from `@voltro/runtime` satisfies it structurally. */
698
+ export declare interface LinkedExperimentLike {
699
+ readonly name: string;
700
+ readonly variants: ReadonlyArray<{
701
+ readonly name: string;
702
+ }>;
703
+ /** Non-null ⇒ the experiment READS its arm off the row (the only mode that
704
+ * can honestly measure a flag). Null/absent ⇒ it assigns its own. */
705
+ readonly variantFrom?: unknown;
706
+ /** Fraction held out of the experiment's OWN assignment. Must be 0 for a
707
+ * flag-driven experiment — see the check below. */
708
+ readonly holdout?: number;
709
+ }
710
+
277
711
  /** Overlay persisted override rows on top of the config baseline. An override
278
712
  * fully replaces the config flag's definition (last-write-wins), so toggling a
279
713
  * flag off in the DB beats the code default. Pure. */
280
714
  export declare const mergeOverrides: (config: Record<string, FlagValue>, rows: ReadonlyArray<FlagOverrideRow>) => Record<string, FlagValue>;
281
715
 
716
+ /** Why a runtime override was refused — surfaced in the boot/inspect log so a
717
+ * dashboard edit that "did nothing" says what it did instead. */
718
+ export declare interface OverrideRefusal {
719
+ readonly key: string;
720
+ readonly reason: string;
721
+ }
722
+
723
+ /** Record one flag evaluation. A no-op (one boolean read) when tracking is off. */
724
+ export declare const recordFlagEvaluation: (flag: string, source: FlagUsageSource, at?: number) => void;
725
+
726
+ /**
727
+ * Retention for the observation table.
728
+ *
729
+ * Registered together with the table, in the same change, because the two are
730
+ * one decision: the TTL is also the report's OBSERVATION CEILING. A flag cannot
731
+ * be shown as "not evaluated in 120 days" when the evidence only reaches back
732
+ * 90 — `flagLifecycleReport` reads this number and downgrades the finding to
733
+ * `insufficientHistory` rather than guessing.
734
+ */
735
+ export declare const registerFlagUsageRetention: (env?: NodeJS.ProcessEnv, defaultDays?: number) => number;
736
+
282
737
  /** Effect-native guard — fails typed `FlagDisabled` when the flag is off for
283
738
  * the caller. Declare `error: FlagDisabled` on the procedure for a typed client. */
284
739
  export declare const requireFlag: (ctx: GuardCtx, key: string) => Effect.Effect<void, FlagDisabled>;
285
740
 
741
+ /**
742
+ * A resolved variant as either side sees it: the server gets it from
743
+ * `evaluateVariant`, the browser from the `flags.variants` wire payload. Both
744
+ * shapes are `{ name, value, enabled }`, which is why one function can serve
745
+ * both — and why it must, since a server guard and the component it gates
746
+ * disagreeing about a flag's value is the exact bug typing this was for.
747
+ */
748
+ export declare interface ResolvedFlagVariant {
749
+ readonly name: string;
750
+ readonly value: unknown;
751
+ readonly enabled: boolean;
752
+ }
753
+
286
754
  /**
287
755
  * Resolve WHICH variant a subject gets — deterministic + stable, weighted by
288
756
  * each variant's `weight` (equal split when omitted). Buckets on a `variant`-
@@ -297,9 +765,92 @@ export declare const resolveVariant: (key: string, def: FlagDefinition, subject:
297
765
  * persists only when a postgres overlay store is wired. */
298
766
  export declare const setFlagEnabled: (key: string, enabled: boolean) => boolean;
299
767
 
768
+ export declare const startFlagUsageFlush: (options: FlagUsageFlushOptions) => FlagUsageFlushHandle;
769
+
300
770
  /** Normalise a bare boolean to a definition. */
301
771
  export declare const toDefinition: (value: FlagValue) => FlagDefinition;
302
772
 
773
+ /**
774
+ * A flag with a value type.
775
+ *
776
+ * `out A` is load-bearing rather than decorative: the plugin holds these in one
777
+ * `ReadonlyArray<TypedFlag<unknown>>`, which is only sound while every member
778
+ * that mentions `A` is a read position. If you add a field that CONSUMES an `A`
779
+ * (a `(value: A) => …` callback, say), that annotation is what will stop you —
780
+ * and the fix is to erase it behind a function returning a result, the way
781
+ * `decode` already does, not to drop the annotation.
782
+ */
783
+ export declare interface TypedFlag<out A> {
784
+ /** Brand — lets the plugin discriminate a typed flag from a bare `FlagValue`. */
785
+ readonly _voltroTypedFlag: true;
786
+ /** The flag key. Shares the flag namespace with `flags: { … }` entries; a
787
+ * duplicate is refused at plugin construction. */
788
+ readonly key: string;
789
+ /** Human label of the value Schema (`number`, `"a" | "b"`, …) — for the
790
+ * dashboard panel and for the refusal message when an override fails to
791
+ * decode. Derived from the Schema's AST, so it cannot drift from it. */
792
+ readonly valueType: string;
793
+ /** The value served whenever the flag does not resolve to a variant: it is
794
+ * off for this caller, or it declares no variants at all. Compile-checked
795
+ * against the Schema. */
796
+ readonly defaultValue: A;
797
+ /** The value arms. Empty ⇒ the flag always serves `defaultValue` (which the
798
+ * lifecycle report classifies as `constantOn`/`constantOff` — a flag that
799
+ * cannot serve anything else is one to delete). */
800
+ readonly variants: ReadonlyArray<TypedFlagVariant<A>>;
801
+ /** Decode an untrusted value against the flag's Schema — the runtime half of
802
+ * the contract `default` gets at compile time. */
803
+ readonly decode: (input: unknown) => FlagDecodeResult<A>;
804
+ /** The evaluator-facing definition. This is what goes into the registry the
805
+ * existing `evaluateFlag` / `resolveVariant` already read; nothing about
806
+ * evaluation changes because a flag is typed. */
807
+ readonly definition: FlagDefinition;
808
+ /**
809
+ * IN-11 — the `defineExperiment` this flag's variants report uplift for.
810
+ *
811
+ * A NAME rather than the definition object, on purpose: `defineExperiment`
812
+ * lives in `@voltro/runtime`, which is server-only, and this module has to
813
+ * stay loadable in a browser bundle. The link is validated at BOOT, where
814
+ * both sides are known (`assertFlagExperimentLinkage`) — a flag naming an
815
+ * experiment that does not exist, or whose arms disagree with the flag's,
816
+ * refuses to start rather than reporting uplift for arms nobody is served.
817
+ */
818
+ readonly experiment?: string;
819
+ }
820
+
821
+ /** One named arm of a typed flag. `value` is checked against the flag's Schema
822
+ * at COMPILE time. Weights behave exactly as `FlagVariant.weight`. */
823
+ export declare interface TypedFlagVariant<out A> {
824
+ readonly name: string;
825
+ readonly value: A;
826
+ /** Relative allocation weight. Omit ⇒ equal split. */
827
+ readonly weight?: number;
828
+ }
829
+
830
+ /**
831
+ * The typed value for a resolution.
832
+ *
833
+ * DECODE FIRST, fall back to `defaultValue` — never branch on `enabled`. That
834
+ * ordering is what makes every case come out right with one rule:
835
+ *
836
+ * - off, boolean-typed flag → the resolution's value IS `false`, and `false`
837
+ * decodes against `Schema.Boolean`. The caller gets `false`, not whatever
838
+ * the author happened to write as `default`.
839
+ * - off, value-typed flag → the resolution's value is the boolean `false`,
840
+ * which does NOT decode against `Schema.Number` / a literal union, so the
841
+ * caller gets `defaultValue`. Correct, and reached without a special case.
842
+ * - on, with variants → the served variant's value decodes.
843
+ * - unknown / not yet loaded → no resolution at all → `defaultValue`.
844
+ *
845
+ * A decode failure is silent HERE on purpose: this runs per read, on the hot
846
+ * path, and the values that can reach it are gated at the point they ENTER the
847
+ * registry (`gateOverride`), where there is a logger and a boot to fail.
848
+ */
849
+ export declare const typedValueOf: <A>(flag: TypedFlag<A>, resolution: ResolvedFlagVariant | undefined) => A;
850
+
851
+ /** UTC calendar day of an instant, `YYYY-MM-DD`. */
852
+ export declare const utcDay: (at: Date) => string;
853
+
303
854
  /** The result of resolving a multivariate flag for a subject. */
304
855
  export declare interface VariantResolution {
305
856
  /** The resolved variant name. */