Skip to content

feat: feature flags on OpenFeature — v26.10.1 - #12

Merged
ancongui merged 75 commits into
mainfrom
feat/feature-flags
Oct 7, 2026
Merged

ancongui merged 75 commits into
mainfrom
feat/feature-flags

Conversation

@ancongui

@ancongui ancongui commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Adds optional feature flags on OpenFeature with the shared PyFly/LaraFly evaluation contract: deterministic targeting, percentage rollouts, layered configuration/file/HTTP/store sources, and audited relational writes with optimistic concurrency. The facade, bean/controller gates, middleware, Blade directives, actuator, dashboard and Artisan command share the same effective definitions.

Includes a complete guide and public contract, tested Lumen rollout examples, and English/Spanish book chapters. Both PDF/EPUB editions are built from the release tag with source-commit and checksum evidence. Runtime version is 26.10.1; the release tag is v26.10.1.

Implementation adaptations

  • The PHP SDK has no flag metadata, provider status/events, domains or shutdown hook. Firefly retains metadata in its own evaluation result, installs/restores providers at lifecycle boundaries, and exposes its built-in definition views separately.
  • Health uses the existing lowercase component name featureflags. Shared-cache refresh and warning suppression account for PHP-FPM's per-request boot model.
  • Controller gate rows live in the existing compiled proxy plan. Normal route gating precedes binding; method fallbacks run through the interceptor. Existing proxy restrictions on final classes remain.
  • Separate configuration classes and an evaluator interface preserve module/lane boundaries. Runtime YAML parsing is a declared dependency.
  • Migrations auto-load only for an enabled database store. Shared tables use exact keys and UTC microseconds; caller-owned transaction commit/rollback remains authoritative.
  • Raw JSON preserves object/array, float/integer and valid member-name distinctions, including leading U+0000. Admin audit origin is trusted in-process metadata; HTTP query input cannot select it. Management preview uses explicit context and suppresses evaluation telemetry. The dashboard displays complete escaped JSON when its native object view cannot represent a name; those definitions are edited through CLI/actuator instead of a lossy editor.
  • Accepted writes may report refreshPending before local visibility. Source outages preserve last-good definitions. Gates use their documented caller default, which can deliberately open a missing/disabled/error result.

Shipped packages outside packages/feature-flags (FROZEN review)

Package Reason for change
actuator Preserve raw JSON and trusted in-process request origin for management while keeping existing request construction compatible.
admin Add the operator page, raw endpoint calls, read/preview/write forms and their integration/browser coverage.
cli Include feature-flag advice in cached proxy planning so compiled and uncached applications use the same advice order.
firefly Include the new optional capability in the umbrella package.
installer Advertise feature-flags in the capability catalog.
kernel Set the framework release version to 26.10.1.
observability Adapt the feature-flag metrics port to MeterRegistry, honoring the metrics master switch; regenerate its manifests.
security Contribute trusted principal context and audit actors; declare the integration suggestion and regenerate manifests.
testing Provide restoring feature-flag overrides and withFeatureFlags().

Validation and review

  • Independent task, lane/merge and final whole-branch reviews completed. The single final fix wave at ca3eafe is independently approved; all eight final finding groups are addressed.
  • Final ca3eafe local gate: unpiped composer check exit 0, 5,727 passed / 6 existing environmental skips / 20,706 assertions. Pint, PHPStan max and Deptrac passed; strict Composer validation, monorepo validation, installed package consumer and sensitive-path checks passed. Browser: 128 passed / 1,034 assertions.
  • Strict MkDocs, 21 book tests and 292 listings per edition passed. Fresh English (546 pages) and Spanish (573 pages) PDF/EPUB assets have verified checksums and source ca3eafe; changed chapter pages and EPUB structure were inspected. The tag workflow will rebuild all four assets at the merge commit.
  • Real cross-runtime PostgreSQL proof exercised both schema owners, bidirectional writes/audit/conflicts and HTTP synchronization, including 1,505 evaluations in each direction.
  • All 25 conformance files and both public contract pages were reverified byte-identical to the shared source. The final cross-runtime run also preserved leading U+0000 member names, nested objects/lists and floating values through stored definitions and both real HTTP directions.

