Repository navigation
feat: feature flags on OpenFeature — v26.10.1 - #12
Merged
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Shipped packages outside packages/feature-flags (FROZEN review)
Validation and review
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.