Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.0002 call up to a whole cent, ~50x, 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
38 changes: 34 additions & 4 deletions packages/flags/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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

Expand All @@ -36,10 +37,39 @@ 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 can answer**, so the raise never depends on
declaration order. 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`.
- **`subjectIdOf` is called only on branches that need a subject**, so a plain
`{ default, rollout }` flag still allocates nothing. Keep it that way — no closures, no
normalisation pass, no `Object.entries` on the common path.
- **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;
2 changes: 2 additions & 0 deletions packages/flags/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
flagDuplicate,
flagExpired,
flagExpiryInvalid,
flagSubjectRequired,
flagTargetingInvalid,
flagUnknown,
} from './errors';
Expand All @@ -29,6 +30,7 @@ describe('unit · @ultimat3/flags errors', () => {
flagDuplicate('search.rerank'),
flagUnknown('search.rerank', ['search.rerank']),
flagTargetingInvalid('search.rerank', 'rollout is 0.5'),
flagSubjectRequired({ key: 'search.rerank', kind: 'org', actorId: 'user-7', via: 'orgs' }),
flagExpiryInvalid('search.rerank', undefined),
flagExpired({
key: 'search.rerank',
Expand Down
34 changes: 32 additions & 2 deletions packages/flags/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const FLAGS_ERROR_CODES = [
'X_FLAG_DUPLICATE',
'X_FLAG_EXPIRED',
'X_FLAG_EXPIRY_INVALID',
'X_FLAG_SUBJECT_REQUIRED',
'X_FLAG_TARGETING_INVALID',
'X_FLAG_UNKNOWN',
] as const;
Expand All @@ -18,6 +19,7 @@ export const FLAGS_ERROR_TITLES: Readonly<Record<FlagsErrorCode, string>> = {
X_FLAG_DUPLICATE: 'two flags were declared with the same key',
X_FLAG_EXPIRED: 'a temporary flag is past its expiry and is still being evaluated',
X_FLAG_EXPIRY_INVALID: 'a temporary flag has no usable expiry date',
X_FLAG_SUBJECT_REQUIRED: 'a flag decides by a subject the evaluation context does not carry',
X_FLAG_TARGETING_INVALID: 'flag targeting is out of range or malformed',
X_FLAG_UNKNOWN: 'no flag is declared under this key',
};
Expand Down Expand Up @@ -63,14 +65,42 @@ export const flagUnknown = (key: string, known: readonly string[]): FlagsError =
meta: { key },
});

export const flagTargetingInvalid = (key: string, problem: string): FlagsError =>
/** `fix` is a parameter because a bad `bucketBy` is not repaired by editing `rollout` — axiom 4. */
export const flagTargetingInvalid = (key: string, problem: string, fix?: string): FlagsError =>
new FlagsError({
code: 'X_FLAG_TARGETING_INVALID',
cause: `${key}: ${problem}`,
fix: `set rollout to an integer 0-100 in defineFlag({ key: '${key}' })`,
fix: fix ?? `set rollout to an integer 0-100 in defineFlag({ key: '${key}' })`,
meta: { key },
});

/** Which targeting field asked for the subject, so the fix names an edit rather than a mechanism. */
export type FlagSubjectVia = 'orgs' | 'subjects' | 'bucketBy';

/**
* Thrown, never softened into a fallback. Answering a subject-scoped flag from the actor axis — or
* from the declared default — is the exact failure the subject axis exists to remove: it looks
* like it worked, and the record finds out when half of it is on a different code path.
*
* The fix differs by kind because the edit does: a missing org is repaired where the actor is
* minted, a missing record is repaired at the call site that already holds it.
*/
export const flagSubjectRequired = (init: {
key: string;
kind: string;
actorId: string;
via: FlagSubjectVia;
}): FlagsError =>
new FlagsError({
code: 'X_FLAG_SUBJECT_REQUIRED',
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`,
fix:
init.kind === 'org'
? `mint the actor with its tenant — userActor({ id: '${init.actorId}', orgId: '<org>' }) — before the isEnabled('${init.key}') call, or drop ${init.via} from defineFlag({ key: '${init.key}' })`
: `pass the record at the call site — isEnabled('${init.key}', actor, { ${init.kind}: '<id>' }) — or drop the "${init.kind}" ${init.via} entry from defineFlag({ key: '${init.key}' })`,
meta: { key: init.key, kind: init.kind, actorId: init.actorId, via: init.via },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const flagExpiryInvalid = (key: string, given: unknown): FlagsError =>
new FlagsError({
code: 'X_FLAG_EXPIRY_INVALID',
Expand Down
Loading
Loading