@ultimat3/flags 2.0.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/CLAUDE.md ADDED
@@ -0,0 +1,98 @@
1
+ # @ultimat3/flags — boundary
2
+
3
+ Tier 1. May import `@ultimat3/core` and nothing else. Never sideways, never upward.
4
+
5
+ Tier 1 is the lowest its real imports allow — the same rule that placed `db`. Evaluation needs an
6
+ `Actor`, a `Clock` and `UltimateError`, all tier 0. It deliberately does **not** import `entity`,
7
+ `query` or `policy`: a store read on the hot path would make `isEnabled()` async, and tier 1 is
8
+ what lets `policy` (tier 2) call it from inside a predicate.
9
+
10
+ | Rule | Detail |
11
+ |---|---|
12
+ | Exports | `src/index.ts`, explicit, no `export *` |
13
+ | Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
14
+ | Files | one responsibility each, < 200 lines, tests beside the source |
15
+ | Subjects | `src/subject.ts` — one resolver; never a second allow list per record kind |
16
+
17
+ ## Invariants
18
+
19
+ - **`defineFlag()` is a `define*` helper, not a ninth primitive.** A flag has no handler, no input
20
+ schema and no surface of its own, so there is nothing for `primitiveRegistrar` to project. The
21
+ eight kinds in `PRIMITIVE_KINDS` stay eight. A capability that *does* need a handler arrives as a
22
+ factory over an existing primitive — `llm()` returns an `action`.
23
+ - **Evaluation is synchronous, and allocates nothing per declared subject kind.** It runs inside
24
+ policy predicates and render passes. No `await`, no I/O, no date parsing (`expiresAtMs` is
25
+ precomputed), and the expired-flag error is built lazily so a rate-limited call costs a map
26
+ lookup. It is **not literally allocation-free** — `roles` allocates a closure for `some()`, and
27
+ `subjectIdOf` takes an options object — so do not restate that stronger claim. What is
28
+ guaranteed: no `Object.entries`/`Object.keys` pass, no normalisation pass, and nothing at all
29
+ allocated for a flag declaring no subject axis.
30
+ - **A temporary flag without an expiry must not be declarable.** `FlagExpiryIsMandatory` in
31
+ `flag.ts` is a compile-time assertion: loosen the union and `tsc -b packages/flags` fails on that
32
+ line. `toFlag()` re-checks at runtime for snapshots and JS callers. Do not replace either with a
33
+ lint rule.
34
+ - **`X_FLAG_EXPIRED` is reported, never thrown.** A framework that took production down on a date
35
+ nobody remembered setting would teach everyone to declare every flag `permanent`, which is the
36
+ failure this design exists to prevent.
37
+ - **The reporter is core's, not this package's.** `reportError({ source: 'process', severity:
38
+ 'warning' })` is the one error-monitoring seam; a second one here would be a second place an app
39
+ wires its monitor and a second place to look when a report does not arrive. `runtime.ts` holds
40
+ the framework's ONE `reportError` call site in this package, same rule as the metric recorders.
41
+ Never name a vendor here (axiom 7).
42
+ - **The rate limit is keyed on `clock.monotonic()`**, not wall time: an NTP correction or a
43
+ container resuming must not reopen the window and flood the monitor.
44
+ - **Buckets are `fnv1a(key + ':' + subjectId) % 100`.** Never `Math.random()`, and never the
45
+ subject id alone — hashing the subject by itself puts the same cohort in the first slice of every
46
+ rollout the app ever runs. **The assignments are pinned in `bucket.test.ts`**: a rollout already
47
+ live is a promise to the subjects inside it, so changing the hash must break that test, never
48
+ move the boundary silently.
49
+ - **A flag decides about a SUBJECT, and the actor is one kind of subject.** `subject.ts` owns the
50
+ resolution. `actor` and `org` come off the `Actor`; every other kind comes from the `subjects`
51
+ argument at the call site. `actors` and `orgs` in targeting are shorthands for the first two
52
+ kinds, not separate mechanisms — do not add a fourth parallel allow list for a new record kind,
53
+ it already works through `subjects`. `roles` is NOT a subject: a role is a predicate over the
54
+ actor, has no id, and cannot bucket.
55
+ - **One source per kind.** A built-in kind is never read from the call-site map, so there is no
56
+ precedence rule and no second place a tenant comes from. Passing `org` at a call site is dead
57
+ data; with no `actor.orgId` the evaluation raises and the fix line says to mint the actor.
58
+ `assertTargeting` refuses `subjects.actor` / `subjects.org` for the same reason.
59
+ - **The kind space is open, like the flag key space.** No registry of kinds: a typo raises
60
+ `X_FLAG_SUBJECT_REQUIRED` at the first evaluation, the same loud failure `X_FLAG_UNKNOWN` already
61
+ gives an undeclared key. Do not add a `defineSubjectKinds()` — it is a second declaration surface
62
+ buying a check evaluation already makes.
63
+ - **Allow lists beat the rollout.** An operator who named a subject is not overruled by a hash.
64
+ `actors`, `roles`, `orgs` and `subjects` are one rank — any hit is `true`, so their order is
65
+ unobservable. That is the same OR Flipper applies across the actors passed to one `enabled?`.
66
+ - **The subject axis throws rather than degrades.** A kind the evaluation context does not carry
67
+ raises `X_FLAG_SUBJECT_REQUIRED`. Never fall back to the actor axis or to `default`: an answer
68
+ about a record computed from whoever was calling looks like it worked, which is the whole bug
69
+ class. **Every declared kind is resolved before any branch answers** — allow lists included — so
70
+ the raise depends only on the flag and the context, never on declaration order and never on which
71
+ list happened to match. An early `return true` on an allow-list hit is the regression to watch
72
+ for: it hides a missing record from exactly the callers who are on the list. A `null` actor is
73
+ the one exception and still gets `default` — no evaluation context at all, every such call
74
+ answers alike, so no single subject is split.
75
+ - **`bucketBy` defaults to `actor`.** The subject axes are opt-in; a flag declared before they
76
+ existed must answer identically, which is why the default is not `org`.
77
+ - **Subject lookups are own-property only.** `subjects[kind]` goes through `Object.hasOwn` and a
78
+ `typeof` re-check, so a kind named `toString` or `constructor` is absent rather than resolving to
79
+ an inherited function, and a non-string id never reaches `bucketOf`. Same rule for the targeting
80
+ map, which is why the loop is `for…in` + `Object.hasOwn` and not `Object.entries`.
81
+ - **Every app-supplied string in a `fix:` goes through `JSON.stringify`, and a subject kind becomes
82
+ a computed key.** A `bank-integration` kind is not a valid identifier, so `{ bank-integration: … }`
83
+ would be a fix that does not parse — axiom 4 wants an instruction that runs. Pinned by
84
+ `errors.test.ts` running the generated snippet through `new Function`.
85
+ - **An unknown key throws.** Answering `false` is a branch that never runs and never says so.
86
+ - `default: true` beside a `rollout` is refused: the two answer the same actors and disagree.
87
+
88
+ ## Open
89
+
90
+ - Nothing calls `flagsReport()` yet. It is the projection an `x flags [--json]` command and an MCP
91
+ `flags.list` tool should read; neither exists.
92
+
93
+ ## Commands
94
+
95
+ ```
96
+ bun test packages/flags
97
+ bun run --filter @ultimat3/flags typecheck
98
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @ultimat3/flags
2
+
3
+ Feature flags: permanent switches, and temporary ones that cannot be forgotten.
4
+
5
+ Feature flags are normally a bad trade — N flags are 2^N states nothing tested. This package takes
6
+ the trade only on terms that bound the exponent: every flag declares which of two kinds it is, and
7
+ a `temporary` flag carries an expiry it cannot be declared without. Past that date, every
8
+ evaluation reports the flag to the app's error monitor and the projection lists it as expired. The
9
+ permanent set is a product surface; the temporary set is forced to shrink.
10
+
11
+ ## The two kinds
12
+
13
+ | Kind | Meaning | Lifecycle rule |
14
+ |---|---|---|
15
+ | `permanent` | a real product or ops switch — a plan capability, a kill switch, a rollout that became the product | none; it legitimately lives forever |
16
+ | `temporary` | scaffolding around an in-progress change | `expiresAt` and `owner` are **required**; past the expiry every evaluation reports `X_FLAG_EXPIRED` |
17
+
18
+ Omitting `expiresAt` on a `temporary` flag is a **type** error, not a lint rule —
19
+ `FlagExpiryIsMandatory` in `src/flag.ts` is a compile-time assertion that fails `tsc` if the union
20
+ is ever loosened. `toFlag()` re-checks it at runtime, because a store snapshot and a plain-JS
21
+ caller have no types to be checked by.
22
+
23
+ ## Declaring
24
+
25
+ ```ts
26
+ import { defineFlag } from '@ultimat3/flags';
27
+
28
+ export const dunning = defineFlag({
29
+ kind: 'permanent',
30
+ key: 'billing.dunning-emails',
31
+ description: 'ops kill switch for dunning email delivery',
32
+ targeting: { default: true },
33
+ });
34
+
35
+ export const newTaxEngine = defineFlag({
36
+ kind: 'temporary',
37
+ key: 'checkout.new-tax-engine',
38
+ description: 'routes checkout through the rewritten tax engine',
39
+ owner: 'payments',
40
+ expiresAt: '2026-12-01',
41
+ targeting: { default: false, rollout: 10, roles: ['staff'] },
42
+ });
43
+ ```
44
+
45
+ `defineFlag()` is a `define*` helper, like `defineRoles` and `defineCatalogs`. It is **not** a ninth
46
+ primitive: a flag has no handler, no input schema and no surface of its own, so there is nothing for
47
+ the registrar to project. The eight primitives stay eight.
48
+
49
+ ## Reading
50
+
51
+ ```ts
52
+ import { isEnabled } from '@ultimat3/flags';
53
+
54
+ if (isEnabled('checkout.new-tax-engine', actor)) {
55
+ // …
56
+ }
57
+
58
+ // with the app's own records in play
59
+ if (isEnabled('scraper.persist-profile', actor, { bank: bank.id })) {
60
+ // …
61
+ }
62
+ ```
63
+
64
+ Synchronous, for the same reason `can()` is: this runs inside policy predicates and render passes,
65
+ and an `await` there turns every guarded branch into an async boundary. An undeclared key throws
66
+ `X_FLAG_UNKNOWN` rather than answering `false` — a typo that reads as "off" is a branch that
67
+ silently never runs in production.
68
+
69
+ ## Targeting
70
+
71
+ | Field | Meaning |
72
+ |---|---|
73
+ | `default` | the answer when no allow list and no rollout claims this actor |
74
+ | `actors` | actor ids that are always on, ahead of any rollout |
75
+ | `roles` | actor roles that are always on, ahead of any rollout |
76
+ | `orgs` | org ids that are always on — shorthand for the `org` subject kind, read from `actor.orgId` |
77
+ | `subjects` | allow lists for the app's own record kinds: `{ bank: ['bank_integration:bbva'] }` |
78
+ | `rollout` | whole percentage 0-100, stable per bucketing subject |
79
+ | `bucketBy` | which subject kind the rollout divides: `'actor'` (default), `'org'`, or any kind the call site carries |
80
+
81
+ Order is allow lists → rollout → default. `actors`, `roles`, `orgs` and `subjects` are one rank —
82
+ any hit is `true`. An operator who names a subject is not overruled by a hash. A rollout buckets
83
+ `fnv1a(key + ':' + subjectId) % 100`, never `Math.random()`: one subject gets one answer on every
84
+ call, in every process, without the nodes talking to each other.
85
+
86
+ ## Subjects — what a flag decides about
87
+
88
+ A flag decides about an **identified record**: a user, a tenant, a bank integration, a device. The
89
+ actor is one subject kind among several, not a privileged one.
90
+
91
+ | Kind | Where its id comes from |
92
+ |---|---|
93
+ | `actor` | `actor.id` — spelled `actors` in targeting |
94
+ | `org` | `actor.orgId` — spelled `orgs` in targeting |
95
+ | anything else | the `subjects` argument at the call site |
96
+
97
+ ```ts
98
+ // whole tenants, named — the 90% case, which is why it has a shorthand
99
+ targeting: { default: false, orgs: ['org_acme'] }
100
+
101
+ // 10% of tenants, each one whole
102
+ targeting: { default: false, rollout: 10, bucketBy: 'org' }
103
+
104
+ // the app's own record kind
105
+ targeting: { default: false, subjects: { bank: ['bank_integration:bbva'] } }
106
+ isEnabled('scraper.persist-profile', actor, { bank: 'bank_integration:bbva' });
107
+
108
+ // 10% of banks, each bank whole
109
+ targeting: { default: false, rollout: 10, bucketBy: 'bank' }
110
+ ```
111
+
112
+ `actor` and `org` are resolved from the `Actor` and **never** from the call-site map — one source
113
+ per kind, so there is no precedence rule to remember and no second place a tenant can come from.
114
+ Every other kind is the app's vocabulary; the kind space is open, exactly like the flag key space.
115
+
116
+ Bucketing by a record is what keeps it **whole**. An actor-bucketed rollout cuts through a tenant:
117
+ 3 of an org's 30 members on the new export path and 27 on the old, sharing documents, filing a bug
118
+ nobody can reproduce. `bucketBy` puts the whole subject on one side. `'actor'` stays the default,
119
+ so every flag declared before this axis answers exactly as it did.
120
+
121
+ `roles` is deliberately **not** a subject kind: a role is a predicate over the actor, not an
122
+ identified record, so it has no id to hash and cannot bucket a rollout.
123
+
124
+ ### A missing subject is an error, not a fallback
125
+
126
+ If targeting decides by a kind the evaluation context does not carry, `isEnabled()` throws
127
+ `X_FLAG_SUBJECT_REQUIRED`. It never falls back to the actor axis or to `default`: an answer about a
128
+ record computed from whoever happened to be calling is the exact failure this axis removes, and it
129
+ looks like it worked. Every declared kind is resolved before any of them can answer, so the raise
130
+ does not depend on the order the keys sit in.
131
+
132
+ A `null` actor is the one exception and still gets `default` — it says there is no evaluation
133
+ context at all, every such call answers alike, and no single subject is split.
134
+
135
+ ## Overrides, out of band
136
+
137
+ ```ts
138
+ applyFlagSnapshot({ 'billing.dunning-emails': { default: false } });
139
+ ```
140
+
141
+ This is the half that keeps evaluation synchronous. A poller, a job or a realtime channel lands the
142
+ store's targeting; `isEnabled()` never loads anything. Keys this build does not declare are
143
+ returned in `unknown` rather than thrown — a control plane is routinely ahead of a deploy, and a
144
+ kill switch that refuses to land because the payload mentioned tomorrow's flag is one that does not
145
+ work on the day it is needed.
146
+
147
+ ## Reporting
148
+
149
+ There is no reporter seam in this package. An overdue flag goes through `@ultimat3/core`'s
150
+ `ErrorReporter` — the framework's one error-monitoring seam — as a `warning` from `source:
151
+ 'process'`, so an app wires its monitor in exactly one place:
152
+
153
+ ```ts
154
+ configureErrorReporting({ reporter: sentryErrorReporter({ dsn }) });
155
+ ```
156
+
157
+ What this package adds is the rate limit core has no opinion about: one report per flag per
158
+ `DEFAULT_REPORT_INTERVAL_MS` (1 hour), on the **monotonic** clock, so a flag read on every request
159
+ does not become the loudest thing in the monitor — which is how a report that fires per call ends
160
+ up muted and the debt invisible again. `configureFlags({ clock, reportEveryMs })` tunes it.
161
+
162
+ ## Projection
163
+
164
+ `flagsReport()` returns every declared flag with its kind, expiry, owner and whether it is expired,
165
+ sorted by key, plus the expired keys lifted out. It is the one shape `x flags --json`, an MCP tool
166
+ and the manifest should all read, so none of them recomputes "expired".
167
+
168
+ ## What it owns
169
+
170
+ | Module | Owns |
171
+ |---|---|
172
+ | `src/flag.ts` | the two kinds, the compile-time expiry rule, normalisation |
173
+ | `src/targeting.ts` | who a flag is on for; declaration-time validation |
174
+ | `src/subject.ts` | what a flag decides about — subject kinds and how each resolves to an id |
175
+ | `src/bucket.ts` | the stable `(flag, subject)` bucket — FNV-1a, never `Math.random()` |
176
+ | `src/registry.ts` | `defineFlag`, key → flag, `applyFlagSnapshot` |
177
+ | `src/runtime.ts` | the clock and the per-flag report rate limit over core's `reportError` |
178
+ | `src/evaluate.ts` | `isEnabled()` — the one way to ask |
179
+ | `src/projection.ts` | `flagsReport()` for the CLI, MCP and the manifest |
180
+ | `src/errors.ts` | this package's X_* codes |
181
+
182
+ ## Boundary
183
+
184
+ Tier 1. May import tiers 0-0 only — enforced by `bun run scripts/boundaries.ts`.
185
+
186
+ ## Errors
187
+
188
+ `X_FLAG_DUPLICATE` · `X_FLAG_EXPIRED` · `X_FLAG_EXPIRY_INVALID` · `X_FLAG_SUBJECT_REQUIRED` ·
189
+ `X_FLAG_TARGETING_INVALID` · `X_FLAG_UNKNOWN`
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ultimat3/flags",
3
+ "version": "2.0.0",
4
+ "description": "Feature flags: permanent switches, and temporary ones that cannot be forgotten",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/flags"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "CLAUDE.md",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "bun": ">=1.3.0"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit -p tsconfig.json",
31
+ "test": "bun test"
32
+ },
33
+ "dependencies": {
34
+ "@ultimat3/core": "2.0.0"
35
+ }
36
+ }
package/src/bucket.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Single responsibility: the stable bucket a (flag, subject) pair falls into. The subject is an
2
+ // actor id or an org id — same hash either way, so a tenant is whole. Never `Math.random()`:
3
+ // a rollout that re-rolls per call shows one user the new experience on one request and the old
4
+ // one on the next, which is a worse product than no rollout at all — and untestable besides.
5
+
6
+ /** A rollout is declared as a percentage, so the bucket space is 100. */
7
+ export const BUCKETS = 100;
8
+
9
+ const FNV_OFFSET_BASIS = 0x811c_9dc5;
10
+ const FNV_PRIME = 0x0100_0193;
11
+
12
+ /**
13
+ * 32-bit FNV-1a. Chosen over a cryptographic digest because bucketing is not a security decision
14
+ * and this one is synchronous, dependency-free and identical in every process — which is the
15
+ * property that matters: two nodes must agree about one actor without talking to each other.
16
+ */
17
+ export function fnv1a(text: string): number {
18
+ let hash = FNV_OFFSET_BASIS;
19
+ for (let index = 0; index < text.length; index += 1) {
20
+ hash ^= text.charCodeAt(index);
21
+ hash = Math.imul(hash, FNV_PRIME);
22
+ }
23
+ return hash >>> 0;
24
+ }
25
+
26
+ /**
27
+ * The flag key is hashed WITH the subject id, not the subject id alone: hashing the subject by
28
+ * itself would put the same unlucky cohort in the first 10% of every 10% rollout the app ever
29
+ * runs, so one group of users — or one group of tenants — would meet every half-finished feature.
30
+ *
31
+ * `subjectId` is whatever axis the targeting buckets by: an actor id, or an org id when
32
+ * `bucketBy: 'org'` keeps a tenant on one side of the boundary. Pure, so two nodes agree about a
33
+ * subject without talking, and a restart does not re-roll anyone.
34
+ */
35
+ export const bucketOf = (key: string, subjectId: string): number =>
36
+ fnv1a(`${key}:${subjectId}`) % BUCKETS;
package/src/errors.ts ADDED
@@ -0,0 +1,145 @@
1
+ // The X_* codes owned by @ultimat3/flags. Each names the exact edit that resolves it.
2
+ // `X_FLAG_EXPIRED` is the odd one out on purpose: it is REPORTED to the error monitor, never
3
+ // thrown, because an overdue flag must become impossible to forget without taking production
4
+ // down with it — the branch keeps working, the debt stops being invisible.
5
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+
7
+ export const FLAGS_ERROR_CODES = [
8
+ 'X_FLAG_DUPLICATE',
9
+ 'X_FLAG_EXPIRED',
10
+ 'X_FLAG_EXPIRY_INVALID',
11
+ 'X_FLAG_SUBJECT_REQUIRED',
12
+ 'X_FLAG_TARGETING_INVALID',
13
+ 'X_FLAG_UNKNOWN',
14
+ ] as const;
15
+
16
+ export type FlagsErrorCode = (typeof FLAGS_ERROR_CODES)[number];
17
+
18
+ export const FLAGS_ERROR_TITLES: Readonly<Record<FlagsErrorCode, string>> = {
19
+ X_FLAG_DUPLICATE: 'two flags were declared with the same key',
20
+ X_FLAG_EXPIRED: 'a temporary flag is past its expiry and is still being evaluated',
21
+ X_FLAG_EXPIRY_INVALID: 'a temporary flag has no usable expiry date',
22
+ X_FLAG_SUBJECT_REQUIRED: 'a flag decides by a subject the evaluation context does not carry',
23
+ X_FLAG_TARGETING_INVALID: 'flag targeting is out of range or malformed',
24
+ X_FLAG_UNKNOWN: 'no flag is declared under this key',
25
+ };
26
+
27
+ // Registered unconditionally at import, like every other package: a second package claiming one
28
+ // of these codes must surface as X_ERROR_CODE_DUPLICATE, never as a silent first-wins.
29
+ registerErrorCodes(
30
+ Object.fromEntries(Object.entries(FLAGS_ERROR_TITLES).map(([code, title]) => [code, { title }])),
31
+ );
32
+
33
+ export class FlagsError extends UltimateError {
34
+ override readonly name = 'FlagsError';
35
+
36
+ constructor(init: {
37
+ code: FlagsErrorCode;
38
+ cause: string;
39
+ fix: string;
40
+ meta?: Readonly<Record<string, unknown>> | undefined;
41
+ }) {
42
+ super({
43
+ code: init.code,
44
+ cause: init.cause,
45
+ fix: init.fix,
46
+ docs: `https://ultimate.dev/errors/${init.code}`,
47
+ meta: init.meta,
48
+ });
49
+ }
50
+ }
51
+
52
+ export const flagDuplicate = (key: string): FlagsError =>
53
+ new FlagsError({
54
+ code: 'X_FLAG_DUPLICATE',
55
+ cause: `"${key}" is already declared, so one of the two declarations would decide nothing`,
56
+ fix: `rename one of the two defineFlag({ key: '${key}' }) declarations`,
57
+ meta: { key },
58
+ });
59
+
60
+ export const flagUnknown = (key: string, known: readonly string[]): FlagsError =>
61
+ new FlagsError({
62
+ code: 'X_FLAG_UNKNOWN',
63
+ cause: `"${key}" is not declared (${known.length} flags known), so it has no default, no owner and no expiry`,
64
+ fix: `declare it with defineFlag({ key: '${key}', ... }), or correct the key at the isEnabled() call site`,
65
+ meta: { key },
66
+ });
67
+
68
+ /** `fix` is a parameter because a bad `bucketBy` is not repaired by editing `rollout` — axiom 4. */
69
+ export const flagTargetingInvalid = (key: string, problem: string, fix?: string): FlagsError =>
70
+ new FlagsError({
71
+ code: 'X_FLAG_TARGETING_INVALID',
72
+ cause: `${key}: ${problem}`,
73
+ fix: fix ?? `set rollout to an integer 0-100 in defineFlag({ key: '${key}' })`,
74
+ meta: { key },
75
+ });
76
+
77
+ /** Which targeting field asked for the subject, so the fix names an edit rather than a mechanism. */
78
+ export type FlagSubjectVia = 'orgs' | 'subjects' | 'bucketBy';
79
+
80
+ /**
81
+ * Thrown, never softened into a fallback. Answering a subject-scoped flag from the actor axis — or
82
+ * from the declared default — is the exact failure the subject axis exists to remove: it looks
83
+ * like it worked, and the record finds out when half of it is on a different code path.
84
+ *
85
+ * The fix differs by kind because the edit does: a missing org is repaired where the actor is
86
+ * minted, a missing record is repaired at the call site that already holds it.
87
+ */
88
+ export const flagSubjectRequired = (init: {
89
+ key: string;
90
+ kind: string;
91
+ actorId: string;
92
+ via: FlagSubjectVia;
93
+ }): FlagsError =>
94
+ new FlagsError({
95
+ code: 'X_FLAG_SUBJECT_REQUIRED',
96
+ cause: `${init.key} decides by the "${init.kind}" subject (targeting.${init.via}) but the evaluation context carries no ${init.kind} id for actor "${init.actorId}", so there is nothing to decide about`,
97
+ // Every app-supplied string goes through JSON.stringify, and the kind becomes a COMPUTED key:
98
+ // a `bank-integration` kind — the realistic shape, next to treasury's `bank_integration:` ids
99
+ // — is not a valid identifier, so `{ bank-integration: … }` would hand the reader a fix that
100
+ // does not parse. Axiom 4: an instruction that cannot be run is not one.
101
+ fix:
102
+ init.kind === 'org'
103
+ ? `mint the actor with its tenant — userActor({ id: ${JSON.stringify(init.actorId)}, orgId: '<org>' }) — before the isEnabled(${JSON.stringify(init.key)}) call, or drop ${init.via} from defineFlag({ key: ${JSON.stringify(init.key)} })`
104
+ : `pass the record at the call site — isEnabled(${JSON.stringify(init.key)}, actor, { [${JSON.stringify(init.kind)}]: '<id>' }) — or drop the ${JSON.stringify(init.kind)} ${init.via} entry from defineFlag({ key: ${JSON.stringify(init.key)} })`,
105
+ meta: { key: init.key, kind: init.kind, actorId: init.actorId, via: init.via },
106
+ });
107
+
108
+ /**
109
+ * `JSON.stringify` throws on a bigint or a cycle, and RUNS a `toJSON` the value carries — so an
110
+ * app object can hijack an error constructor with its own throw, and the caller then catches
111
+ * something that is not `X_FLAG_EXPIRY_INVALID`. A cause only has to describe, so a value that
112
+ * defeats rendering degrades to its type rather than destroying the refusal.
113
+ */
114
+ const renderGiven = (given: unknown): string => {
115
+ if (given === undefined) return 'undefined';
116
+ if (typeof given === 'bigint') return `${given}n`;
117
+ if (typeof given === 'symbol') return String(given);
118
+ try {
119
+ return JSON.stringify(given) ?? String(given);
120
+ } catch {
121
+ return `a ${typeof given} that cannot be rendered`;
122
+ }
123
+ };
124
+
125
+ export const flagExpiryInvalid = (key: string, given: unknown): FlagsError =>
126
+ new FlagsError({
127
+ code: 'X_FLAG_EXPIRY_INVALID',
128
+ cause: `${key} is a temporary flag whose expiresAt is ${renderGiven(given)}, which is not a date`,
129
+ fix: `set expiresAt to an ISO-8601 date such as '2026-12-01' in defineFlag({ key: '${key}' })`,
130
+ meta: { key },
131
+ });
132
+
133
+ /** Built, handed to the reporter, and never thrown. See this file's header for why. */
134
+ export const flagExpired = (init: {
135
+ key: string;
136
+ owner: string;
137
+ expiresAt: string;
138
+ overdueDays: number;
139
+ }): FlagsError =>
140
+ new FlagsError({
141
+ code: 'X_FLAG_EXPIRED',
142
+ cause: `${init.key} expired ${init.overdueDays} day(s) ago (${init.expiresAt}, owner ${init.owner}) and is still being evaluated`,
143
+ fix: `delete the ${init.key} branch and its defineFlag() declaration, or move it to kind: 'permanent' if it is a real product switch`,
144
+ meta: { key: init.key, owner: init.owner, expiresAt: init.expiresAt },
145
+ });
@@ -0,0 +1,55 @@
1
+ // Single responsibility: `isEnabled()` — the one way to ask a flag a question. Synchronous, for
2
+ // the same reason `can()` is: this runs inside policy predicates and render passes, and an `await`
3
+ // there turns every guarded branch into an async boundary.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import { flagExpired } from './errors';
7
+ import type { Flag } from './flag';
8
+ import { flagFor } from './registry';
9
+ import { flagsClock, reportOnce } from './runtime';
10
+ import type { FlagSubjects } from './subject';
11
+ import { evaluateTargeting } from './targeting';
12
+
13
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
14
+
15
+ /**
16
+ * The lifecycle rule, enforced on the one path nobody can avoid. An overdue temporary flag reports
17
+ * itself where it is USED, not where it is declared — a declaration is read once at boot and can
18
+ * sit in a module nothing evaluates, while an evaluation proves the branch is still live.
19
+ *
20
+ * It reports and returns; it never throws. An expiry is a debt, not an outage, and a framework
21
+ * that took production down on a date nobody remembered setting would teach everyone to declare
22
+ * every flag `permanent`, which is the failure this design exists to prevent.
23
+ */
24
+ function reportIfOverdue(flag: Flag): void {
25
+ if (flag.expiresAtMs === null || flag.owner === null || flag.expiresAt === null) return;
26
+ const nowMs = flagsClock().now().getTime();
27
+ if (nowMs < flag.expiresAtMs) return;
28
+ const expiresAt = flag.expiresAt;
29
+ const owner = flag.owner;
30
+ const overdueDays = Math.floor((nowMs - flag.expiresAtMs) / MS_PER_DAY);
31
+ reportOnce(flag.key, () => flagExpired({ key: flag.key, owner, expiresAt, overdueDays }));
32
+ }
33
+
34
+ /**
35
+ * Is `key` on for `actor`? An undeclared key throws `X_FLAG_UNKNOWN` rather than answering `false`
36
+ * — see `flagFor`.
37
+ *
38
+ * `actor` is passed rather than read from the ambient context on purpose: a policy predicate,
39
+ * a job and a render pass each already hold the actor they are deciding about, and reading an
40
+ * ambient one would let a job evaluate a flag for whoever enqueued it.
41
+ *
42
+ * `subjects` carries the app's own records in play — `{ bank: 'bank_integration:bbva' }` — for a
43
+ * flag targeted at something other than the caller. Same reasoning as `actor`: the call site
44
+ * already holds the record it is deciding about. A flag that needs a kind the call site did not
45
+ * pass raises `X_FLAG_SUBJECT_REQUIRED` rather than quietly deciding about somebody else.
46
+ */
47
+ export function isEnabled(
48
+ key: string,
49
+ actor: Actor | null,
50
+ subjects?: FlagSubjects | undefined,
51
+ ): boolean {
52
+ const flag = flagFor(key);
53
+ reportIfOverdue(flag);
54
+ return evaluateTargeting(flag.key, flag.targeting, actor, subjects);
55
+ }
package/src/flag.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Single responsibility: what a flag IS — the two kinds, and the normalisation from a declaration
2
+ // to the frozen record everything else reads.
3
+ //
4
+ // The two kinds are the whole answer to "N flags = 2^N untested states". A `permanent` flag is a
5
+ // product or ops switch that legitimately outlives the change that introduced it. A `temporary`
6
+ // flag is scaffolding, so it carries an expiry and an owner, and past that date every evaluation
7
+ // reports it (see `evaluate.ts`). The state space stays bounded because the temporary half is
8
+ // forced to shrink.
9
+
10
+ import { flagExpiryInvalid } from './errors';
11
+ import type { FlagTargeting } from './targeting';
12
+ import { assertTargeting } from './targeting';
13
+
14
+ export const FLAG_KINDS = ['permanent', 'temporary'] as const;
15
+
16
+ export type FlagKind = (typeof FLAG_KINDS)[number];
17
+
18
+ export interface PermanentFlagDef {
19
+ readonly kind: 'permanent';
20
+ readonly key: string;
21
+ /** What the switch means. The projection prints it; it is all a reader has to go on. */
22
+ readonly description: string;
23
+ readonly targeting: FlagTargeting;
24
+ }
25
+
26
+ export interface TemporaryFlagDef {
27
+ readonly kind: 'temporary';
28
+ readonly key: string;
29
+ readonly description: string;
30
+ readonly targeting: FlagTargeting;
31
+ /** ISO-8601 date the scaffolding is due to come down. Required — see `FlagExpiryIsMandatory`. */
32
+ readonly expiresAt: string;
33
+ /** Who takes it down. A temporary flag with no owner is one nobody removes. */
34
+ readonly owner: string;
35
+ }
36
+
37
+ export type FlagDef = PermanentFlagDef | TemporaryFlagDef;
38
+
39
+ type Assert<T extends true> = T;
40
+
41
+ /**
42
+ * Compile-time proof that `kind: 'temporary'` cannot be declared without an expiry: a temporary
43
+ * shape missing `expiresAt` must NOT be assignable to `FlagDef`. Loosen the union and the
44
+ * conditional yields `false`, `Assert<false>` fails its constraint, and `tsc -b packages/flags`
45
+ * goes red here. Axiom 3 — the rule is a build error, not a comment in a style guide.
46
+ */
47
+ export type FlagExpiryIsMandatory = Assert<
48
+ {
49
+ readonly kind: 'temporary';
50
+ readonly key: string;
51
+ readonly description: string;
52
+ readonly targeting: FlagTargeting;
53
+ readonly owner: string;
54
+ } extends FlagDef
55
+ ? false
56
+ : true
57
+ >;
58
+
59
+ /** The normalised record. Both kinds share one shape so nothing downstream branches on kind. */
60
+ export interface Flag {
61
+ readonly key: string;
62
+ readonly kind: FlagKind;
63
+ readonly description: string;
64
+ readonly targeting: FlagTargeting;
65
+ /** ISO-8601 as declared, `null` for a permanent flag. */
66
+ readonly expiresAt: string | null;
67
+ /** Epoch ms of `expiresAt`, precomputed so evaluation never parses a date. */
68
+ readonly expiresAtMs: number | null;
69
+ readonly owner: string | null;
70
+ }
71
+
72
+ /**
73
+ * Declaration → `Flag`, with both invariants enforced here rather than at the first evaluation.
74
+ * The expiry is re-checked at runtime even though the type already demands it: a snapshot pushed
75
+ * from a store, or a plain-JS caller, has no types to be checked by.
76
+ */
77
+ export function toFlag(def: FlagDef): Flag {
78
+ assertTargeting(def.key, def.targeting);
79
+ if (def.kind === 'permanent') {
80
+ return Object.freeze({
81
+ key: def.key,
82
+ kind: def.kind,
83
+ description: def.description,
84
+ targeting: def.targeting,
85
+ expiresAt: null,
86
+ expiresAtMs: null,
87
+ owner: null,
88
+ });
89
+ }
90
+ // A bare ISO date parses as UTC midnight, so the deadline is the same instant on every node —
91
+ // no ambient zone, which is the framework's rule about dates applied to a deadline.
92
+ const expiresAtMs = Date.parse(def.expiresAt);
93
+ if (Number.isNaN(expiresAtMs)) throw flagExpiryInvalid(def.key, def.expiresAt);
94
+ return Object.freeze({
95
+ key: def.key,
96
+ kind: def.kind,
97
+ description: def.description,
98
+ targeting: def.targeting,
99
+ expiresAt: def.expiresAt,
100
+ expiresAtMs,
101
+ owner: def.owner,
102
+ });
103
+ }
104
+
105
+ /** Re-target a declared flag without re-declaring it — how `applyFlagSnapshot` lands an override. */
106
+ export function withTargeting(flag: Flag, targeting: FlagTargeting): Flag {
107
+ assertTargeting(flag.key, targeting);
108
+ return Object.freeze({ ...flag, targeting });
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Public API of @ultimat3/flags. Explicit re-exports only.
2
+
3
+ export { BUCKETS, bucketOf, fnv1a } from './bucket';
4
+ export type { FlagSubjectVia, FlagsErrorCode } from './errors';
5
+ export {
6
+ FLAGS_ERROR_CODES,
7
+ FLAGS_ERROR_TITLES,
8
+ FlagsError,
9
+ flagDuplicate,
10
+ flagExpired,
11
+ flagExpiryInvalid,
12
+ flagSubjectRequired,
13
+ flagTargetingInvalid,
14
+ flagUnknown,
15
+ } from './errors';
16
+ export { isEnabled } from './evaluate';
17
+ export type {
18
+ Flag,
19
+ FlagDef,
20
+ FlagExpiryIsMandatory,
21
+ FlagKind,
22
+ PermanentFlagDef,
23
+ TemporaryFlagDef,
24
+ } from './flag';
25
+ export { FLAG_KINDS } from './flag';
26
+ export type { FlagFacts, FlagsReport } from './projection';
27
+ export { flagsReport } from './projection';
28
+ export type { SnapshotResult } from './registry';
29
+ export { allFlags, applyFlagSnapshot, defineFlag, hasFlag, resetFlags } from './registry';
30
+ export type { FlagsRuntimeOptions } from './runtime';
31
+ // The reporter seam is `@ultimat3/core`'s `ErrorReporter`, wired once with
32
+ // `configureErrorReporting()`. This package deliberately re-exports none of it.
33
+ export { configureFlags, DEFAULT_REPORT_INTERVAL_MS, resetFlagReporting } from './runtime';
34
+ export type { BuiltInSubjectKind, FlagSubjects } from './subject';
35
+ export { BUILT_IN_SUBJECT_KINDS } from './subject';
36
+ export type { FlagTargeting } from './targeting';
@@ -0,0 +1,43 @@
1
+ // Single responsibility: the declared flags as data — one JSON-safe shape for `x flags --json`,
2
+ // the MCP tool and the manifest, so all three answer "what is expired?" identically instead of
3
+ // each computing it. Same rule as `listErrorCodes()`: the projection lives with the registry.
4
+
5
+ import type { FlagKind } from './flag';
6
+ import { allFlags } from './registry';
7
+ import { flagsClock } from './runtime';
8
+ import type { FlagTargeting } from './targeting';
9
+
10
+ export interface FlagFacts {
11
+ readonly key: string;
12
+ readonly kind: FlagKind;
13
+ readonly description: string;
14
+ readonly targeting: FlagTargeting;
15
+ /** ISO-8601, `null` for a permanent flag. */
16
+ readonly expiresAt: string | null;
17
+ readonly owner: string | null;
18
+ /** Past its expiry as of this call. Always `false` for a permanent flag — it has no expiry. */
19
+ readonly expired: boolean;
20
+ }
21
+
22
+ export interface FlagsReport {
23
+ readonly flags: readonly FlagFacts[];
24
+ /** The expired keys, lifted out: the one number a gate or a dashboard wants is this length. */
25
+ readonly expired: readonly string[];
26
+ }
27
+
28
+ /** Sorted by key, so a diff of two reports is a diff of what changed. */
29
+ export function flagsReport(): FlagsReport {
30
+ const nowMs = flagsClock().now().getTime();
31
+ const flags = allFlags().map(
32
+ (flag): FlagFacts => ({
33
+ key: flag.key,
34
+ kind: flag.kind,
35
+ description: flag.description,
36
+ targeting: flag.targeting,
37
+ expiresAt: flag.expiresAt,
38
+ owner: flag.owner,
39
+ expired: flag.expiresAtMs !== null && nowMs >= flag.expiresAtMs,
40
+ }),
41
+ );
42
+ return { flags, expired: flags.filter((flag) => flag.expired).map((flag) => flag.key) };
43
+ }
@@ -0,0 +1,78 @@
1
+ // Single responsibility: the declared set of flags for this process — the one place a key resolves
2
+ // to a `Flag`. Process-global and filled at import time, exactly like the error-code registry, so
3
+ // evaluation is a map lookup rather than a load.
4
+
5
+ import { flagDuplicate, flagUnknown } from './errors';
6
+ import type { Flag, FlagDef } from './flag';
7
+ import { toFlag, withTargeting } from './flag';
8
+ import type { FlagTargeting } from './targeting';
9
+
10
+ const flags = new Map<string, Flag>();
11
+
12
+ /**
13
+ * The one call an app makes to declare a flag. A `define*` helper, deliberately NOT a ninth
14
+ * primitive: a flag is a declaration plus a pure predicate, it has no handler, no schema and no
15
+ * surface of its own — the same shape as `defineRoles` and `defineCatalogs`.
16
+ */
17
+ export function defineFlag(def: FlagDef): Flag {
18
+ const flag = toFlag(def);
19
+ if (flags.has(flag.key)) throw flagDuplicate(flag.key);
20
+ flags.set(flag.key, flag);
21
+ return flag;
22
+ }
23
+
24
+ /**
25
+ * Throws rather than answering `false` for an unknown key. A typo that reads as "off" is the
26
+ * failure mode a flag system exists to have: the branch silently never runs, in production, and
27
+ * nothing anywhere says so.
28
+ */
29
+ export function flagFor(key: string): Flag {
30
+ const flag = flags.get(key);
31
+ if (flag === undefined) throw flagUnknown(key, [...flags.keys()]);
32
+ return flag;
33
+ }
34
+
35
+ export const hasFlag = (key: string): boolean => flags.has(key);
36
+
37
+ /** Sorted by key — the projection and the manifest both need a stable order. */
38
+ export const allFlags = (): readonly Flag[] =>
39
+ [...flags.values()].sort((a, b) => a.key.localeCompare(b.key));
40
+
41
+ export interface SnapshotResult {
42
+ /** Keys whose targeting was replaced. */
43
+ readonly applied: readonly string[];
44
+ /** Keys the store carried that this build does not declare. */
45
+ readonly unknown: readonly string[];
46
+ }
47
+
48
+ /**
49
+ * Land a store's targeting on the declared flags. This is the out-of-band half that keeps
50
+ * `isEnabled()` synchronous: a poller, a job or a realtime channel calls this, and evaluation
51
+ * never awaits anything.
52
+ *
53
+ * Unknown keys are reported, not thrown: a control plane is routinely ahead of a deploy, and a
54
+ * kill switch that refuses to land because the payload also mentioned tomorrow's flag is a kill
55
+ * switch that does not work on the day it is needed. A bad *targeting* still throws — landing a
56
+ * `rollout: 0.5` would silently switch a feature off for everyone.
57
+ */
58
+ export function applyFlagSnapshot(
59
+ snapshot: Readonly<Record<string, FlagTargeting>>,
60
+ ): SnapshotResult {
61
+ const applied: string[] = [];
62
+ const unknown: string[] = [];
63
+ for (const [key, targeting] of Object.entries(snapshot)) {
64
+ const flag = flags.get(key);
65
+ if (flag === undefined) {
66
+ unknown.push(key);
67
+ continue;
68
+ }
69
+ flags.set(key, withTargeting(flag, targeting));
70
+ applied.push(key);
71
+ }
72
+ return { applied, unknown };
73
+ }
74
+
75
+ /** Test-only. Production declares once at import and never withdraws. */
76
+ export function resetFlags(): void {
77
+ flags.clear();
78
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Single responsibility: the two ambient things an evaluation needs that the declaration cannot
2
+ // carry — what "now" is, and how often the same overdue flag may report.
3
+ //
4
+ // The reporter itself is NOT here. `@ultimat3/core`'s `ErrorReporter` is the framework's one
5
+ // error-monitoring seam; a second one in this package would be a second place an app has to wire
6
+ // its monitor, and two places to look when a report does not arrive. What this package adds is the
7
+ // rate limit, which core deliberately has no opinion about: `reportError` is called once per
8
+ // caught failure, while a flag is evaluated on every request.
9
+
10
+ import type { Clock, UltimateError } from '@ultimat3/core';
11
+ import { reportError, systemClock } from '@ultimat3/core';
12
+
13
+ /**
14
+ * One hour. Small enough that an overdue flag shows up the same day, large enough that a flag read
15
+ * on every request does not become the loudest thing in the monitor — which is how a report that
16
+ * fires per call ends up muted, and the debt invisible again.
17
+ */
18
+ export const DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1000;
19
+
20
+ export interface FlagsRuntimeOptions {
21
+ readonly clock?: Clock | undefined;
22
+ /** Minimum gap between two reports of the SAME flag key. */
23
+ readonly reportEveryMs?: number | undefined;
24
+ }
25
+
26
+ let clock: Clock = systemClock;
27
+ let reportEveryMs = DEFAULT_REPORT_INTERVAL_MS;
28
+ const lastReportedAt = new Map<string, number>();
29
+
30
+ export function configureFlags(options: FlagsRuntimeOptions): void {
31
+ if (options.clock !== undefined) clock = options.clock;
32
+ if (options.reportEveryMs !== undefined) reportEveryMs = options.reportEveryMs;
33
+ }
34
+
35
+ export const flagsClock = (): Clock => clock;
36
+
37
+ /**
38
+ * Report `build()`'s error through core's seam at most once per `reportEveryMs` for this key, and
39
+ * say whether it did. The rate limit is keyed on the MONOTONIC clock: a wall-clock jump — an NTP
40
+ * correction, a container resuming — must not reopen the window and flood the monitor.
41
+ *
42
+ * The error is built lazily, so a rate-limited call costs a map lookup and a subtraction. That
43
+ * matters: this sits on the same path as `can()`.
44
+ *
45
+ * `severity: 'warning'` because the framework recovered — the flag still answered. `source:
46
+ * 'process'` because an overdue flag is a fact about this deploy, not about whichever request
47
+ * happened to evaluate it first.
48
+ */
49
+ export function reportOnce(key: string, build: () => UltimateError): boolean {
50
+ const now = clock.monotonic();
51
+ const previous = lastReportedAt.get(key);
52
+ if (previous !== undefined && now - previous < reportEveryMs) return false;
53
+ lastReportedAt.set(key, now);
54
+ // `reportError` never throws and logs a failing transport itself, so an evaluation cannot fail
55
+ // because the monitor is down.
56
+ reportError(build(), { source: 'process', severity: 'warning', scope: { operation: key } });
57
+ return true;
58
+ }
59
+
60
+ /** Test-only, and the counterpart to every other reset in the framework. */
61
+ export function resetFlagReporting(): void {
62
+ clock = systemClock;
63
+ reportEveryMs = DEFAULT_REPORT_INTERVAL_MS;
64
+ lastReportedAt.clear();
65
+ }
package/src/subject.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Single responsibility: what a flag decides ABOUT, and how a subject kind resolves to the one id
2
+ // that gets matched or hashed. A subject is any identified record — a tenant, a bank integration,
3
+ // a device — which is the generalisation of the actor axis, not a second mechanism beside it.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import type { FlagSubjectVia } from './errors';
7
+ import { flagSubjectRequired } from './errors';
8
+
9
+ /**
10
+ * The records in play at ONE evaluation, keyed by kind: `{ bank: 'bank_integration:bbva' }`.
11
+ *
12
+ * A map rather than a list of `{ kind, id }` because a single evaluation has a single bank, a
13
+ * single project, a single device: the shape makes a duplicate kind unrepresentable instead of a
14
+ * rule nothing enforces. The id is the app's — an opaque string, never parsed here.
15
+ */
16
+ export type FlagSubjects = Readonly<Record<string, string>>;
17
+
18
+ /**
19
+ * The two kinds every app has, and the two an `Actor` already carries. Everything else is the
20
+ * app's own vocabulary and arrives in `FlagSubjects`.
21
+ *
22
+ * A built-in kind is resolved from the actor and NEVER from the map. That is what keeps this a
23
+ * single mechanism rather than two: one source per kind, so there is no precedence rule to
24
+ * remember and no second place a tenant can come from. Passing `org` at a call site is dead data,
25
+ * and it cannot produce a wrong answer — without `actor.orgId` the evaluation raises, and the fix
26
+ * line says to mint the actor with its org.
27
+ */
28
+ export const BUILT_IN_SUBJECT_KINDS = ['actor', 'org'] as const;
29
+
30
+ export type BuiltInSubjectKind = (typeof BUILT_IN_SUBJECT_KINDS)[number];
31
+
32
+ export const isBuiltInSubjectKind = (kind: string): kind is BuiltInSubjectKind =>
33
+ (BUILT_IN_SUBJECT_KINDS as readonly string[]).includes(kind);
34
+
35
+ /**
36
+ * The id for `kind`, or a loud failure — never a fallback to the actor and never the declared
37
+ * default. An answer about a record computed from whoever happened to be calling is the bug this
38
+ * axis removes: it looks like it worked. An empty string is absent, not an id; it would otherwise
39
+ * match an allow list entry or hash to a real bucket.
40
+ *
41
+ * The kind space is open, exactly like the flag key space. A typo'd kind raises here on the first
42
+ * evaluation, which is the same loud failure an undeclared key already gets from `X_FLAG_UNKNOWN`
43
+ * — a registry of kinds would be a second declaration surface buying a check this already makes.
44
+ */
45
+ export function subjectIdOf(init: {
46
+ readonly key: string;
47
+ readonly kind: string;
48
+ readonly actor: Actor;
49
+ readonly subjects: FlagSubjects | undefined;
50
+ readonly via: FlagSubjectVia;
51
+ }): string {
52
+ const { key, kind, actor, subjects, via } = init;
53
+ const id = resolve(kind, actor, subjects);
54
+ if (id === undefined || id === '') {
55
+ throw flagSubjectRequired({ key, kind, actorId: actor.id, via });
56
+ }
57
+ return id;
58
+ }
59
+
60
+ /**
61
+ * Own properties only. `subjects['toString']` would otherwise walk the prototype chain and hand
62
+ * back a function where an id belongs — a weird downstream failure instead of the clean
63
+ * `X_FLAG_SUBJECT_REQUIRED` this package designed for exactly that case.
64
+ *
65
+ * The `typeof` re-check is for JS callers and store-shaped data: an id is a string or it is
66
+ * nothing, and a number reaching `bucketOf` would hash to a real bucket rather than raise.
67
+ */
68
+ function resolve(
69
+ kind: string,
70
+ actor: Actor,
71
+ subjects: FlagSubjects | undefined,
72
+ ): string | undefined {
73
+ if (kind === 'actor') return actor.id;
74
+ if (kind === 'org') return actor.orgId;
75
+ if (subjects === undefined || !Object.hasOwn(subjects, kind)) return undefined;
76
+ const id = subjects[kind];
77
+ return typeof id === 'string' ? id : undefined;
78
+ }
@@ -0,0 +1,163 @@
1
+ // Single responsibility: who a flag is on for. Pure and synchronous — this is the code that runs
2
+ // inside a policy predicate and a render pass, so it never awaits, never reads a store and never
3
+ // touches a clock. Loading the store is somebody else's job (`applyFlagSnapshot`).
4
+ import type { Actor } from '@ultimat3/core';
5
+ import { hasRole } from '@ultimat3/core';
6
+ import { BUCKETS, bucketOf } from './bucket';
7
+ import { flagTargetingInvalid } from './errors';
8
+ import type { FlagSubjects } from './subject';
9
+ import { BUILT_IN_SUBJECT_KINDS, isBuiltInSubjectKind, subjectIdOf } from './subject';
10
+
11
+ export interface FlagTargeting {
12
+ /** The answer when no allow list and no rollout claims this actor. `false` is off, `true` is on. */
13
+ readonly default: boolean;
14
+ /** Actor ids that are always on, ahead of any rollout — shorthand for the `actor` subject kind. */
15
+ readonly actors?: readonly string[] | undefined;
16
+ /** Actor roles that are always on. NOT a subject: a role is a predicate, not an identified record. */
17
+ readonly roles?: readonly string[] | undefined;
18
+ /** Org ids that are always on — shorthand for the `org` subject kind, read from `actor.orgId`. */
19
+ readonly orgs?: readonly string[] | undefined;
20
+ /**
21
+ * Allow lists for the app's own record kinds: `{ bank: ['bank_integration:bbva'] }`. One rank
22
+ * with `actors`, `roles` and `orgs` — any hit is `true`, which is the same OR Flipper applies
23
+ * across the actors handed to one `enabled?` call.
24
+ *
25
+ * Built-in kinds are refused here: `actors` and `orgs` are their one spelling.
26
+ */
27
+ readonly subjects?: Readonly<Record<string, readonly string[]>> | undefined;
28
+ /** Percentage of the bucketing subject, 0-100 inclusive. Stable: one subject, one answer. */
29
+ readonly rollout?: number | undefined;
30
+ /**
31
+ * Which subject kind the `rollout` divides — `'actor'` (the default), `'org'`, or any kind the
32
+ * call site carries. The kind space is open on purpose, like the flag key space.
33
+ *
34
+ * Bucketing by a record is what keeps it whole: an org whose members share documents, or a bank
35
+ * integration whose connections share a scraper, must be entirely on the new path or entirely
36
+ * on the old one. `'actor'` stays the default, so every flag declared before this axis existed
37
+ * answers exactly as it did.
38
+ */
39
+ readonly bucketBy?: string | undefined;
40
+ }
41
+
42
+ /**
43
+ * Declaration-time validation, the way `can()` validates its permission rather than waiting for a
44
+ * request. Each rule closes a way for a flag to look wired and decide nothing:
45
+ *
46
+ * | Rejected | Why |
47
+ * |---|---|
48
+ * | `rollout: 0.5` | read as a fraction it means "half", read as a percentage it means "nobody" |
49
+ * | `default: true` with a `rollout` | the two answer the same actors and disagree; there is no reading of "on for everyone, and also on for 10%" |
50
+ * | `bucketBy` with no `rollout` | it names what a rollout divides, and there is no rollout to divide |
51
+ * | a blank `bucketBy` | names no kind at all |
52
+ * | `subjects.actor` / `subjects.org` | `actors` and `orgs` are the one spelling; two would disagree |
53
+ * | a `subjects` entry that is not a list of non-empty ids | reachable from a store snapshot, and it matches nothing while reading as an allow list |
54
+ *
55
+ * The `subjects` checks narrow by hand rather than through a schema: this package's other runtime
56
+ * re-checks (`Number.isInteger`, `Date.parse`) do the same, and a dependency here would buy one
57
+ * validation on a path that must stay allocation-free.
58
+ */
59
+ export function assertTargeting(key: string, targeting: FlagTargeting): void {
60
+ const { bucketBy, rollout } = targeting;
61
+ if (targeting.subjects !== undefined) assertSubjects(key, targeting.subjects);
62
+ if (bucketBy !== undefined) {
63
+ if (typeof bucketBy !== 'string' || bucketBy.trim() === '') {
64
+ throw flagTargetingInvalid(
65
+ key,
66
+ `bucketBy is ${JSON.stringify(bucketBy)}, which names no subject kind`,
67
+ `set bucketBy to a subject kind — '${BUILT_IN_SUBJECT_KINDS.join("', '")}', or one your call site passes — in defineFlag({ key: '${key}' })`,
68
+ );
69
+ }
70
+ if (rollout === undefined) {
71
+ throw flagTargetingInvalid(
72
+ key,
73
+ `bucketBy is '${bucketBy}' with no rollout, so it divides nothing`,
74
+ `add a rollout to defineFlag({ key: '${key}' }), or remove bucketBy`,
75
+ );
76
+ }
77
+ }
78
+ if (rollout === undefined) return;
79
+ if (!Number.isInteger(rollout)) {
80
+ const problem = `rollout is ${rollout}; a rollout is a whole percentage, not a fraction`;
81
+ throw flagTargetingInvalid(key, problem);
82
+ }
83
+ if (rollout < 0 || rollout > BUCKETS) {
84
+ throw flagTargetingInvalid(key, `rollout is ${rollout}, outside 0-${BUCKETS}`);
85
+ }
86
+ if (targeting.default) {
87
+ throw flagTargetingInvalid(key, `default is true and rollout is ${rollout}; the two disagree`);
88
+ }
89
+ }
90
+
91
+ function assertSubjects(key: string, subjects: Readonly<Record<string, readonly string[]>>): void {
92
+ const fix = `give each subjects entry a kind and a list of ids — { bank: ['bank_integration:bbva'] } — in defineFlag({ key: '${key}' })`;
93
+ for (const [kind, ids] of Object.entries<unknown>(subjects)) {
94
+ if (kind.trim() === '') throw flagTargetingInvalid(key, 'a subjects kind is blank', fix);
95
+ if (isBuiltInSubjectKind(kind)) {
96
+ throw flagTargetingInvalid(
97
+ key,
98
+ `subjects.${kind} restates a built-in kind`,
99
+ `use ${kind === 'org' ? 'orgs' : 'actors'} instead of subjects.${kind} in defineFlag({ key: '${key}' })`,
100
+ );
101
+ }
102
+ if (!Array.isArray(ids)) {
103
+ throw flagTargetingInvalid(key, `subjects.${kind} is not a list of ids`, fix);
104
+ }
105
+ for (const id of ids as readonly unknown[]) {
106
+ if (typeof id !== 'string' || id === '') {
107
+ throw flagTargetingInvalid(key, `subjects.${kind} holds an id that is not a string`, fix);
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Allow lists first, rollout second, declared default last. That order is the contract: a subject
115
+ * an operator explicitly named must not depend on where a hash happened to put it, which is the
116
+ * whole reason an allow list exists. `actors`, `roles`, `orgs` and `subjects` are ONE rank — any
117
+ * hit is `true`, so their order among themselves is not observable, which is the same OR Flipper
118
+ * applies across the actors passed to a single `enabled?` call.
119
+ *
120
+ * A `null` actor gets the default and nothing else. There is no id to hash, so a rollout could
121
+ * only be answered by re-rolling per call — the one thing this file refuses to do. An anonymous
122
+ * `Actor` DOES have an id (`anonymous`), so every anonymous visitor shares one bucket: one
123
+ * identity, one answer, which is what the anonymous actor already means everywhere else. `null`
124
+ * does NOT raise `X_FLAG_SUBJECT_REQUIRED`: it says there is no evaluation context at all, and
125
+ * every such call gets the same answer, so no single subject is split — which is the failure being
126
+ * designed out. A context that exists but lacks the kind is the ambiguous case, and that throws.
127
+ *
128
+ * Every kind the targeting declares resolves before any branch answers, so whether a call raises
129
+ * `X_FLAG_SUBJECT_REQUIRED` depends only on the flag and the context — never on which allow list
130
+ * happened to match first, nor on the order the keys sit in.
131
+ */
132
+ export function evaluateTargeting(
133
+ key: string,
134
+ targeting: FlagTargeting,
135
+ actor: Actor | null,
136
+ subjects?: FlagSubjects | undefined,
137
+ ): boolean {
138
+ if (actor === null) return targeting.default;
139
+ // Nothing answers until every declared kind has resolved. Returning early on an allow-list hit
140
+ // would hide a missing record from exactly the callers who are on the list: the call site ships
141
+ // green, and raises later only for everybody else. `allowed` accumulates instead of returning.
142
+ let allowed = targeting.actors?.includes(actor.id) === true;
143
+ if (targeting.roles?.some((role) => hasRole(actor, role)) === true) allowed = true;
144
+ if (targeting.orgs !== undefined) {
145
+ const orgId = subjectIdOf({ key, kind: 'org', actor, subjects, via: 'orgs' });
146
+ if (targeting.orgs.includes(orgId)) allowed = true;
147
+ }
148
+ if (targeting.subjects !== undefined) {
149
+ // `for…in` + `Object.hasOwn` rather than `Object.entries`: own keys only, and no array pair
150
+ // allocated per declared kind on a path that runs inside policy predicates.
151
+ for (const kind in targeting.subjects) {
152
+ if (!Object.hasOwn(targeting.subjects, kind)) continue;
153
+ const id = subjectIdOf({ key, kind, actor, subjects, via: 'subjects' });
154
+ if (targeting.subjects[kind]?.includes(id) === true) allowed = true;
155
+ }
156
+ }
157
+ if (targeting.rollout === undefined) return allowed || targeting.default;
158
+ const subjectId =
159
+ targeting.bucketBy === undefined
160
+ ? actor.id
161
+ : subjectIdOf({ key, kind: targeting.bucketBy, actor, subjects, via: 'bucketBy' });
162
+ return allowed || bucketOf(key, subjectId) < targeting.rollout;
163
+ }