Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 45 additions & 3 deletions packages/entity/src/type-pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,51 @@ type _MoneyValueIsSchemasDeclaration = Assert<Identical<MoneyValue, SchemaMoneyV
/** The value type is a `number`. A `bigint` here is the regression, not a widening. */
type _MoneyMinorIsANumber = Assert<[MoneyValue['minor']] extends [number] ? true : false>;

/** Immutable, enforced: a mutable `minor` is a rounding bug with a place to hide. */
type _MoneyIsReadonly = Assert<
Identical<MoneyValue, { readonly minor: number; readonly currency: string }>
// The shape is pinned as independent properties rather than as one literal snapshot of the whole
// interface. The snapshot said the same thing, but every additive change had to be hand-edited
// past it — and a pin the next reader learns to hand-edit reflexively has stopped being a check.
// Only the key set moves when a field is added, which is the one place that decision belongs.

/** No field but these three, ever: a fourth is a shape nobody declared. */
type _MoneyHasNoOtherField = Assert<
[keyof MoneyValue] extends ['minor' | 'currency' | 'scale'] ? true : false
>;

/** …and none of the three may go — the pin must not pass by the type shrinking instead. */
type _MoneyHasEveryField = Assert<
['minor' | 'currency' | 'scale'] extends [keyof MoneyValue] ? true : false
>;

// Immutable, enforced, field by field: a mutable `minor` is a rounding bug with a place to hide.
// `Pick` carries `readonly` and optionality through, so each of these is exact about one field
// and says nothing about the others.

type _MoneyMinorIsAReadonlyNumber = Assert<
Identical<Pick<MoneyValue, 'minor'>, { readonly minor: number }>
>;

type _MoneyCurrencyIsAReadonlyString = Assert<
Identical<Pick<MoneyValue, 'currency'>, { readonly currency: string }>
>;

/**
* `scale` is the decimal exponent `minor` counts in — `{ minor: 2, currency: 'USD', scale: 6 }` is
* $0.000002. Optional, and pinned optional, because a cents-only `Money` could not name a
* sub-cent amount at all: the AI cost path rounded a $0.00016 call up to a whole cent, 62x, and
* the alternative to this field was a second money type.
*/
type _MoneyScaleIsAReadonlyOptionalNumber = Assert<
Identical<Pick<MoneyValue, 'scale'>, { readonly scale?: number }>
>;

/**
* The additive half, and the pin that decides the semver: a value carrying no scale is still a
* `MoneyValue`, meaning the currency's own minor unit. Every amount already stored, serialized
* and asserted against in every app is that shape — so the day this fails, the change that made
* it fail is a breaking one and needs a major, not a fix here.
*/
type _MoneyWithoutAScaleIsStillMoney = Assert<
{ readonly minor: number; readonly currency: string } extends MoneyValue ? true : false
>;