The tag workflow validates PHP 8.3–8.5, verifies Packagist indexes the exact tagged commit, installs the public package in a clean consumer, and attaches the freshly built books. Publication and downloaded-asset checks will be verified separately after merge and tag.

Andres Contreras added 30 commits October 2, 2026 02:33
… every registration point

Adds packages/feature-flags (firefly/feature-flags, Firefly\FeatureFlags\):
the discovered AutoConfiguration provider, an empty wiring provider,
FeatureFlagsSettings with its four source/server settings classes, and
FeatureFlagsAutoConfiguration (#[Order(700)]) whose one bean binds the
settings only while firefly.feature-flags.enabled is on. Settings refuse
the boot naming the key: a disabled-status other than 404/403/503, an
unknown store driver, an enabled file source without a path or http
source without a url, an enabled sync server without a token (unless
allow-anonymous) or mounted on the root path, and a duration that does
not parse. The compiled manifests are committed and drift-guarded.

The root library requires open-feature/sdk ^2.3, and symfony/yaml moves
from require-dev to require (same constraint) for .yaml/.yml flag files.
The local, git-ignored lock gains exactly open-feature/sdk 2.3.0 and
myclabs/php-enum 1.8.5.

Shipped packages touched outside packages/feature-flags, and why
(docs/contributing.md FROZEN rule):
- firefly/firefly (packages/firefly/composer.json): the runtime
  metapackage requires every runtime component, now including this one.
- firefly/cli (packages/cli/composer.json, require): ManifestCacheWriter's
  proxy planner will name FeatureFlagAdviceSource so firefly:cache
  compiles #[FeatureFlag] into the same proxy plan.
- firefly/testing (packages/testing/composer.json, require): the test
  kit will install the test-override layer (FeatureFlagOverrides,
  withFeatureFlags()).
- firefly/observability (packages/observability/composer.json, require):
  it will back the FeatureFlagMetrics port with
  feature_flag_evaluations_total{flag, variant, reason}.
- firefly/security (packages/security/composer.json, suggest only): it
  will fill the evaluation context and the actor of a flag write from
  the principal, guarded by #[ConditionalOnClass].
- firefly/installer (src/CapabilityCatalog.php): a `feature-flags`
  capability after `openapi`; CapabilityCatalogTest requires every
  package to be a capability or a documented core package.

Also: deptrac.yaml gains the FeatureFlags layer and its ruleset plus the
Security/Observability/Testing/Cli => FeatureFlags edges, each with its
prose; skeleton/config/firefly.php documents every firefly.feature-flags
leaf (ConfigReferenceTest); docs/modules.md, docs/index.md, docs/book.md,
docs/publishing.md and the AUDITED docblock in tests/Support/DocsCodeAudit.php
count thirty packages (SiteNavigationTest derives the counts).
… and read null as unset

FeatureFlagsSettings said a malformed value refuses the boot, but four
coercions let one through:
- a negative number of seconds became 0 while '-5s' was refused; a
  negative or non-finite number is now refused, naming its key;
- sources.http.timeout accepted 0, which Laravel's HTTP client reads as
  no timeout at all; it must now be above zero (a refresh-interval of 0
  stays allowed: the source is re-checked on every refresh);
- an explicit null duration refused the boot, unlike every other Config
  read; null (env('X') with X unset) now takes the default;
- a non-string sources.store.connection (5, true) silently became the
  default connection; it is now refused, naming its key.

Durations are read by Settings\DurationReader, whose get() takes the full
key once: the same literal is read and named in every refusal, and stays
visible to ConfigReferenceTest and DocsCodeAudit.

Tests: one case per boolean pattern sets every leaf to a distinct
non-default value and checks every property (a swapped mapping fails),
plus disabled-status 403/503 accepted and each refusal above.
…ionReader::get()

A sources.http.refresh-interval of 0 would send the remote server one
conditional GET per request with no stampede lock, so it is now refused
at boot, naming the key, like a zero sources.http.timeout. A file or
store refresh interval of 0 stays allowed: re-checking those on every
request costs a stat or one MAX(id) query. DurationReader::get() now
takes the reason zero is unsafe and quotes it in the refusal.

A test runs ConfigReferenceTest's key-discovery pattern over
FeatureFlagsSettings.php and expects the four duration keys, so renaming
DurationReader::get() fails instead of silently hiding those keys from
the configuration-reference check.
…s and import the conformance suite

Definition\{Json, FlagDefinition, FlagDocument} and Evaluation\{FlagType,
EvaluationReason, EvaluationError, Resolution, FlagdEvaluator} are the
vocabulary the evaluator and core lanes code against. Json keeps `{}`
apart from `[]` (an empty or list-like object stays a stdClass) and every
accessor gives numeric-looking flag keys, variant names and metadata
names back as text.

tests/Conformance is a byte-identical copy of the shared conformance
folder (firefly-vectors.json sha256 6199394048ea…: normalize 5,
validate 20, compose 4, expiry 1, evaluate 48, bucket 5); a test
recomputes MANIFEST.sha256 and refuses an extra file. ConformanceFiles
and FireflyVectors read it for the later runners, iterating the file
rather than counting cases.

Pre-flight rulings applied: Json::isList() asserts list<mixed> and
Json::object() declares its return type (M23); the vector helpers narrow
instead of casting mixed (M23); the unpinned boolean-defaultVariant
assertion is dropped until both frameworks agree on a vector (m33).
…budget boundary vectors

The shared folder gained two evaluate vectors in a third document that pin
contract delta 4: every `$ref` resolved during expansion costs one unit of
the 10 000-value budget (within the budget TARGETING_MATCH, beyond it
PARSE_ERROR). firefly-vectors.json is now sha256 b80d27eb678c…; evaluate
50, every other kind unchanged. The copy stays byte-identical to the
shared folder; the runners iterate the file, so no count changes.
…non-scalar document metadata

PHP stores a numeric-looking name ("1", "2024") as an int array key, so
FlagDefinition::metadata(), FlagDocument::$metadata and keyFingerprints()
were int-keyed behind array<string, ...> types, and a consumer passing
such a key to a string parameter under strict_types would throw. The
three shapes are now array<array-key, ...>, so static analysis makes
every consumer cast with (string); keys(), FlagDefinition::$key and
variantNames() stay text. A test pins the int keys, the reachable values
and the (string) round trip.

FlagDocument::fromJsonValue() no longer drops non-scalar document
metadata values: it keeps them as decoded so validation can reject the
document with `metadata values must be scalars`, as PyFly does
(R-doc-meta-scalars).

New tests: PHP empty arrays in a definition's variants, metadata and
targeting encode as {}; one changed variant or metadata value moves the
document fingerprint and that flag's fingerprint only; the per-kind
vector datasets add up to the whole file, and a duplicate case name
throws instead of dropping a case. Docblocks gain @throws JsonException,
the shallow-readonly note and a corrected round-trip claim (1E400 and
integers beyond 64 bits); the dead boolean defaultVariant fixture is gone.
Resolution::$metadata is filled from the document's and the flag's
metadata, whose numeric-looking names ("1", "2024") are int keys, so it
is now array<array-key, bool|int|float|string> like them and static
analysis makes consumers cast names with (string). open-feature/sdk 2.3's
ResolutionDetails has no flag-metadata slot, so no key reaches the SDK
from here; the names leave PHP as (string) casts or as JSON member names
through Json::object(), which keeps a list-like map an object. A test
pins both, and that withValue() carries the metadata.
…s with the contract's messages

FlagDefinitions implements every rule of CONTRACT.md's rules table in the order
PyFly checks them: the twelve flag phrases, metadata keys must not be empty,
numbers must be finite (an iterative walk over the whole definition, evaluators
and document metadata, so INF/NAN never reach encoding), and the document
sections (flags, $evaluators, metadata must be an object; document metadata
values must be scalars), each reported with the section's name as the key.
A boolean or other non-string defaultVariant, and "" unless it names a
variant, is not a variant (R-default-variant-bool). Numeric-looking names are
text (RF3), unknown fields are kept and ignored (RF6), and a $ref to a missing
evaluator is not a load error.
…r rules and the document key

An empty list where the contract expects an object (a flag's targeting or
metadata, a document section) counts as {} and a flag's [] targeting or
metadata is handed on as {}, so every reader sees no targeting; [] as the
document, a flag definition or an evaluator rule stays an error. A non-object
evaluator rule is refused with key $evaluators and 'targeting must be an
object' (the evaluator named in the hint); a document that is not an object
with key <document> and 'document must be an object'. Re-copies the shared
conformance folder (firefly-vectors.json eab99181..., validate 23).
R-depth-validate: a flag definition, an evaluator rule or the document
metadata nesting deeper than 256 levels (the definition itself is level 1)
is refused with 'definition nests too deeply' (key: the flag, $evaluators
naming the rule, or metadata). The check rides the existing iterative walk,
which stops descending at the limit, so validation stays recursion-free and
bounded, and a document that validates always encodes (closes the review's
Minor 1). Pins INF from 1E400 inside a stdClass variant. Re-copies the
shared conformance folder (firefly-vectors.json 721e54ad..., validate 26).
…ting runs on

JsonLogic is a port of panzi-json-logic 1.0.1, the engine
openfeature-flagd-core 1.0.0 evaluates targeting with, so a rule answers
the same in PHP and Python: Python truthiness with the empty-list rule,
`===` as Python `==`, "True"/"False" and `%.15g` in to_string(), float()
in to_number() with "inf" as NaN, `/` always a float, `%` signed like
the divisor, `var` dot paths with canonical list indexes and `length`.

Beyond the plan's table, each checked against the reference itself:
numbers compare exactly across int and float (beyond 2^53 too); a boolean
stays a boolean through min/max; a zero remainder takes the divisor's
sign; float() reads Unicode decimal digits and strips Unicode spaces,
linearly on long text; an infinite index or length, a dotted name under a
known operator and a third `var` argument are JsonLogicError (GENERAL),
as the reference's OverflowError and TypeError are; a float index far
outside the int range answers instead of raising a PHP 8.5 warning; a
stdClass compares by identity in `==`.

An operation nobody registered is an UnknownOperator (PARSE_ERROR), an
unresolved {"$ref": ...} included. Every other failure is a
JsonLogicError: a registered operator's own exception is wrapped, and
logic nested deeper than 1000 levels stops instead of exhausting the
stack. The gaps PHP cannot close (list identity in `==`, 64-bit integer
arithmetic, `log` writes nothing) are in the class docblock.
…ched-file sources

FlagSource (name, precedence, refresh interval, boot refusal, reported
revision, load against a known revision), SourceSnapshot and
FlagSourceUnavailable. ConfigFlagSource (precedence 100, checked on every
refresh, revision = sha256 of the two maps) hands flags/evaluators to the
shared validation as written: [] is an empty section, a non-empty list is
refused (flags/$evaluators must be an object). FileFlagSource (precedence 200)
reads .json/.yaml/.yml, revision mtime-size, and keeps no state: a failed load
yields no revision, so a broken edit is retried on every check. Only JSON null
or an empty/comment-only YAML file is an empty document; any other non-object
top level is refused with key <document>; a parse error names the file. YAML
dates and timestamps become text (M4: expires: 2025-01-01 is the YYYY-MM-DD
text flagd expects), PHP tags are refused. Both are #[Component]s gated on
firefly.feature-flags.enabled (file also on sources.file.enabled) that back
off when the application declares its own OpenFeature Provider; compiled
manifests regenerated. Tests pin mtimes with touch() and remove temp files
in afterEach (m30).
…imes and deep data

The builtin names live twice in JsonLogic: the dispatch match and the
BUILTINS list unrecognized() reads to tell a dotted name under a builtin
(`in.x`, GENERAL) from an unknown one (`a.b`, PARSE_ERROR). A test now
holds both to the reference's operation table (the match arms are read
from evaluate()'s source) and checks that every builtin dispatches and
fails its dotted form as GENERAL; dropping a name from either side fails
it.

The class docblock no longer says date-times have no JSON form. The
contract evaluates a context date-time as Unix epoch milliseconds, so the
evaluator converts DateTimeInterface values before evaluation and this
class never sees one (R-datetime); a test pins that a stray one reads as
an object. The gap list gains deep data: past about 490 levels the
reference's recursive to_string() raises RecursionError (GENERAL), and
its to_number() past about 990, where PHP answers.
…cache clearing and parser errors

R-yaml-invalid-date: an impossible calendar date written unquoted as a YAML
value (expires: 2025-02-30) is rolled over by PHP's DateTimeImmutable
(2025-03-02) before validation can see it, where PyFly refuses the file and
JSON refuses the string. The FileFlagSource docblock no longer claims YAML
dates read as the same JSON would; it states the divergence and advises
quoting dates, and a test pins the current behavior (quoted, it is refused).

A test now pins clearstatcache(): an unchanged check caches the stat of the
path, a child process rewrites the file (this process's own file I/O and
touch() would clear the stat cache), and the next check must reload.

Symfony Yaml throws a raw Error for a block-mapping key starting with "\0";
any throwable from Yaml::parse (only) is now the <document> error naming the
file. datesAsText() rebuilds objects through an array, so a flow-mapping key
starting with "\0" (which Symfony accepts) no longer throws there.
…tors with the shared bucketing table

FlagdOperators registers flagd's four operators on JsonLogic, ported
from openfeature-flagd-core 1.0.0's custom_ops (mmh3 5.3.1, semver
3.1.0) so a user lands in the same variant in PHP and Python.

Fractional is fractional-v2: the bucket key is an explicit string first
argument, else $flagd.flagKey . targetingKey (no targeting key -> null);
the hash is unsigned MurmurHash3 x86 32 (PHP's murmur3a digest, read
with unpack('N')) and bucket = (hash * total) >> 32 stays an int because
the total is capped at 2^31 - 1 before the product. Integer weights
only, negatives clamp to 0, a malformed bucket is null. Where the
reference raises (data or $flagd that is not an object, a flag key and
targeting key that are not both text) it throws JsonLogicError
(GENERAL) instead of answering null.

SemVer reads a float as Python's repr, not PHP's 14-digit string cast,
pads partial versions, parses strict SemVer 2.0.0 in linear time
(python-semver's own pattern exhausts PCRE's JIT stack on a long
prerelease) and compares numbers of any length exactly. Python's
4300-digit int() limit is kept: a longer core part does not parse, and
comparing a longer numeric prerelease identifier fails the rule, as it
raises in the reference. starts_with/ends_with take two strings or
answer null.

The bucketing table (5 splits x 301 keys) passes at the operator level;
the hash test pins the testbed's boundary keys at 0, 1, 2^31 - 1, 2^31
and 2^32 - 1.
…ookkeeping and change events

FlagRegistry composes the sources per key (the highest layer supplies the
whole definition; $evaluators and document metadata merge per name), records
each flag's origin and the layers it shadows, and keeps every source's last
good document, revision and check time in the application cache so
share-nothing FPM workers share one refresh schedule and one change detector.
The composition is memoized until the shortest polling refresh-interval
elapses, so long-lived processes see changes on the same schedule; when the
cache store fails, sources load in-process and the flags keep evaluating.