/**
Expand Down
56 changes: 49 additions & 7 deletions packages/flags/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ what lets `policy` (tier 2) call it from inside a predicate.
| Exports | `src/index.ts`, explicit, no `export *` |
| Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
| Files | one responsibility each, < 200 lines, tests beside the source |
| Subjects | `src/subject.ts` — one resolver; never a second allow list per record kind |

## Invariants

- **`defineFlag()` is a `define*` helper, not a ninth primitive.** A flag has no handler, no input
schema and no surface of its own, so there is nothing for `primitiveRegistrar` to project. The
eight kinds in `PRIMITIVE_KINDS` stay eight. A capability that *does* need a handler arrives as a
factory over an existing primitive — `llm()` returns an `action`.
- **Evaluation is synchronous and allocation-free on the hot path.** It runs inside policy
predicates and render passes. No `await`, no I/O, no date parsing (`expiresAtMs` is precomputed),
and the expired-flag error is built lazily so a rate-limited call costs a map lookup.
- **Evaluation is synchronous, and allocates nothing per declared subject kind.** It runs inside
policy predicates and render passes. No `await`, no I/O, no date parsing (`expiresAtMs` is
precomputed), and the expired-flag error is built lazily so a rate-limited call costs a map
lookup. It is **not literally allocation-free** — `roles` allocates a closure for `some()`, and
`subjectIdOf` takes an options object — so do not restate that stronger claim. What is
guaranteed: no `Object.entries`/`Object.keys` pass, no normalisation pass, and nothing at all
allocated for a flag declaring no subject axis.
- **A temporary flag without an expiry must not be declarable.** `FlagExpiryIsMandatory` in
`flag.ts` is a compile-time assertion: loosen the union and `tsc -b packages/flags` fails on that
line. `toFlag()` re-checks at runtime for snapshots and JS callers. Do not replace either with a
Expand All @@ -36,10 +41,47 @@ what lets `policy` (tier 2) call it from inside a predicate.
Never name a vendor here (axiom 7).
- **The rate limit is keyed on `clock.monotonic()`**, not wall time: an NTP correction or a
container resuming must not reopen the window and flood the monitor.
- **Buckets are `fnv1a(key + ':' + actorId) % 100`.** Never `Math.random()`, and never the actor id
alone — hashing the actor by itself puts the same cohort in the first slice of every rollout the
app ever runs.
- **Allow lists beat the rollout.** An operator who named an actor is not overruled by a hash.
- **Buckets are `fnv1a(key + ':' + subjectId) % 100`.** Never `Math.random()`, and never the
subject id alone — hashing the subject by itself puts the same cohort in the first slice of every
rollout the app ever runs. **The assignments are pinned in `bucket.test.ts`**: a rollout already
live is a promise to the subjects inside it, so changing the hash must break that test, never
move the boundary silently.
- **A flag decides about a SUBJECT, and the actor is one kind of subject.** `subject.ts` owns the
resolution. `actor` and `org` come off the `Actor`; every other kind comes from the `subjects`
argument at the call site. `actors` and `orgs` in targeting are shorthands for the first two
kinds, not separate mechanisms — do not add a fourth parallel allow list for a new record kind,
it already works through `subjects`. `roles` is NOT a subject: a role is a predicate over the
actor, has no id, and cannot bucket.
- **One source per kind.** A built-in kind is never read from the call-site map, so there is no
precedence rule and no second place a tenant comes from. Passing `org` at a call site is dead
data; with no `actor.orgId` the evaluation raises and the fix line says to mint the actor.
`assertTargeting` refuses `subjects.actor` / `subjects.org` for the same reason.
- **The kind space is open, like the flag key space.** No registry of kinds: a typo raises
`X_FLAG_SUBJECT_REQUIRED` at the first evaluation, the same loud failure `X_FLAG_UNKNOWN` already
gives an undeclared key. Do not add a `defineSubjectKinds()` — it is a second declaration surface
buying a check evaluation already makes.
- **Allow lists beat the rollout.** An operator who named a subject is not overruled by a hash.
`actors`, `roles`, `orgs` and `subjects` are one rank — any hit is `true`, so their order is
unobservable. That is the same OR Flipper applies across the actors passed to one `enabled?`.
- **The subject axis throws rather than degrades.** A kind the evaluation context does not carry
raises `X_FLAG_SUBJECT_REQUIRED`. Never fall back to the actor axis or to `default`: an answer
about a record computed from whoever was calling looks like it worked, which is the whole bug
class. **Every declared kind is resolved before any branch answers** — allow lists included — so
the raise depends only on the flag and the context, never on declaration order and never on which
list happened to match. An early `return true` on an allow-list hit is the regression to watch
for: it hides a missing record from exactly the callers who are on the list. A `null` actor is
the one exception and still gets `default` — no evaluation context at all, every such call
answers alike, so no single subject is split.
- **`bucketBy` defaults to `actor`.** The subject axes are opt-in; a flag declared before they
existed must answer identically, which is why the default is not `org`.
- **Subject lookups are own-property only.** `subjects[kind]` goes through `Object.hasOwn` and a
`typeof` re-check, so a kind named `toString` or `constructor` is absent rather than resolving to
an inherited function, and a non-string id never reaches `bucketOf`. Same rule for the targeting
map, which is why the loop is `for…in` + `Object.hasOwn` and not `Object.entries`.
- **Every app-supplied string in a `fix:` goes through `JSON.stringify`, and a subject kind becomes
a computed key.** A `bank-integration` kind is not a valid identifier, so `{ bank-integration: … }`
would be a fix that does not parse — axiom 4 wants an instruction that runs. Pinned by
`errors.test.ts` running the generated snippet through `new Function`.
- **An unknown key throws.** Answering `false` is a branch that never runs and never says so.
- `default: true` beside a `rollout` is refused: the two answer the same actors and disagree.

Expand Down
73 changes: 66 additions & 7 deletions packages/flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ import { isEnabled } from '@ultimat3/flags';
if (isEnabled('checkout.new-tax-engine', actor)) {
// …
}

// with the app's own records in play
if (isEnabled('scraper.persist-profile', actor, { bank: bank.id })) {
// …
}
```

Synchronous, for the same reason `can()` is: this runs inside policy predicates and render passes,
Expand All @@ -68,11 +73,64 @@ silently never runs in production.
| `default` | the answer when no allow list and no rollout claims this actor |
| `actors` | actor ids that are always on, ahead of any rollout |
| `roles` | actor roles that are always on, ahead of any rollout |
| `rollout` | whole percentage 0-100, stable per actor |
| `orgs` | org ids that are always on — shorthand for the `org` subject kind, read from `actor.orgId` |
| `subjects` | allow lists for the app's own record kinds: `{ bank: ['bank_integration:bbva'] }` |
| `rollout` | whole percentage 0-100, stable per bucketing subject |
| `bucketBy` | which subject kind the rollout divides: `'actor'` (default), `'org'`, or any kind the call site carries |

Order is allow lists → rollout → default. `actors`, `roles`, `orgs` and `subjects` are one rank —
any hit is `true`. An operator who names a subject is not overruled by a hash. A rollout buckets
`fnv1a(key + ':' + subjectId) % 100`, never `Math.random()`: one subject gets one answer on every
call, in every process, without the nodes talking to each other.

## Subjects — what a flag decides about

A flag decides about an **identified record**: a user, a tenant, a bank integration, a device. The
actor is one subject kind among several, not a privileged one.

| Kind | Where its id comes from |
|---|---|
| `actor` | `actor.id` — spelled `actors` in targeting |
| `org` | `actor.orgId` — spelled `orgs` in targeting |
| anything else | the `subjects` argument at the call site |

```ts
// whole tenants, named — the 90% case, which is why it has a shorthand
targeting: { default: false, orgs: ['org_acme'] }

// 10% of tenants, each one whole
targeting: { default: false, rollout: 10, bucketBy: 'org' }

// the app's own record kind
targeting: { default: false, subjects: { bank: ['bank_integration:bbva'] } }
isEnabled('scraper.persist-profile', actor, { bank: 'bank_integration:bbva' });

// 10% of banks, each bank whole
targeting: { default: false, rollout: 10, bucketBy: 'bank' }
```

`actor` and `org` are resolved from the `Actor` and **never** from the call-site map — one source
per kind, so there is no precedence rule to remember and no second place a tenant can come from.
Every other kind is the app's vocabulary; the kind space is open, exactly like the flag key space.

Bucketing by a record is what keeps it **whole**. An actor-bucketed rollout cuts through a tenant:
3 of an org's 30 members on the new export path and 27 on the old, sharing documents, filing a bug
nobody can reproduce. `bucketBy` puts the whole subject on one side. `'actor'` stays the default,
so every flag declared before this axis answers exactly as it did.

`roles` is deliberately **not** a subject kind: a role is a predicate over the actor, not an
identified record, so it has no id to hash and cannot bucket a rollout.

### A missing subject is an error, not a fallback

If targeting decides by a kind the evaluation context does not carry, `isEnabled()` throws
`X_FLAG_SUBJECT_REQUIRED`. It never falls back to the actor axis or to `default`: an answer about a
record computed from whoever happened to be calling is the exact failure this axis removes, and it
looks like it worked. Every declared kind is resolved before any of them can answer, so the raise
does not depend on the order the keys sit in.

Order is allow lists → rollout → default. An operator who names an actor is not overruled by a
hash. A rollout buckets `fnv1a(key + ':' + actor.id) % 100`, never `Math.random()`: one actor gets
one answer on every call, in every process, without the nodes talking to each other.
A `null` actor is the one exception and still gets `default` — it says there is no evaluation
context at all, every such call answers alike, and no single subject is split.

## Overrides, out of band

Expand Down Expand Up @@ -113,7 +171,8 @@ and the manifest should all read, so none of them recomputes "expired".
|---|---|
| `src/flag.ts` | the two kinds, the compile-time expiry rule, normalisation |
| `src/targeting.ts` | who a flag is on for; declaration-time validation |
| `src/bucket.ts` | the stable `(flag, actor)` bucket — FNV-1a, never `Math.random()` |
| `src/subject.ts` | what a flag decides about — subject kinds and how each resolves to an id |
| `src/bucket.ts` | the stable `(flag, subject)` bucket — FNV-1a, never `Math.random()` |
| `src/registry.ts` | `defineFlag`, key → flag, `applyFlagSnapshot` |
| `src/runtime.ts` | the clock and the per-flag report rate limit over core's `reportError` |
| `src/evaluate.ts` | `isEnabled()` — the one way to ask |
Expand All @@ -126,5 +185,5 @@ Tier 1. May import tiers 0-0 only — enforced by `bun run scripts/boundaries.ts

## Errors

`X_FLAG_DUPLICATE` · `X_FLAG_EXPIRED` · `X_FLAG_EXPIRY_INVALID` · `X_FLAG_TARGETING_INVALID` ·
`X_FLAG_UNKNOWN`
`X_FLAG_DUPLICATE` · `X_FLAG_EXPIRED` · `X_FLAG_EXPIRY_INVALID` · `X_FLAG_SUBJECT_REQUIRED` ·
`X_FLAG_TARGETING_INVALID` · `X_FLAG_UNKNOWN`
26 changes: 26 additions & 0 deletions packages/flags/src/bucket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ describe('unit · bucketOf', () => {
});
});

describe('unit · pinned assignments', () => {
test('a known (flag, subject) pair keeps its bucket, so a hash change cannot re-roll everyone', () => {
// These literals are the contract. A rollout already in production is a promise to the orgs
// and actors inside it: changing the hash silently moves the boundary under a live rollout,
// switching a feature off for tenants who had it. Breaking this test is the intended alarm —
// never re-pin it to whatever the new hash says.
expect(bucketOf('billing.export', 'org-42')).toBe(13);
expect(bucketOf('billing.export', 'org-12')).toBe(0);
expect(bucketOf('billing.export', 'org-1')).toBe(78);
expect(bucketOf('billing.export', 'user-1')).toBe(51);
expect(bucketOf('search.rerank', 'org-42')).toBe(87);
// A record subject is the same hash — `flipper_id`-shaped ids included.
expect(bucketOf('scraper.persist-profile', 'bank_integration:bbva')).toBe(39);
expect(bucketOf('scraper.persist-profile', 'bank_integration:santander')).toBe(86);
});

test('one org gets one bucket for one flag, in this process and any other', () => {
// Determinism is what makes "whole org in or whole org out" true across nodes and restarts:
// the hash is pure, so a second process computing it agrees without being asked.
const first = bucketOf('billing.export', 'org-42');
for (let call = 0; call < 1_000; call += 1) {
expect(bucketOf('billing.export', 'org-42')).toBe(first);
}
});
});

describe('unit · fnv1a', () => {
test('is the published 32-bit FNV-1a, so two nodes agree without talking', () => {
// Reference vectors from the FNV specification.
Expand Down
17 changes: 11 additions & 6 deletions packages/flags/src/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Single responsibility: the stable bucket a (flag, actor) pair falls into. Never `Math.random()`:
// Single responsibility: the stable bucket a (flag, subject) pair falls into. The subject is an
// actor id or an org id — same hash either way, so a tenant is whole. Never `Math.random()`:
// a rollout that re-rolls per call shows one user the new experience on one request and the old
// one on the next, which is a worse product than no rollout at all — and untestable besides.

Expand All @@ -23,9 +24,13 @@ export function fnv1a(text: string): number {
}

/**
* The flag key is hashed WITH the actor id, not the actor id alone: hashing the actor by itself
* would put the same unlucky cohort in the first 10% of every 10% rollout the app ever runs, so
* one group of users would meet every half-finished feature in the product.
* The flag key is hashed WITH the subject id, not the subject id alone: hashing the subject by
* itself would put the same unlucky cohort in the first 10% of every 10% rollout the app ever
* runs, so one group of users — or one group of tenants — would meet every half-finished feature.
*
* `subjectId` is whatever axis the targeting buckets by: an actor id, or an org id when
* `bucketBy: 'org'` keeps a tenant on one side of the boundary. Pure, so two nodes agree about a
* subject without talking, and a restart does not re-roll anyone.
*/
export const bucketOf = (key: string, actorId: string): number =>
fnv1a(`${key}:${actorId}`) % BUCKETS;
export const bucketOf = (key: string, subjectId: string): number =>
fnv1a(`${key}:${subjectId}`) % BUCKETS;
Loading
Loading