FeatureFlagsChanged names the keys whose effective flag differs in any field
(types included), through a referenced evaluator (direct or transitive) or an
inherited document metadata entry; origin is the moved source, startup, or
test-overrides. A failing config always refuses the boot, a file only until it
ever loaded; remote and store failures never do. Forced refreshes bypass the
refresh lock, and each transition is announced once across workers.
…the config-key audit does not read as a hidden key

tests/ConfigReferenceTest flags any ->get(Class::CONST. call as a configuration
key spelled out of sight; CacheBook's cache reads are not configuration.
…test overrides are active

Parity with PyFly: FeatureFlagsChanged.changedKeys compares what the
application evaluates. A source change to a key an active test override
shadows is not announced (clearing the override announces it with the
source's definition), and an overridden key whose targeting references a
changed evaluator is. The shared record still tracks the shared set for every
worker.
…he bookkeeping failure-proof

Review round 1 of the registry:
- A worker that composed an older document (it served the last good one while
  another worker loaded a newer one) no longer overwrites the newer shared
  record nor announces a revert: right before recording a transition the
  source states are read again and a mismatch leaves the record alone.
- A cache failure while releasing a lock is caught and degrades the book; it
  no longer escapes refresh(), start() or document().
- A polling source's state is written before its lock is released, and the
  holder re-reads the state after taking the lock, so a second worker does not
  load again at the interval boundary.
- A cached state that never loaded no longer displaces this process's loaded
  one (an evicted entry written back by a worker that could not load it).
- The cache outage WARN is logged once per outage, not once per process.
- A cached document that cannot be read (not JSON, or another shape) makes the
  registry reload the source instead of failing every request.
- observe()/record() name the layer hash $layersHash.
… sem_ver and the evaluator

The reference evaluator turns a value into text with Python's str() in
two places: sem_ver reads a number as the version str() writes, and
flagd-core names the variant a targeting result selects with it (a
fractional bucket written as 1 selects the variant "1"). SemVer's
private float formatter moves, unchanged, to PythonText::float(), and
PythonText::str()/repr() add the rest of str(): text as itself, True,
False and None, and lists and objects as Python's list and dict repr,
their text quoted and escaped as repr() does (\x, \u and \U escapes for
characters Unicode calls Other or Separator).

A differential run against Python 3.14 (100 000 random JSON values,
code points across every plane) matches except where a character is
unassigned in Python's Unicode 16.0 and assigned in this PCRE's Unicode
17: the docblock names that limit.
…with the flagd testbed and the Firefly vectors

DefaultFlagdEvaluator is openfeature-flagd-core 1.0.0's _resolve in PHP,
returning a Resolution: FLAG_NOT_FOUND, DISABLED (unchecked), STATIC or
DEFAULT through the defaultVariant, TARGETING_MATCH, GENERAL for a name
no variant has (or a null-valued one), TYPE_MISMATCH by flagd's own
check (a float request accepts an int or a bool and returns a float, an
object request accepts a list), PARSE_ERROR for an unknown operation and
for a targeting that is not an object (one Python reads as false is no
targeting), GENERAL for any other rule failure. A non-text targeting
result names its variant through PythonText (Python's str()). It never
throws.

$ref resolution follows the contract, not flagd's textual pass:
RefResolver expands references structurally and transitively inside a
flag's targeting, keeps strings byte for byte, and leaves a missing name
or a cycle in place (PARSE_ERROR when evaluated). One bounded pass first
refuses a flag over 10 000 JSON values (each resolved reference costing
one) or 128 nesting levels without building the expansion; expand()
builds a new tree, so the document is never touched.

The JSON Logic data carries $flagd.flagKey, $flagd.timestamp (a clock
closure, Unix seconds) and targetingKey over the attributes. Every
DateTimeInterface in the context, at any depth, becomes its epoch
milliseconds, correctly rounded from the exact decimal of the whole
microseconds (int/int division would round twice past 2^53 us); the
caller's context is never modified and an object that contains itself
terminates. Successful results carry the document's scalar metadata
under the flag's. A defaultVariant "" names the "" variant when there
is one (contract ruling; flagd reads it as none).

FlagdEvaluatorConfiguration (#[Order(700)]) registers the evaluator when
firefly.feature-flags.enabled, unless the application has its own;
manifests regenerated. The testbed runs 125 scenarios (15 @fractional-v1
skipped), every evaluate vector (50) and the five bucket tables through
the evaluator.
…d the shared set under a lock

Review round 2 of the registry:
- A long-lived process now remembers every loaded state it serves, adopted
  from the cache or loaded itself, so after an eviction and a failing source
  it falls back on the newest document instead of resurrecting an older one
  cluster-wide.
- The shared record is read, compared, checked against the source states and
  written under the non-blocking lock `lock:composed`; a worker that finds it
  held skips (a long-lived process records at its next refresh), so a newer
  record written meanwhile is never overwritten. The same-layers fast path
  needs no lock. A source cannot be named `composed`.
…ate-time walk and stop on self-containing data

Three controller rulings after the evaluator landed:

R-default-empty reverses the earlier ruling: `defaultVariant: ""` is no
default variant even when a variant is named "", as flagd-core reads it
(caller default, DEFAULT). The conformance folder is re-copied verbatim
from the shared contract (firefly-vectors.json 5d886af1..., evaluate 51
with the new empty-default vector, validate 27).

R-L-T5-bounds: the context date-time walk is PyFly's
_epoch_millis_context walk: every value read spends one of 10 000,
containers past 128 levels (the attributes are level 1) are not entered,
and a container is rebuilt only when a date-time inside it changed. A
date-time beyond the bounds stays one. A ladder of 2^60 shared paths now
costs at most the budget; level 128/129 and value 10 000/10 001 are the
same boundaries PyFly's own walk draws (600 random contexts compared
with it: no difference).

R-L-T5-cycles: JsonLogic's recursive comparison (===, !==, in) and its
text and number conversions stop at MAX_DATA_DEPTH (1000) levels with a
JsonLogicError (GENERAL), where data that contains itself used to recurse
until PHP ran out of memory; the reference raises RecursionError, GENERAL
too. As in Python, a container compared with itself is equal at once by
identity (PHP objects have one). PythonText writes an object that
contains itself as repr() does ({...}) and gives no text past 1000
levels or for an array holding a reference to itself.
…text and the FeatureFlags service

- FireflyFlagProvider (open-feature/sdk 2.3 AbstractProvider, name `firefly`)
  evaluates the registry's document through FlagdEvaluator and never throws:
  an unreadable document or a failing evaluator is GENERAL with the caller's
  default, logged at DEBUG. resolution() is the Firefly view with metadata;
  its value is a deep copy (ValueCopy) and the SDK's ResolutionDetails hold
  plain arrays, so a caller mutating an object value cannot change the flag.
- The Context port: EvaluationContextContributor, EvaluationContextBuilder,
  EvaluationContextResolver (contributors in order, explicit context and
  targeting key win, a failing contributor skipped) and the #[Component]
  ApplicationEvaluationContextContributor (`application`, `profiles`).
  Date-times pass through unconverted (an immutable one as the equal
  DateTime the SDK's attribute type requires); numeric-looking names and
  other objects are dropped, since the SDK's attribute merge cannot carry
  them. `ambient: false` keeps only the process attributes.
- FeatureFlags evaluates through the `firefly` client (hooks set once, the
  provider re-asserted on the API), each typed getter with its own type.
  details(..., ambient: false) is the management preview: no principal,
  roles or tenant, and the hook hint `firefly.preview` for the telemetry
  hooks to skip.
- FlagEvaluation carries array-key metadata encoded through Json::object()
  and owns a deep copy of its value.
…boolean for a number, expand targeting once per document

Review fixes for the in-process evaluator:

- A Resolution's object or list value was the stored definition's own
  stdClass: changing a returned {} (or a nested one, or a list-like
  {"0": ...}) changed the next answer and the document. Success values
  are now copied in the same faithful form ({} stays a stdClass), on the
  STATIC, DEFAULT and TARGETING_MATCH paths.
- A boolean is never a number (CONTRACT.md): a float request on a
  boolean variant is TYPE_MISMATCH, where flagd-core's Python reads
  float(True). The unchecked paths (DISABLED, DEFAULT without a variant)
  still answer float(default), as flagd-core and PyFly do.
- Each flag's $ref expansion and limit decision is worked out once per
  document and kept in a WeakMap keyed by the (immutable) document, so a
  document's targeting is not re-expanded on every evaluation, another
  document instance never reads a stale expansion, and a released
  document drops its entry.

The conformance folder is re-copied verbatim from the shared contract
(firefly-vectors.json 8986ef44..., evaluate 53 with the two authored
type-mismatch vectors); the evaluate-vector count is no longer hard-coded.
…nd log every dropped context attribute

Review round 1 of the evaluation context:
- An explicit `targetingKey` that is an int (`['targetingKey' => $user->id]`)
  or a Stringable is the targeting key, as text, ambient or in a preview. It
  used to be discarded silently, so the principal's key (or none) decided
  the rollout bucket. Any other type (float, bool, array, object, a failing
  Stringable) is refused with a DEBUG line naming its type.
- Every top-level attribute the SDK cannot carry is dropped with one DEBUG
  line naming it and the reason: a numeric-looking name, an enum, any other
  object, a resource. A Stringable becomes its string. A top-level stdClass
  becomes its members when they form Json's faithful array (non-empty, not
  a list); `{}` and list-like objects, which would read as lists, are
  dropped. Values inside arrays stay as they are, as the docblock explains.
… context, Observability the meter

Telemetry for the `firefly` client (CONTRACT.md "Telemetry and events"):
- FeatureFlagMetrics port + NoOpFeatureFlagMetrics; MetricsHook counts
  feature_flag_evaluations_total{flag, variant, reason}, variant `none`
  without one, a failed evaluation as reason ERROR / variant none.
- ExposureEventHook publishes FeatureFlagEvaluated(key, value, variant,
  reason, errorCode, targetingKey); the value is a deep copy (ValueCopy) of
  the served value, the variant null on ERROR, and a thrown failure carries
  its ThrowableWithResolutionError code (GENERAL otherwise), as the SDK
  answers the caller.
- Both hooks note the outcome in `after`/`error` (a WeakMap keyed by the
  HookContext the SDK passes to every phase) and act once, in `finally`,
  on what the caller got: a hook failing after theirs turns the evaluation
  into ERROR + default, and it is counted and exposed once, as ERROR.
- Neither records anything for a preview (hint firefly.preview, I-3), and
  neither ever throws: open-feature/sdk 2.3 runs `after` hooks unguarded,
  so a failing recorder or listener is logged at DEBUG and the value stays.

The context resolver no longer reads an Eloquent model or a collection
(Arrayable/Jsonable, Stringable as its JSON text) as text: as an attribute
it is dropped, as the targeting key refused, each with a DEBUG line
("pass $user->id"). A UUID-like Stringable still reads as its string.

Shipped packages touched outside packages/feature-flags, and why
(docs/contributing.md FROZEN rule):
- firefly/security (src/FeatureFlags, cache): PrincipalEvaluationContext-
  Contributor fills the evaluation context from the principal — targeting
  key = its name, roles = ROLE_ authorities without the prefix, tenant =
  the attribute firefly.feature-flags.context.tenant-attribute names.
  A #[Component] at #[Order(-100)], guarded by #[ConditionalOnClass] (the
  package is a `suggest`) and both master switches. Security ->
  FeatureFlags only.
- firefly/observability (src/FeatureFlags, ObservabilityAutoConfiguration,
  cache): MeterRegistryFeatureFlagMetrics backs the FeatureFlagMetrics port
  on the MeterRegistry (the cqrsMetrics() seam, #[Order(500)] before the
  feature-flags NoOp), gated on firefly.observability.metrics.enabled and
  firefly.feature-flags.enabled.
Bind the temporary evaluator in Security and Observability boot tests while L1b awaits the real evaluator in T12. Their production packages remain unchanged. Correct the accepted T10 exposure snapshot descriptions.
Andres Contreras added 29 commits October 3, 2026 12:26
# Conflicts:
#	packages/feature-flags/src/FeatureFlagsWiringProvider.php
#	packages/feature-flags/tests/Conformance/MANIFEST.sha256
Extend firefly/actuator EndpointRequest with optional raw JSON so flag definitions retain object and fractional-number shapes.
@ancongui
ancongui merged commit a18a968 into main Oct 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant