v10.4.0 - enforcement points declare what they can discharge; /health capabilities from one registry
Latest[10.4.0] - 2026-09-06 (an enforcement point declares what it can discharge and is refused the obligations it cannot; typed policy authoring in the portal behind a per-organization flag; a /health capability list that cannot go stale)
Scope: operators upgrading from 10.3.0. Nothing in this release reaches a deployment that changes nothing else. The one new refusal needs a new request header AND a new environment variable, and is Enterprise-only, so an operator has to opt in twice to reach it. Two narrowings need no flag and are the two lines to read before upgrading: a custom-policy connector ceiling that was read from an unverified licence and from a deployment mode anything unrecognised read as Enterprise now fails closed to Community, so a deployment relying on either defect finds its connector list capped at the tier it actually holds - measure per-organization counts against
MaxCustomPolicyConnectorsCommunityfirst; and the GCS connector now loadscredentials_file/credentials_jsonas service-account keys specifically, where the deprecated helpers accepted any credential configuration, with the file read eagerly so a bad path fails at construction rather than on first use. FIVE additive migrations, and unlike 10.2.0 a Community deployment applies one (core/170;enterprise/151-154are Enterprise only). Rolling the binaries before applying any of them keeps 10.3.0 behaviour.
Security
-
Two bundle-activation checks had no executing test, and now do (#3690
follow-on). Proving the gate 19 threat model's citations row by row - mutate
the control the row names, require the cited test to go red - separated three
outcomes, and the third has no citation remedy: a control nothing exercises.
DeletingTrustStore.Verify's advertised-digest comparison, or any of its
three provenance comparisons, leftTestSignatureBindsTheBytesTheCompilerReads
and the whole conformance corpus -AXC-009included - green. Neither is
reachable by the signature, for two different reasons:Bundle.Digestis
not insideview(), so a wrong digest over genuine content carries a
perfectly valid signature; and provenance, though signed, is compared against
this evaluator's helper module, compiler and schema, which are genuine on
both sides, so no signature can make that comparison. A bundle is "activated
by digest and rolled back by digest", and ADR-065 bindsPolicyBundleDigest
into the decision proof, so the one recomputation inVerifywas carrying the
whole property unguarded.bundle_activation_integrity_test.gocovers both,
one sub-case per provenance field because the three are independent checks and
a test changing all three at once passes with two of them deleted. The design
half - that the field the system keys on is outside the signed bytes - is
#3700, not changed here. -
The certified signing window is bounded where a verifier can see it
(#3626).MaxEphemeralSigningLifetimewas enforced in exactly one place:
RotationPolicy.Validate, a check on the honest PDP's own configuration, run
by the process about to rotate. Nothing in the certificate and nothing in any
verifier bounded the span, so anything holdingkms:Signon the certification
root - a compromised PDP, a misconfigured deployment, an operator with the IAM
grant, a second signer added later - could mint a certificate for a multi-year
signing key that every verifier in the fleet accepted: valid chain, correct
scope, ordered window. A bound checked only by the generator is an assertion,
not a control.KeyCertificate.validateStructurenow bounds it, and that
function is shared by the signing path, the publication path and every verify
path, so an over-long certificate is unproducible as well as unacceptable. The
verification tail is bounded too, at 24 hours: no verification path reads
signing_not_after, so a private half recovered after the signing window
closed mints proofs the fleet accepts right up tonot_after- a one-hour
signing cap in front of a five-year tail is a one-hour cap on the honest
signer and a five-year one on everybody else. -
The root-generation rotation control now exists (#3627).
IssuerEpochwas
written into the signed bytes, populated from the request and validated for
positivity - and compared against nothing.TrustAnchorcarried no epoch,
CertificateVerifyOptionscarried no epoch, and no comparison existed in the
package, so a certificate signed by root generation 1 and one signed by
generation 2 were indistinguishable to every verifier for exactly as long as
generation 1's public half stayed trusted, which is the whole overlap window a
manual multi-year rotation is built around. Anchors now pin a generation and
verification requires the certificate's to equal it, with a distinct failure
reason so a half-applied rotation is not reported as an unknown root. The
epoch is REQUIRED on a certification anchor and REFUSED on a distribution one:
a certificate binds an issuer epoch and a key-set document does not, so an
epoch on a distribution anchor would be a value no check could read - the same
defect, on the other root. Deployments configure
AXONFLOW_DECISION_PROOF_CERTIFICATION_ROOT_EPOCH; it is required rather than
defaulted, because a default of 1 would be a number nobody chose deciding
whether a superseded generation still verifies. -
The KMS root "never an alias" guard now implements its own stated property
(#3628). The test wasstrings.HasPrefix(cfg.KeyID, "arn:"), which the
fully-qualified alias ARN satisfies. It caughtalias/nameand accepted
arn:aws:kms:...:alias/...- a legitimate ARN thatkms:GetPublicKeyand
kms:Signboth resolve, and precisely the value the guard existed to refuse,
with the guard's own error message naming the harm it was failing to prevent.
The id is copied into every certificate as the issuer, so an alias made the
deployment's certification root repointable by anyone holding
kms:UpdateAlias- an action granted far more widely thankms:Sign, because
on its face it is a naming operation. Two layers now, because a guard is only
as wide as the syntax it matches: the ARN's STRUCTURE is parsed (service
kms, resourcekey/<id>, non-empty region and account), and the id
kms:GetPublicKeyRESOLVED to is compared against what was configured -
which closes the class rather than the spelling, including indirect forms AWS
has not shipped. The three pre-existing checks could never have recovered
this: an alias resolves to a real key with a real usage, a real spec and a
parseable public half. -
A decision-proof key-id report can no longer be filed under another PEP's
identity (#3629). The endpoint tookpep_idfrom the request body and
stored it verbatim, while the credential authenticating the call carried no
identity at all - the internal-service token's covered material is a
compile-time constant plus a timestamp. That secret is fleet-wide by
construction, so every PEP held a credential that let it write a report under
any other PEP's identity: green-lighting a rotation past a peer that had never
fetched the new key, or pinning a complete rotation open by claiming a peer
holds only the old one.platform/shared/serviceauthgained a subject-bound
token - same wire format, signature covering a declared subject, domain
separated so a plain fleet token cannot authenticate as any subject - the
internal-service middleware validates against that subject and records it in
the request context under an unexported key, and the reports endpoint requires
an authenticated subject before it reads the body and requirespep_idto
equal it. Stated plainly rather than implied: the secret is still
fleet-wide, so a holder can mint a subject-bound token for any subject. What
changes is that the identity is a property of the CREDENTIAL rather than of
the payload, so the residual is a credential-distribution problem rather than
a code one; per-PEP credentials are deployment work, and the residual is
recorded with what an attacker gains in
technical-docs/ADR-065-GATE-19-THREAT-MODEL.md. -
ADR-065 gate 19's threat model is written (#3690).
technical-docs/ADR-065-GATE-19-THREAT-MODEL.mdcovers all eight named
threats with, per control, the attacker capability, afile:line, the test
that proves the control executes, and the CI job that runs it. Six
cross-cutting gaps are labelled gaps rather than softened - among them that a
key-set document binds no distribution-root generation, thatTrustStore.Verify
has no bundle-rollback refusal, and thataudit_logscarries no RLS policy. It
also carries the bound decision-proof field census: three signed objects
(Binding33 fields,KeyCertificate14,KeySetDocument7), each covered by
a reflective test that enumerates fields from the struct so a field added later
cannot be silently uncovered. -
A signed field that no verifier reads is now caught by a test (#3690).
Binding coverage answers "can this be changed without breaking a signature?";
it cannot answer "does anything READ it?" - and #3626 and #3627 were both
instances of the second question.TestEverySignedCertificateFieldIsReadOnVerification
re-signs each mutation with the genuine root before presenting it, which takes
the signature out of the question. Eleven ofKeyCertificate's fields are read
on verification; the two that are not (SubjectKeyID,SubjectPublic) are
allow-listed with the site that consumes them, and a second test proves each is
consumed there with the reason code it produces. Completeness is reflective, so
a new field must be read or explicitly exempted rather than assumed fine.
TestTheKeySetEncodingCoversEveryDocumentFieldcloses the matching census gap
on the key-set document, which had no reflective field coverage at all. -
A third scanner-feed CVE, same signature:
github.com/apache/thrift
v0.23.0 -> v0.24.0 for CVE-2026-43871 (HIGH, denial of service via infinite
loop). Indirect ineeonly, reached through the Snowflake connector
(gosnowflake->arrow-go/v18->thrift); both direct dependencies
require only v0.22.0, so the explicit pin is what carries the fix and no
direct dependency moves. No source change;eebuilds and vets clean with
and without theenterprisetag.
Community
-
The realm store's realms/epoch agreement test no longer depends on how fast
the runner is (#3648). Its one CI failure (run 33623146131 attempt 1) was
not the invariant: twelveUpserts finished before the thirdLoadreturned
and the case failed on its own anti-vacuity floor, "only 2 load(s) ran against
the concurrent writer", while every load had agreed. The writer is now paced
by the reader's progress - each write waits for aLoadthat completed after
it started - so every write is sampled on any machine, the two still overlap,
and the floor is structural (reads >= writes) rather than a bet on speed.
The pacer and a deterministic regression test that reproduces the free-running
under-sampling and shows the paced arrangement cannot are in
platform/shared/identity/realm_store_pacing_test.go; theworst:sample now
prints the direction of a disagreement, which discriminates a doubled epoch
bump from a stale epoch read. -
POST /api/v1/access/evaluationdeclares its500and says why its401
is not anAuthZENError(#3637 item 3). The handler's meet-refusal writes a
typedAuthZENErrorwith500, which the spec did not declare, and the401
is the one refusal written by the auth middleware in the platform envelope;
the spec now says both instead of leaving a generated client to find out. -
The three
DEPLOYMENT_MODEnormalisation rules are documented in one place
(#3637 item 7): exact, trim-only and trim-plus-lower-case, each with the site
that uses it and the incident or gate that justified it, in
platform/shared/deploymode, so the next reader picks a rule on purpose. -
TestBackgroundRefreshstops the policy-refresh loop it starts, and proves
it stopped (#3798). The fixture built its engine without a stop channel, so
the loop's 100 ms ticker outlived the test's closed sqlmock handle for the
rest of the package run - 3,812 "sql: database is closed" lines and ~520 s of
the race lane's 600 s budget on one merge-queue entry. The fixture now stops
the loop in its cleanup and waits, from the runtime's own stacks, until no
backgroundRefreshgoroutine remains, so the same leak fails this test in
the unit lane instead of a lane ten minutes later. The refresher's own bound
on a closed handle follows in a separate change. -
ADR-065 gate 17's published budgets are now enforced, not stated
(#3555).platform/decision/pdp/gate17_budgets_test.goturns the budget table
inADR-065-gate17-budgets.mdinto verdicts: the MEMORY rows (bytes per
operation for evaluation, activation and compilation) and the SHAPE rows (the
500-vs-100-policy evaluation ratio and the 100-vs-10 activation ratio, both
measured in one process) fail on every pull request inUnit Tests: Decision Contractsand its community twin. Shape ratios are verdicts only in the named
gate 17 step, where the binary has the runner to itself
(AXONFLOW_GATE17_ALONE=1, pinned by a regression guard): undergo test ./...package parallelism a ratio read 47.7 where the same binary alone read
12.1, so in a package run the shape test skips and says so. Shape ceilings are
2x the hosted alone-run median, tabled in the ADR. The LATENCY rows are
measured and logged beside their budget in those lanes, with the mode printed,
and are verdicts only in the dedicatedgate17-latency-budgets.ymljob. The Go
table and the document's table are asserted equal row for row, so a ceiling
cannot move on one side alone. The benchmark loops were split from their engine
builds so the budget test builds the 500-policy engine once instead of once per
testing.Benchmarkattempt. -
The GCS connector loads credentials by type through
cloud.google.com/go/auth;
the two deprecatedoption.WithCredentials*calls and their//nolintlines
are gone (#3645).google.golang.org/apiv0.264.0 deprecated both helpers
"because of a potential security risk": they accepted ANY credential
configuration without the caller stating what it expected.credentials_file
andcredentials_jsonare documented as service-account keys and are now
loaded as exactly that (credentials.NewCredentialsFromJSONwith
credentials.ServiceAccount, the file read eagerly so a bad path fails at
Connect rather than on the first request); a configuration of another type is
refused at Connect naming the type it found and how to use Application
Default Credentials instead. Precedence is unchanged (file over JSON, now with
a warning when both are set) and a deployment that sets neither still uses
ADC. Unit tests cover every branch without a network; the client the
credentials feed is proven by a new round-trip againstfake-gcs-server
(create bucket, put, get, list, delete, health), whichUnit Tests: Connectors
starts and requires to PASS rather than skip. -
The AuthZEN route and profile header are declared once, in the contract, and
published in the surface artifact (#3603 follow-up).POST /api/v1/access/evaluationandX-Axonflow-AuthZEN-Profilewere hand-written
in the agent handler and in all five SDKs with nothing generating or checking
them, so a rename would have been caught only by whichever SDK happened to
have a live e2e run.contract.AuthZENRouteMethod,AuthZENRoutePathand
AuthZENProfileHeaderare now the one source; the handler derives from them;
cmd/authzen-codegenpublishes them asrouteandprofile_headerin
platform/decision/surface/authzen-surface.jsonso each SDK generates the
path and header it calls; and a new contract test asserts the OpenAPI document
declares the same path, method and header (verified by mutant: a renamed path
and a renamed header each red it). The artifact format version is unchanged
(additive members); SDK emitters that reject unknown members refuse the new
artifact until their own change lands, which is the currency gate doing its
job - the five SDK changes follow in their repositories. -
The gateway pre-check plane reads, validates, binds and counts a capability handshake (#3778). As on the decide and MCP planes, the wire contract is
enterprise_protocoland ships in both editions, because a client is correct against ONE contract rather than one per edition: a Community deployment refuses a malformed declaration, binds the identity to the authenticated credential, and counts the outcome onaxonflow_pep_handshake_total{plane="gateway"}.- A Community deployment behaves exactly as it does today. Nothing is denied that was not denied before: the caller is told
requires_redaction: trueand is expected to act, exactly as now. No operator needs to change anything or hold off an upgrade. - What a Community operator gains is the ability to SEE the gap - a client that has declared it cannot apply a redaction is now visible on the
plane="gateway"counters, on a build that does not act on the declaration itself. - Nothing changes for a caller that presents no handshake. Absent is not empty: a request with no header takes byte-for-byte the path it took before, and is counted under
outcome="absent"because that is the denominator the per-plane cutover reads its adoption ratio off.
- A Community deployment behaves exactly as it does today. Nothing is denied that was not denied before: the caller is told
-
The MCP plane reads, validates, binds and counts a capability handshake
(#3766). The wire contract isenterprise_protocolunder ADR-066 Decision 2
and ships in both editions, because a client is correct against ONE contract
rather than one per edition: a Community deployment refuses a malformed
declaration, binds the identity to the authenticated credential and counts the
outcome onaxonflow_pep_handshake_total{plane="mcp"}exactly as an Enterprise
one does. Only the code that DECIDES on the strength of a declaration is
Enterprise, and it is physically absent from this build by build tag.- Nothing changes for a caller that presents no handshake. Absent is not
empty: a request with no header takes byte-for-byte the path it took before,
and is counted underoutcome="absent"because that count is the denominator
the per-plane cutover (#3564) is read off. This is what keeps every shipped
plugin working unchanged until it ships a declaration of its own. - A Community deployment behaves exactly as it does today. Nothing is
denied that was not denied before and nothing is allowed that was not
allowed before: the masked statement is emitted as it always has been, and
an enforcement point that cannot substitute it fails closed at its own seam
rather than forwarding the original. No operator on a Community build needs
to change anything or hold off an upgrade. - What a Community operator GAINS is the ability to SEE the gap. A client
that declares it cannot substitute a masked statement is now counted on
axonflow_pep_handshake_total{plane="mcp"}, and where a capability gap
exists onaxonflow_pep_capability_refusals_total{plane="mcp"}- so an
operator can find a client that cannot honour a redaction and get it fixed,
on a build that does not act on the declaration itself. That visibility,
not a change in behaviour, is what this delivers to Community.
- Nothing changes for a caller that presents no handshake. Absent is not
-
The custom-policy connector ceiling reads the licence tier through the
verified read; the trust-on-read parser is gone (#3709 row 1, follow-on to
#3713 / #3749).platform/shared/policydecided the ceiling from a base64
decode ofAXONFLOW_LICENSE_KEYthat took the payload'stieron trust: no
signature check, no expiry, and under thecommunityandcommunity-saas
posturesrun.goskipslicense.ValidateWithRetry, so nothing else ever
verified the key either. Measured: a key signed with the literal bytes
NOTASIG!naming tier Enterprise resolved to unlimited connectors, and an
expired key kept its grant indefinitely. Now the agent registers
license.GetCurrentTierintoplatform/shared/policyat init
(platform/agent/license_tier_source.go,policy.SetLicenseTierSource),
extractTierFromLicenseKeyis deleted rather than wrapped, and the read
itself is one implementation for both build tags (license.ReadCurrentTier
inplatform/agent/license/tier_read.go) that counts every refusal on
axonflow_license_tier_read_rejected_total{reason}and logs it once per
reason. A forged, expired, malformed or unknown-tier key resolves to the
Community ceiling; a malformed key no longer falls back to the Evaluation
ceiling of five. With no source registered the package resolves to Community
and says so, onaxonflow_license_tier_source_unregistered_totaland a log
line. The live community-SaaS fleet's key was re-validated through the
repo's own validator (Enterprise, expires 2027-05-21) and lands on unlimited
through the verified read, andMCP_DYNAMIC_POLICIES_CONNECTORSis empty on
every running task, so no running deployment loses a connector.GetCurrentTierin bothlicense.goandtier_support.godelegates to
ReadCurrentTier; its answer is unchanged (any pastExpiresAtis
Community, grace window included). Only the refusal became observable.license.SignPayloadForTestandForgeSignatureForTestjoin
OverridePublicKeysForTestas the licence test seam.- The ee twin,
ee/platform/agent/license, carries the same read: the
shipped enterprise image overlays that directory onto the platform copy
(platform/agent/Dockerfile,EDITION=enterprise), so a change made to
the platform copy alone would not ship.tier_read.go,testing.goand
tier_read_test.goare held identical apart from the ee copy's
//go:build enterpriseline by a second claim class in
license_pair_byte_identity_test.sh;tier_read_enterprise_test.gois
held byte-identical.
-
Realm ids follow the decision contract's qualifier grammar, and
identity.CanonicalFormVersionisidentity/2(#3709 row 3, filed from
#3751).identity.ValidateRealmIDadmitted any printable, colon-free,
whitespace-free rune whilecontract's qualifier regex admits only
[A-Za-z0-9][A-Za-z0-9_.-]*, so an operator who named a realmacme+prod,
eu/central,realm@okta,-leadingorréalmminted principals every
decision proof bound and the PDP could not parse, reachable through
realm_verify.goon every credential it admits. Fixed on the permissive
side:contract.ValidateQualifieris exported, withcontract.QualifierGrammar
as the rule in words, andValidateRealmIDdelegates to it, so there is one
grammar and the refusal names the rule. Therealm-charsetdivergence class
in the lockstep sweep is retired,User::acme+prod:00u1joins the corpus
both parsers must refuse, and a realm-only corpus pins the agreement in both
directions. The one remaining realm divergence is identity's 512-byte bound,
in the safe direction. Every built-in realm id and every realm literal in
this tree is inside the grammar.DBRealmStore.Upsertnow refuses a realm,
or a delegate realm, outside it before any row is written (the column's
CHECKs enforce only colon-free and non-empty), so a non-conforming realm can
no longer be stored, andRealmRegistry.Registerrefuses one at
registration. No plane loads realms from the store yet, so nothing is
"refused at load"; the DB CHECK that would enforce the grammar without a Go
caller is filed on #3709 as its own migration. -
pep.ConfiggainsClientIDandClientVersion, and every engine
round-trip carries anX-Axonflow-Clientheader (#3668; entry added by
#3709 row A).platform/shared/pep/pep.goships in the community build and
the two fields are set by any PEP built on it: when both are non-empty the
client sendsX-Axonflow-Client: <ClientID>/<ClientVersion>on every call to
the engine, and an emptyClientIDsends no header. Until now the only
description of this lived in an### Enterprisesub-bullet about the
agentgateway PEP adapters, which withheld a capability the community edition
has. A community-build agent accepts the header and discards it
(recordClientVersionTelemetryis a no-op there); recording per-client
version telemetry from it is the Enterprise half. -
The connector-entitlement classifier granted Enterprise limits to any
deployment mode that was not spelledcommunity(#3713, child of #3709).
resolveConnectorLimitTierinplatform/shared/policy/dynamic_evaluator.go
testedmode != "community" && mode != ""and returned the Enterprise tier -
unlimited custom-policy connectors - for everything else. That is a question
about the spelling of a string, not about entitlement, and it answered
Enterprise for three populations that have bought nothing:community-saas,
the free shared fleet;evaluation, which reaches the Evaluation ceiling only
through the licence-key path it never took, so the one mode named for the
Evaluation limit was the one mode that could not receive it; and every
UNRECOGNISED value, so a typo inDEPLOYMENT_MODEwas worth a purchased
Enterprise licence.
platform/shared/deploymodenow carries the ENTITLEMENT axis -
IsEnterpriseEntitled/CurrentIsEnterpriseEntitled- alongside the schema
axis it already owned, and the classifier asks it. Everything not entitled by
its deployment mode falls through toAXONFLOW_LICENSE_KEY, which is how the
free Evaluation tier is actually granted. Note precisely what that does and
does not do forevaluation: it makes the ceiling REACHABLE, it does not
grant it by name - that mode with no licence key resolves tocommunity(2),
because a self-asserted mode name must not raise a limit on its own.
It fails CLOSED on an unrecognised mode, which is the OPPOSITE of the
neighbouringAppliesCategory, whose own comment records that it answers
YES for an unrecognised value on purpose: for a schema READ the recoverable
wrong is to try the table and fail, and entitlement has no such symmetry.
SubstitutingAppliesEnterpriseSchema()for the new predicate was measured
rather than argued about - it reds exactly five assertions, all of them
unrecognised-mode rows, because the two axes agree on every recognised mode
and differ only there.
#3738 landed first and named this classifier as the fifth reading it
deliberately left byte-identical, exempting it by name in its posture
census. That exemption is now REMOVED rather than reworded, and it was not a
human who noticed: #3738's own stale-exemption check failed with "either the
site was fixed and this entry should go, or the census stopped seeing it" the
moment the reading disappeared. The entitlement column also joins
TestDeploymentModePartitions rather than forming a second hand-written table
over the same mode list - the argument that test makes for itself.
The axis is DECLARED, not derived from the migration categories, though
the derivation is available, free and total by construction. Deriving it would
mean that the day a mode gains one Enterprise-only TABLE it silently gains
unlimited Enterprise LIMITS, decided by whoever edited the migration selector;
AppliesEnterpriseSchema's own doc already records that the schema and
edition answers disagree on the community-saas fleet, so borrowing one axis to
answer another is a measured defect shape here rather than a hypothetical. The
cost is a second table, and it is paid for by two tests: one requires it to be
TOTAL overcanonicalModes(a mode added to one and not the other fails), and
one pins that the two axes AGREE on every recognised mode today, so the day
they must not, that is where it gets said.
Three tier-to-limit mappings became one, and two of them disagreed.
ValidateCustomPolicyConnectorLimitandEnforceCustomPolicyConnectorLimit
each carried a switch recognising onlyenterprise, soprofessionaland
plus- the lower-cased forms oflicense.TierProfessionaland
license.TierEnterprisePlus- fell into the COMMUNITY default of 2, while the
unreferencedCustomPolicyConnectorLimitForTierread both as unlimited and
license.GetTierLimits, the authority in both build tags, maps
TierProfessional, TierEnterprise, TierEnterprisePlusalike onto
EnterpriseLimits(CustomPolicyConnectors: -1). This was latent while only
communityand unset reached the licence-key path; routing community-saas,
evaluation and unrecognised modes onto it ARMED it, and a paying Professional
deployment whoseDEPLOYMENT_MODEcarried a capital letter would have gone
from unlimited to 2 connectors, truncated SILENTLY. All three now resolve
through onecustomPolicyConnectorLimitForTier; the exported method is a
wrapper over it, so the copies cannot disagree again. Found by R3, not by the
author: a change that NARROWS an entitlement has to enumerate who it narrows.
The live fleet was measured before the code was changed, per running task
definition rather than per organisation -EnabledConnectorshas exactly one
writer,NewDynamicPolicyEvaluatorFromEnvreading
MCP_DYNAMIC_POLICIES_CONNECTORS, so it is process-scoped and there is no
per-organisation unit to count. On 2026-09-04 that variable is EMPTY on every
running task in the account, and is not delivered throughsecretsor
environmentFileseither, solen(EnabledConnectors) > limitis0 > 2and
no tier can truncate anything. Independently, the community-SaaS agent carries
a licence key whose payload names tierEnterprise, so the one deployment
this change moves off the mode-based answer lands back onenterprisethrough
the licence key. The change therefore revokes nothing that is running. The
twoevaluation-mode surfaces outside the fleet were measured too, after R3
raised them -scripts/setup-e2e-testing.shand
.github/workflows/tier-gate-contract.yml- and neither sets
MCP_DYNAMIC_POLICIES_CONNECTORSat all; the only non-empty setter anywhere
in the tree configures ONE connector under an Enterprise-entitled mode.
Do not read "falls through to the licence key" as "is now verified". It is
not, and R3 measured it:extractTierFromLicenseKeytakes the payload's
tieron trust with no signature and no expiry check, and
platform/agent/run.goskipslicense.ValidateWithRetryentirely under both
the community and community-saas postures, so a key signed with the literal
bytesNOTASIG!naming tier Enterprise resolves toenterprisethere. This
is a narrower and more inspectable grant than a mode string; it is not a
cryptographically checked one, and an earlier draft of this entry claimed it
was. The verified surface exists -license.GetCurrentTiervalidates the
signature AND comparesExpiresAt- andplatform/sharedcannot reach it
without importingplatform/agent. Reported on #3709.
The guard against reintroduction is the tests, not the lint, and that was
measured in five plants rather than assumed.scripts/lint-deployment-mode.sh
greps for the literalos.Getenv("DEPLOYMENT_MODE"): it reds a raw-env-read
reintroduction, and is BLIND to the same defect routed through
deploymode.Current(), to the schema-axis substitution, and to an inverted
unrecognised case - all of which the tests catch. The classifier no longer
reads the variable at all, sodynamic_evaluator.gocomes OFF the lint's
allow-list, narrowing it from 7 files to 6 (#3738 landed first and took it
11 -> 7); the plant that reds both
instruments only does so because that entry is gone. The fifth plant is the
one that mattered: R3 found the PR's own HEADLINE unpinned - denying
evaluationthe licence path one frame later than the original defect left
every test and the lint GREEN, because the onlyevaluationrow carried no
licence key, wherecommunityis the right answer for both the fixed and the
broken build. It landed on a cheaper signal. Two assertions now pin it, one on
the tier and one on the CEILING, since a test asserting only the tier string
would still pass if the Evaluation arm of the limit mapping were deleted.
Runtime proof:runtime-e2e/3713_connector_entitlement/, three arms on a
real booted stack with real Ed25519 licences, observed through the MCP tool
surface a user reaches -axonflow_create_tenant_policy's response names the
governed connectors, read from the live evaluator's post-truncation list.
15/15 assertions, floor met; the mutation gate is verified rather than
asserted, the pre-#3713 classifier making arm A exit 1 with 5 of 8 failing.
Five paths in this change do NOT reach the community mirror, and are named
here rather than left to be inferred:runtime-e2e/3713_connector_entitlement/
(three files -runtime-e2e/is stripped wholesale),
.github/workflows/connector-entitlement-e2e.ymland
scripts/e2e/runtime_e2e_suites.tsv(repo tooling;/scripts/*is stripped
whilescripts/lint-deployment-mode.shis re-included by name). All five are
TEST SURFACE for behaviour that ships: the entitlement axis, the classifier,
the one tier mapping, the licence-authority pin and the lint all reach the
mirror, which is why this is filed under Community.
No wire change and no migration; the Community and Evaluation ceilings (2 and
5) are untouched. -
The canonical principal wire form had two independent definitions; they are
now held in lockstep (#3711, umbrella #3709).Type::qualifier:localis
emitted and parsed byidentity.PrincipalIDand, separately, by
contract.IDin the decision module, with the same algorithm and a different
vocabulary:identity.SubjectTypeis a CLOSED set of six andcontract.ID's
type segment is an OPEN regex, soRobot::okta-prod:00u1is a valid
principal to one and a hard error to the other. Only the identity one is
versioned, and that version is bound into every decision proof as
proof.Binding.IdentityCanonicalFormVersion- a guarantee that a change to
the canonical form invalidates outstanding proofs loudly, written against a
form whose other definition it does not version.- The repair is NOT an import, and the reason is structural:
axonflow/platform/decisionis a separate module with a deliberately
minimal dependency set andaxonflow/platformdepends on it, so the
vocabulary cannot be shared as code without inverting that. Instead
contract.ID.Typenow states in its own source that it is deliberately
open, namesidentity.SubjectTypeas the closed vocabulary and
identity.CanonicalFormVersionas the versioned one, and names the guard. - That guard compares the two implementations over one corpus in BOTH
directions - byte-identical rendering, and each parser reading the other's
output back to the same three components - across a subject set that
includes SPIFFE ids and LDAP DNs, which contain the separator character and
are what a naive parser gets wrong. The vocabulary is derived from
SubjectTypes(), so a seventh subject type is covered on the day it lands. - Every divergence is DECLARED with a reason and a disposition, every declared
one must be exercised by the corpus, and an UNDECLARED one is an error. The
first version of this guard asserted the type vocabulary was the only
divergence and passed, because its corpus was drawn from inside the
intersection of the two grammars; a hostile sweep found three more, one in
the dangerous direction -ValidateRealmIDis deliberately permissive about
printable characters whilecontract's qualifier regex is not, so an
operator naming a realmacme+prodmints principals every decision proof
binds and the PDP cannot parse. All three are filed on #3709. - The type vocabulary is still pinned in both directions, so a change that
givescontractits own principal-type vocabulary fails and its author must
reconcile the two definitions. Closing the set stays dispositioned to
v11.0.0: no Go CONSTRUCTOR emits a type outside the six, but the decode
paths (replay records, policy bundles, authoring documents) accept anything
the open regex admits and the published JSON schema blesses it, so closing
it rejects artifacts those paths accept today.
- The repair is NOT an import, and the reason is structural:
-
The two published OpenAPI documents are now compared against the Go types
the platform actually marshals (#3724, umbrella #3709). Nothing held them
together, and a pre-cut audit found the consequences:platform/shared/pep's
Targethad noservermember whileDecisionTargetin
docs/api/agent-api.yamldeclared one anddecision_handler.gofed it to
capability-scoped evaluation and totool_serveron every audit row, so the
BLESSED PEP client was the one client that could not populate it -- #3717
root-caused the same absence from the MCP seam independently and its PR
(#3736) landed the field first; and
docs/api/orchestrator-api.yamldeclared 6 properties on
PolicyEvaluationResultagainst a Go type carrying 17. Among the eleven
missing wasevaluation_error, the only discriminator between could not
govern and a policy said block - a client could not tell a platform
failure from a policy decision.TestThePublishedSchemasMatchTheTypesThePlatformMarshalswalks both
documents from six declared ROOTS and derives everything below them
STRUCTURALLY: the Go side by reflection, nested pairs by following a member
on both sides at once, sopep.Targetis compared againstDecisionTarget- two names sharing no substring - with no naming convention doing the
work. A member is compared when both sides call it a leaf and descended
into when both call it an object; there is no third arm, so a shape the
walker has not met is reported rather than skipped.
- two names sharing no substring - with no naming convention doing the
- BOTH MIRRORS of each Decision API DTO are anchored on the same schema, so
platform/agentandplatform/shared/pepagree with each other by
construction. That is what found the third gap the audit missed: a
DecideResponse.Errormember on the PEP mirror that no server has ever
populated, unreachable becauseDecidedecodes only 200 bodies into that
type and the 200 body has no such field. It is removed: a PEP author
writingif resp.Error != ""got a branch that could never be true while
every real failure came back through theerrorreturn. - The
servermember itself landed on the #3717 lane (#3736), which
root-caused the same absence from the MCP seam and merged first; this PR
carries the guard that makes such an absence REPORTABLE rather than
something a person has to notice twice, in either direction. - The walker also checks CARDINALITY (list versus single value) and reports
every shape it cannot read - a$refthat does not resolve, aoneOfat
either level, the tuple form ofitems, a node declaring bothitemsand
properties, and a cyclicallOforitemschain - each naming the cause
it OBSERVED. Two hostile rounds are why: the first version passed silently
on a document declaringapplied_policiesas a bare string against a Go
[]string, and printed "the document describes this as a leaf" for causes
it had never looked at. A cyclic document first crashed the whole test
binary with a stack overflow and then, under a depth counter, hung instead;
a visited set is the instrument that actually bounds it. A published
document is an input, and an input must not be able to crash or wedge its
reader. - The registry of finding kinds is enforced STATICALLY as well as
dynamically. A dynamic panic alone only fires if an emission site runs, so
a new kind on a condition no document hits shipped untested - measured. A
source scan now requires every declared kind to be registered, and the
control requires every registered kind to have a planted instance. - The same rounds found TWO more in-tree mirrors of the Decision API DTOs, in
examples/integrations/decision-mode-adapterand
.../decision-mode-mcp-adapter. The first carried the same phantomerror
member and it is removed there too; the second already diverges, carrying
expires_atas a string where the other three carry a timestamp. Both are
their own Go modules and neither can be reached by the guard, which is
recorded on #3709. - The walk's population is its anchored graph, not the whole document. A
census across both documents found 88 name-matched schema/struct pairs
carrying roughly 145 divergences; that is reported on #3709 as its own lane
rather than absorbed here.
-
One source of truth for the SDK version floors both
/healthplanes
advertise (#3712).getSDKCompatibility()was a hand-maintained map literal
inplatform/agent/capabilities.goAND inplatform/orchestrator/capabilities.go,
and both are served: a client asking the agent on 8080 which SDK version it
needs got an answer with no structural reason to match the orchestrator's on
8081. Four lines below each literal satgetPluginCompatibility(), which had
already been consolidated ontoplatform/shared/plugincompatfor exactly this
reason after it drifted one-sided in a real train - the SDK maps were left
behind by that fix. Both planes now returnplatform/shared/sdkcompat, so
they cannot disagree; the release-train narrative moved with the values, and
the comment-attribution guards on both planes now read the one file rather
than each reading its own copy. The two guards that stood over the literals
are retired, because both were the instrumentplugincompat's own package doc
records as insufficient: an orchestrator-side test pinning the values beside
the values, and an agent-side test parsing both plane files and comparing the
two literals. Neither can see the shape where both copies agree and both are
stale, which is the shape that actually shipped for the plugin maps. In their
place a walk over every Go file in both modules -go/ast, so it reads the
enterprise build-tag arm too - reports any map literal carrying two or more
entries whose key is an SDK id and whose value looks like a version, outside
sdkcompat. It scores the conforming SUBSET rather than requiring every entry
to conform, because the first version did the latter and review broke it three
ways in minutes:"go": "v8.0.0"among four bare versions hid all five, a
complete five-SDK copy plus one"note"key hid itself, and aconst
identifier was not resolved. All three are how a person would actually write
it - every SDK release tag isvX.Y.Z- and all three are now fixtures. The
detector is proved able to fire against seven synthetic third copies and to
stay silent on four decoys that share one half of the signature, including the
livecheckpoint-servicemap with the same five keys and GitHub repositories
for values. What it does not cover is stated rather than left to be found:
a map built by assignment, a struct or slice table, or a value from another
package are outside a literal scan, and a list of forms to refuse cannot
terminate.
The same values are restated in THREE more places outside Go, and two of the
three had drifted.docs/api/agent-api.yamladvertised a minimum of python
6.0.0 / typescript 5.0.0 / go 5.0.0 / java 5.0.0 against a real floor of 8.0.0
everywhere, and omittedrustfrom its schema description although rust has
been in the map since the 9.7.0 train;docs/api/orchestrator-api.yamlgave a
one-key example with a recommended 9.0.0 against a real 9.2.0. Those are the
copies a CUSTOMER reads - the spec is published, the Go literal is not - so
correcting them was the smaller half: bothexample:blocks in both specs are
now compared againstsdkcompatby a test, and a stale example fails CI. The
third isdocs/COMPATIBILITY_MATRIX.md, which restates the same ten values in
prose; it is accurate today and is NOT guarded by anything, and it is named
here rather than left out, because an under-count published as a complete list
is the same defect this entry is about.
No wire change:/healthserves the same bytes. -
An external enforcement point can finally say what it is and what it can
discharge (#3704, ADR-065).platform/decision/registryhas been the
ADR-065 PEP catalog since it was written -PEPRecord,
SupportsObligation, and aCapabilityStatusthat deliberately keeps "never
told us anything" and "told us it can do nothing" apart. What did not exist
was a way for an SDK client, a plugin or a gateway adapter to enter it: they
identify themselves only throughX-Axonflow-Client, which carries a library
name and no capabilities, so every external enforcement point wasNoRecord
for ever and the only reason nothing refused was that nothing asked.
The gateway adapters can present one, opt-in behindAXONFLOW_PEP_AUDIENCE
(platform/gateway-adapters/config.go, which reaches the mirror, so the knob
exists in both editions). On a Community deployment setting it changes
nothing: the declaration is read, bound and counted, and acted on by
nothing, because the capability deny is physically absent from that build. The
Enterprise entry above is where that knob has an effect, and where to read
before setting it.
A PEP now presentsX-Axonflow-PEP-Handshake- base64url of a small JSON
document naming its profile version, its name inside the caller's credential,
the audience it expects proofs for, and its exact obligation types and
versions. The wire contract, its decoder and validator, the identity binding
and the observability ship in both editions, because a Community PEP must
be able to say what it is.
Nothing changes for a caller that presents nothing. A request with no
header takes byte-for-byte the path it took before, pinned by a fixture
captured from the pre-change tree rather than regenerated from the new code. A
handshake that cannot be READ is refused rather than degraded to "legacy
caller" - degrading it would go on handing an enforcement point obligations it
had just said it cannot discharge.
A PEP may declare what it can DO, never what the platform already knows
about it. There is no edition, realm, tier or licence member on the wire,
and an unknown member is refused at every depth. The identity is a name inside
a namespace the server owns - the platform composes
client:<authenticated credential>:<pep_id>- so naming another credential's
enforcement point is impossible by construction rather than refused by a
comparison, and two enforcement points behind one credential stay
distinguishable. -
fulfillment_capabilitiescan now be SENT as an empty list, and its
meaning is deliberately unchanged (#3704, on top of #2958). The member was
[]stringwithomitempty, which cannot represent present but empty: Go's
encoder omits a nil slice and an empty one alike, so a Go client could not
send[]at all. It is now a pointer on both the client and the server DTO,
so the state is representable and readable.
What did NOT change is what the server makes of it, and that is the
correction worth reading. An earlier version of this change gave[]a new
meaning - "a capability-aware caller whose seam discharges nothing" - and
justified it as strictly additive because the state had been unsendable. That
was true of the Go encoder and false of the wire: the server's decoder
has always accepted"fulfillment_capabilities":[], and any non-Go PEP could
send it (caps or []in Python,JSON.stringify([]), a hand-built curl, a
generated client that never omits an array member). Those bytes have always
read as a legacy caller. Giving them a new meaning would have moved an
UNCHANGED caller from "obligation emitted, the PEP fails closed" to
"obligation suppressed, organization fallback posture" - whose documented
default islog, i.e. allowed without the redaction. That is a widening
of a security control, so it was reverted.[]still reads as a legacy
caller, as does a list of blank strings, and giving either a distinct meaning
is a wire-semantic change that belongs with the field's retirement rather than
with a minor.
No caller's behaviour changes in this release. -
A declared-empty capability set no longer serialises as an absent one
(#3704). Found while converging three copies of the capability comparator onto
one: the shared helper returnednilfor an empty input, so a registered
enforcement point that declares it discharges nothing rendered
"capabilities": nullfromPEPRecord.Profile()- an ABSENT member on the
wire. That is the same collapse as above, one layer down, and precisely what
PEPRecord.clone's own comment forbids. -
docs/api/agent-api.yamldocuments the handshake header and its document, and
documentsfulfillment_capabilitiesfor the first time - the field has been on
the wire since 9.11.0 and was in no published schema. -
No human approval decision has ever appeared in the decisions feed, and now
every one does (#3718, pre-cut audit #3709).WriteHITLAuditEventwrote an
audit_logsrow with fifteen columns and none of them wasdecision_id,
planeorcorrelation_id; the feed selects on exactly that
(decision_id IS NOT NULL OR policy_details->>'decision_id' IS NOT NULL). So
the most consequential decision the platform records - a person confirming or
overriding a policy outcome - was the one class a reviewer could not find
there, and every plane-scoped export dropped it. v9.16.1 (#3243) fixed the
POLICY-ATTRIBUTION half of this class after an OJK export shipped a blank
Policy column; the IDENTITY half was never fixed. Additive, no migration:
all three columns exist (core/119,core/121) and are written by other
planes.- The row carries its OWN decision id,
hitl_<action>_<request_id>, not
the originating one.core/119documentsdecision_idas the "stable
satellite join key" every writer mints fresh, so two rows sharing one would
render a decision twice in the feed and give a satellite join two parents.
The originating decision travels ascorrelation_id- whichcore/121
defines as precisely "a value shared by every decision row of one logical
request" - plus an explicitpolicy_details.originating_decision_id.
Deriving the id from the approval also makes it structurally impossible to be
empty, so an approval with NO originating decision still reaches the feed
rather than being the one shape a fallback would have written NULL for. That
absence is stated affirmatively rather than left as a missing key. correlation_idis now recorded on the queue entry at enqueue time (all
three planes already held the trace id), so the human decision joins the
same chain as the machine decision that asked for it. Written only when
non-empty:''would enter the exporters'correlation_id IS NOT NULL
partial index and group every untraced approval into one bogus chain.audit_logs.planeishitlon these rows - its own plane, becauseplane
answers "which surface decided" and a person deciding through the Article 14
oversight API is not the agent gateway PEP. Verified against a real
PostgreSQL that this does NOT reach the operator's Avg Latency tile: the
writer binds a NULLresponse_time_ms, and the same assertion proves the
predicate still admits an ordinary enforcement row, so the zero is an
exclusion rather than a predicate matching nothing.- Proven as an outcome, not as column presence. Three real-Postgres tests
seed through the production statement and then askqueryDecisionList- the
functionGET /api/v1/decisionscalls - whether the row came back, for an
approval, a rejection and an approval with no originating decision. The
negative control seeds the pre-fix fifteen-column row verbatim and asserts it
is ABSENT while a control row is present, so the positive test cannot be
green because the feed returns everything.
- The row carries its OWN decision id,
-
An EU AI Act accuracy alert could not be acknowledged or resolved through
any API, and acknowledging one would have been a cross-org IDOR (#3721, the
EU AI Act slice of #2884). The spec,euaiact/doc.go,
ee/docs/api/eu-ai-act-api.mdand the handler itself all describe
POST .../alerts/{id}/acknowledgeand/resolve; the gorilla-mux
registration production serves declared neither, so both 404'd, while the one
route it did declare -GET, PUT /alerts/{id}- answered 405 to both of its
own methods because the handler admitted only POST. Four surfaces agreed and
the one that decides did not, and nothing compared them. An operator under
Article 15 obligations could see accuracy alerts and could not record that one
was acknowledged.- Both action routes registered;
GET /alerts/{id}implemented (it had been
registered and documented since the module shipped); thePUTregistration
removed - nothing ever implemented it, and gorilla answers 405 for an
undeclared method either way, so no caller observes a change. - Every by-id alert operation is now org-scoped.
AcknowledgeAlertand
ResolveAlerttook(alertID, userID)and never saw an org, and
GetAlertByIDisWHERE id = $1. That was unexploitable only because the
routes were unreachable - so registering them without this would have shipped
the IDOR the 404 was accidentally covering. Another org's alert is reported
as 404, never 403, so the response is not an existence oracle. - The three "known wiring issue" notes in
ee/docs/api/eu-ai-act-api.mdare
replaced by what the routes now do. #2884's other items (RBAC unmounted, the
RBI PATCH/PUT mismatch, the portal policy-override detail routes) are
untouched and still open.
- Both action routes registered;
-
One tolerant base64 decoder for operator-pasted secrets, in
platform/shared/secretenv(#3710). Reading a base64 secret out of the
environment had three independent implementations that disagreed about what
they accepted. Two lived inplatform/: the audit non-repudiation signing
key inplatform/agent/decision_chain_signing.go, whose own comment
described itself as mirroring the other, the licence signing seed in
platform/agent/license/keygen.go. The third, inee/, accepted only
padded standard base64 and did not trim whitespace at all. They also
disagreed about the error: one returned the standard-encoding decode error
with its byte offset, another a generic sentence naming the four dialects.
secretenv.GetBase64Seedis now the one entry point THOSE THREE use - trim,
then standard, raw-standard, URL-safe or raw-URL-safe, first success wins -
withsecretenv.DecodeBase64Tolerantfor callers that already hold the
string, such as one element of a comma-separated verify-key list. It is not
the only base64-secret decoder in the tree and the entry does not claim to
be: a census of every base64 decode of a secret-shaped input - run against
the BEHAVIOUR, because the original census grepped forRawStdEncodingand
so could only find decoders that were already tolerant - leaves
getPluginClaimPublicKeyin both licence copies (#3727) and
NewCredentialEncryptorFromKeyinplatform/connectors/configstill strict.
That census was BASE64-SCOPED, which is stated so it is not read as an
enumeration of the class: the class is "a secret an operator pastes is
consumed byte-exact", and it has members with no base64 in them. The
shipped counter-example is the HITL webhook HMAC signing key, read bare
withos.Getenvin both copies ofplatform/agent/hitl/webhook.goand
handed straight tohmac.New, where a trailing newline changes the key
bytes and the receiver 401s with no diagnostic. Filed as #3733 with a
published instrument, not fixed here.
The trim is inside the shared function rather than left to each caller,
because a helper whose callers must each remember to trim is the divergence
this replaced.ErrNotSetkeeps "the operator has not set this variable"
distinct from "the value does not decode": the empty string is itself valid
base64, so without that check an unset key reportsexpected 32 bytes, got 0.
What a strict decoder actually rejected, measured rather than assumed:
an UNPADDED value (illegal base64 data at input byte 40for a 32-byte
seed), the URL-safe alphabet (byte 0), and a leading or trailing SPACE or
TAB (byte 44 on a padded seed). A trailing NEWLINE was never one of them -
Go's base64 decoder ignores\rand\nat every position, leading,
interior and trailing - and #3710's premise that it was is corrected here,
in thesecretenvdoc comment and in the runtime-e2e rationale, all three
of which had copied it.
For community deployments the behaviour change is confined to the audit
signing key, which now also accepts the two extra dialects and the
whitespace shapes listed above, and whose unset-means-unsigned mode is
unchanged and pinned by its own test.
The lane that runs the new guard now also STARTS when the guard changes
(round 3).unit-tests-standalone-modulesdiscovers everygo.modunder
platform/andscripts/, butdetect-changes'go-codefilter listed only
platform/**/*.goandee/platform/**/*.go. The lane still fired on this
change, via**/go.modand.github/workflows/test.yml; what did NOT fire it
was an edit to the module's Go source or to its own test - the entire case the
lane exists for.scripts/**/*.gois added to that filter intest.ymland
test-community.yml, verified against realpicomatch(the matcher
dorny/paths-filteruses) in both directions. A census of every
dorny/paths-filterfilter in all ten workflows that carry one found no third
instance:lint.ymlalready matched via**.go, andsecurity.yml's
manifest-only scope is deliberate.
Discovery also now excludestestdata/andvendor/and refuses a module
path containing whitespace: the Go toolchain ignorestestdataby
construction and thisfinddid not, so a fixture module under one of
platform/'s three existingtestdatatrees would have reddened a step that
gates the requiredTest Summaryon every pull request.
And the license package now runs in an ordinary lane. All eighteen files
inee/platform/agent/licenseare//go:build enterprise, so
unit-tests-enterprise's untaggedgo test ./...matched no package there
and exited 0 - a lane that compiled nothing, reading as a pass. The only
tagged runs were in theci:full-gated Real-PG lane. A tagged step over both
module copies is added, which is the same fix #3699 took for
ee/platform/proofcustody. A new
tests/regression-test-required/license_pair_byte_identity_test.shenforces
the byte-identity the twokeygen_seed_paste_test.gocopies assert about
themselves and nothing checked; it matters because the shipped image overlays
the ee copy onto the platform one, so a divergence ships. -
decision-replay: reproduce a recorded decision offline, from normalized
input against pinned bundles, with nothing running (#3689). ADR-065 gate 16
had determinism tests and no artifact. Newplatform/decision/replaypackage
andplatform/decision/cmd/decision-replaycommand, plus a committed fixture
generated from the conformance corpus. What is pinned is deliberately more
than the bundles: the action registry decides admission before any policy
runs, and an enforcement point advertising no capabilities turns a mandatory
obligation into a DENY, so an artifact pinning only bundle digests can
reproduce a decision that happens to be right. Every mismatch REFUSES and
returns no decision - a nearly-matching artifact answering a different
question is worse in an incident than no answer - with four mismatch shapes
detected including the one that would otherwise pass silently, an environment
root the record never pinned. Exit codes separate "you are holding the wrong
artifact" (2) from "the artifacts agree and the answer moved" (3). The tool
imports nothing outside the decision module, so an offline incident tool does
not link the cloud SDKs and database drivers ofaxonflow/platform. -
One capability registry, and a
/healthlist that cannot go stale
(#3590, #3618).getCapabilities()on both planes was a hand-maintained Go
literal, and a hand-maintained list is a census bounded by whoever last
remembered to edit it: four release trains, v10.0 through v10.3, shipped
without a single entry, includingPOST /api/v1/access/evaluation, which
all five SDKs call. Both planes now PROJECT the list from
platform/shared/capability, the one canonical capability registry (ADR-066
decision 1), so there is nothing left in either file to forget to edit.
The served wire is unchanged, byte for byte. The 29 agent entries and 17
orchestrator entries were captured by running the two functions before the
change and are checked in astestdata/health_wire_freeze_*.json; a test
compares the projection against them name,sinceand description at a time,
and a second, independent test holds the invariant the two literals used to
keep by convention - that the orchestrator list is an ordered subset of the
agent's with matchingsincevalues. The one place the two planes genuinely
differ,platform_identity_discovery's description, is reproduced rather
than flattened.
What makes it stay current is not the projection. The registry derives its
candidate set from the tree:go/astparses every non-test Go file under the
declared scan roots and resolves the path argument of everyHandleFunc/
Handle/PathPrefix/Pathcall, following package-local and
cross-package string constants andPathPrefix(...).Subrouter()chains. A
route belonging to no capability fails CI. Parsing rather than grepping is
load-bearing and the measurement says so: 19 registration sites name a
constant instead of a string literal, two of themPOST /api/v1/decideand
POST /api/v1/access/evaluation, so a regex census would report a clean
sweep of a route set missing the platform's two most-called governance
surfaces. Eight sites remain unattributed - a table-driven suffix, and seven
registrations on a router assigned inside a function body, which the scanner
refuses to price rather than assume is the root - and each carries a declared
exemption naming the literal path and the capability that claims it. An
exemption that stops matching anything fails; so does one citing a capability
that does not exist, or one whose named capabilities do not claim the path it
is exempting.
A capability's absence from/healthis now a RECORDED decision: every entry
carries either ahealthblock or ahealth_absent_reason, and neither-nor
is a validation failure. The validator also refuses duplicate ids, a missing
or non-test owner, an unknown edition or classification, a classification
that contradicts the build tag or the mirror disposition, a non-monotonic
score, and any encoding of "not decided yet" -"","unknown"and"tbd"
are all rejected rather than passing as a blank.
A runtime suite proves the projection where the package tests cannot -
runtime-e2e/3618_health_capability_projection/boots the stack and compares
the LIVE 8080 and 8081 responses against the frozen wire, field by field and
in order, then calls the routeauthzen_evaluationnames and requires the
server to answer it. An advertisement nothing serves is the same defect
mirrored, and this census found five capabilities a Community build
advertises and does not serve.
No entitlement behaviour changes: the registry describes the boundary, and
the runtime licence checks and build-tag separation that enforce it are
untouched. -
A process-wide
enforceis refused at boot on the identity axis (#3633).
The decision axis has always refused its process-wide enforce by name at
parse; the identity axis accepted it in ONE unconditioned call, so
AXONFLOW_IDENTITY_COMPAT_MODE=enforcebegan refusing requests at boot with
no shadow phase behind it, no observed denominator and nothing recorded about
why it was safe - across every organization at once, since the
process-wide mode has no per-org dimension, and on divergences that are
per-(organization, path)constants rather than tail events. That path had no
test at all, which is how it survived two releases. The refusal lives in the
BOOT path rather than in the shared parser, deliberately: the parser also
serves the per-organization stored value, and refusing there would have left
no route to enforcement at all.enforceis withdrawn from every deployment
surface that offered it - both CloudFormation templates and the stack-update
dispatch input - because a value the process refuses must not stay advertised:
CloudFormation would accept it at change-set time and the container would then
fail to become healthy mid-rollout. -
Per-organization identity-plane divergences are now countable (#3633).
axonflow_identity_compat_org_divergences_total{component,org,synthetic,divergence}
mirrors the existing per-organization comparison counter - same cap, same
overflow and unattributed buckets - and carries the divergence CLASS rather
than a boolean. It counts divergent records only: counting agreements too
would make "no divergences" mean "thenonechild is the only child", a sum
over an open label set that a new class silently breaks. Read WITH the
comparison counter and never alone, because a CounterVec with no children
exports no series, so an absent divergence series is equally consistent with
"nothing diverged" and "nothing ran". -
The identity axis gains a per-path rollback lever (#3634).
AXONFLOW_IDENTITY_COMPAT_PATHSnarrows which legacy credential paths
evaluate, mirroringAXONFLOW_DECISION_SHADOW_PLANESon the decision axis.
The failure this exists for is one path going wrong for everyone - a fleet
asserting only an email ontrusted_header, an IdP whose JWKS endpoint starts
timing out onoidc- where the only previous remedies were lowering an
organization, which loses its other three paths, or lowering the deployment,
which loses the window entirely. Neither is proportionate to "one credential
path is noisy", and both discard measurements that were fine. A path omitted
from the list takes the identical early return anoffmode takes - no
clock read, no registry touch, no recorder call - because "off for this path"
must mean exactly what "off" means, or the lever is a third posture with its
own behaviour to reason about, and an operator reaching for it during an
incident would be trying something new at the worst moment. It is checked
ABOVE the mode read, so a path an operator has switched off cannot fail on the
per-organization settings lookup it was excluded from. An unrecognised path
is fatal at boot: case and surrounding whitespace are normalised, but a name
matching nothing refuses to start rather than being dropped, because a list
that silently omitted an entry would measure fewer paths than its author
reads. That is deliberately stricter thanAXONFLOW_IDENTITY_COMPAT_MODE,
which acceptsfalse,0anddisabledas spellings ofoff- a mode is a
posture written by hand, where synonyms are kind, and a path list is a set of
identifiers, where they are how a typo becomes a silent narrowing. Unset and
whitespace-only mean every path, which is the only complete window and the
state of every existing deployment; a value that is separators alone (an
unexpanded or empty-expanded variable) is refused, because that would evaluate
nothing while reading as configured. A trailing comma on a real list is fine -
hs256,keeps that path.
It is reachable everywhere the mode is: both services indocker-compose.yml,
docker-compose.enterprise.ymlanddocker-compose.scaled.yml, and as the
IdentityCompatPathsCloudFormation parameter on both the community-SaaS and
marketplace templates, wired throughupdate-stack.ymlincluding the
UsePreviousValue list - a parameter absent from that list is reset on every
update, which is how a lever set during an incident would quietly revert. Its
AllowedPatternenumerates the four declared path names verbatim from
identity.legacyPaths, so CFN rejects a typo at change-set time rather than
ECS discovering it as a container that never becomes healthy. A lever wired
only into compose would have been the same dead configuration this change is
itself about. -
One reader of the compat environment, in one place (#3634).
identity.EnvCompatConfigis now the singleos.Getenvsite for
AXONFLOW_IDENTITY_COMPAT_MODE,_ENFORCE_REASONSand_PATHSanywhere
underplatform/, and a census test fails on a second one wherever a future
author puts it. The defect it closes was not that the orchestrator forgot a
line: it was that two call sites each maintained their own copy of the
variable list, so adding a variable required remembering both - and the
remembering failed silently, with the lever declared in compose for both
services, documented for both, and read by one. Nothing failed; the
orchestrator simply kept evaluating every path. The agent's enforce-precondition
handler, which had grown its own read of the enforce-reason set, now goes
through the same reader. -
The OpenAPI lockstep check now fails when it cannot find its input, instead
of reporting a pass (#3639).TestTemplatesMatchAgentAPISpecholds the
registered path templates againstdocs/api/agent-api.yamlin both
directions, and both the locate and the read weret.Skips: a moved, renamed
or unreadable document made the check report PASS having compared nothing,
and on a green board a skipped test and a passing one are indistinguishable.
The same function already treated an empty PARSE as fatal one step later, so
the vacuity question had been asked and answered differently for two adjacent
failure modes. The skip's stated reason - that the package might be vendored
into a Lambda without the document - was checked rather than argued with: no
go.modin this repository referencespath_template, and its only importer
isplatform/agentin the same module. The failure now names every path
searched, so it is actionable rather than a puzzle. No behaviour change: the
document this guards is a published contract, and the guard was the only
thing that had stopped working. -
The AuthZEN contract's Go struct field TYPES are now compared against the
published contract; before, nothing compared them (#3641). The drift guard
held Go and the JSON Schema together on field NAMES and REQUIRED-NESS only, so
a Go field could declare a type the contract contradicts and no test noticed.
That is not hypothetical: during the review of #3632, changing
authzen_error.request_idfromstringtointegerin the schema and
regenerating the artifact leftRequestID stringin Go, and all nine
platform/decisionpackages stayed green - the server and the artifact every
SDK generates from described different wires. #3632 pinned the artifact's
types to the profile constant, which protects the SDKs; this pins the Go
structs, which is what the server actually serialises. It covers the
request-side shapes as well as the response-side ones, so the same hole is not
left open on the other half of the surface. The comparison reads the type off
the generated surface artifact rather than re-deriving it from JSON Schema -
a second reducer in the test suite would be free to disagree with the real
one - and the artifact's currency is asserted against the embedded schema's
digest, so the comparison cannot quietly run against a document that has
stopped describing the contract. Test-only; no shipped behaviour changes. -
Saying "these approvals must be separated" and being read as the opposite
is no longer possible (#3630).ApprovalRequirement.SeparationOfDutiesis
set from exactly one place - a string-typed obligation parameter, read as
o.Params["separation_of_duties"] == "true"- so every spelling that was not
exactly that (TRUE,1,yes, a trailing space, a typo) read asfalse:
"no separation required", the permissive answer for a control whose whole
purpose is to be restrictive. A carried value must now be one of the two
declared boolean spellings, refused at the same boundary thedelivery
guarantee already is, and read back through a two-value parse rather than a
bare comparison. The parameter is also refused on an obligation family that
declares no separation of duties, where nothing would read it - a second,
smaller widening, named here rather than left to be discovered. An ABSENT
parameter is unchanged and still meansfalse: that is the authored default,
and refusing it would refuse every approval policy written to date. -
The authoring boundary now validates the document an author SENT, not the
one this build re-renders from it (#3630).authoring.Parsechecked the
published JSON Schema against a re-rendered document: the bytes went through
the decoder into a Go value and back out, so a member the author OMITTED came
back at its Go zero value and satisfiedrequiredon the way past. The
measured consequence, on the member that matters: a policy document whose
obligation omittedmandatorywas ACCEPTED and stored as advisory, with the
author's own document saying nothing of the kind - the authoring-plane form of
the decision-contract defect above, on the same member. (Most other required
members were already refused, by nestedminLength,enumandminimum
constraints the re-render cannot satisfy;mandatoryis a boolean whose zero
value serialises, which is exactly why it slipped through.) The raw bytes are
now validated too, and both checks are kept, because they answer different
questions: the raw one says the author supplied what the schema requires, the
rendered one says this build can round-trip the document without loss. -
The decision contract's Go decoder now refuses a document that omits a
required boolean, instead of reading it as the permissive value (#3630).
obligation.mandatoryandapproval_requirement.separation_of_dutieshave
been declaredrequiredbycontract-2026-08-29.schema.jsonsince it was
written, and the Go decoder could not see either:encoding/jsoncannot tell
an omitted member fromfalse, andDisallowUnknownFieldsonly catches
members that are EXTRA. Absence read as ADVISORY for the first - every
combining site treats advisory as "record it and proceed" - and as "no
separation required" for the second. Presence is now resolved once at the
decode boundary and refused by the shape's ownValidatewith a typed error
naming the member's JSON Pointer (/obligations/1/mandatory), so the
combining sites keep a plainbooland gain no nil checks. A member present
asnullis refused too, and the refusal says so rather than claiming the
document omitted a member it carries. A DUPLICATED member is refused as well:
encoding/jsonsilently keeps the last occurrence, so
{"mandatory":true,"mandatory":false}read as advisory - a document stating
the obligation is mandatory, read as the opposite, and a different request
again to any layer that read the first occurrence.
Scope, stated plainly for all three items here: nothing that succeeds today
begins to fail. These are latent guards on a contract boundary, not changes
to a live request path, and this is a v11-readiness minor precisely so they
land before that boundary is load-bearing. Measured, not assumed: no binary
this release ships decodes those shapes -authoring.Parseis the only
decoder in the tree that reads a document carrying obligations, and it is in
neither the agent's nor the orchestrator's dependency closure - and no
obligation producer in the tree emits aseparation_of_dutiesparameter,
because each writes a fixed key set that does not include it. An exhaustive
sweep of every committed policy pack, fixture, corpus, migration, ADR example
and SDK-facing document found four occurrences of that parameter, all spelled
"true"and all on an approval obligation, so no committed artifact changes
behaviour. What changes is that the boundary the ADR-065 policy decision point
will decode through, and the parameter an authored ADR-065 obligation will
carry, are correct before anything arrives on them. -
A malformed obligation can no longer have its own refusal dropped as an
advisory contribution (#3630).ComposeObligationssplit the set on
MandatoryBEFORE validating, so an obligation missing that member sorted
itself into the advisory bucket and the refusal of it was then reclassified
as "the advisory contribution does not compose" and discarded, with the
request allowed to proceed. An obligation whose advisory-ness is unknown is
now refused before the split, with reasonschema_violation. The guard is
deliberately narrow: an advisory obligation that is invalid for any OTHER
reason is still dropped and recorded, because the advisory-drop rule is a
deliberate design decision - a detector's contribution must not be able to
refuse a request - and widening it would have turned permits into denials on
any deployment carrying a bundle compiled before the rule that refuses one.
No schema change and no artifact regeneration in any of the above: these
close divergences in which the server was more permissive than its own
published contract, so the response-surface ratchet, the generated surface
artifact and the five SDKs' vendored copies are untouched, and nothing this
platform emits is affected. The class is now swept from the schema rather
than by hand: all 11 required boolean/integer members the contract
declares carry a recorded ruling that a committed test asserts - 2 newly
refused, 3 already refused because their zero value is invalid
(obligation.schema_version,approval_clause.quorum,
tool_call.registry_version), and 6 admitted with written reasons
(snapshot's four epochs andattribute.source_version, whose absence
mis-states replay metadata rather than widening access, routed to #3661; and
authzen_response.decision, which this package produces and never decodes,
whose absence reads asfalse- deny, the fail-closed direction - and which
the response schema's own conditional already pins against the operational
state in both directions). -
The AuthZEN response context is produced by ONE function, and the approval
challenge it advertised but never sent is settled (#3631). Two
hand-maintained renderings built this payload:Decision.ToAuthZEN, which the
conformance cases exercised and no serving path called, and a struct literal
inside the route handler, which every served response came from and no test
compared to the other. They had already drifted on exactly one member - the
handler never setapproval- so every response
POST /api/v1/access/evaluationhas returned since v10.3.0 omitted the
approval challenge that both theauthzen_evaluationcapability entry and
docs/api/agent-api.yamlpromised. A PEP that read the capability list,
negotiated the profile specifically to receive the challenge and got a context
with noapprovalmember had no error and no signal: an absentapprovalis
indistinguishable from "this decision requires no approval".
The ruling: the challenge is NOT carried on this surface, and both
advertisements now say so. The route is an adapter over
POST /api/v1/decide, whose response names no eligible approver set, no
quorum and no challenge expiry - so there is no requirement to render, and
synthesising one would hand an enforcement point a fabricated approval policy
to enforce, which is worse than the omission. A decision awaiting approval
still arrives asstate: CHALLENGEwithdecision: false; act on it by
holding, and obtain the challenge from the human-approval surfaces. Surfacing
a real requirement would need the evaluator to carry it on/api/v1/decide,
a wire change to the route this surface exists in order not to change. The
approvalmember stays DECLARED indocs/api/agent-api.yamlwith its
$refunchanged, and the RESERVED note sits on the parent schema's
description - adescriptionbeside a$refis ignored under OpenAPI 3.0.3,
and wrapping the$refin anallOfto carry one is a generator-visible
shape change rather than a comment. So a generated client does not change
shape the day the member starts being sent, and a decoder written against the
profile stays complete. -
A CHALLENGE now carries its obligations onto the AuthZEN wire, where the
serving path previously dropped them (#3631). The gate deciding whether a
decision carries instructions lived at one of the two renderings and was
stated there asstate == ALLOW- narrower than the contract's own rule,
which is that a permit carries them and a challenge IS a permit with an
approval outstanding. It is reachable through a plural envelope, where
obligations accumulate across entries while the meet takes the worst state.
The gate now lives in the shared producer, derived from the authorization
mapping rather than restated, so it cannot come apart from it. A denied or
errored decision still carries none. No PEP behaviour changes on a challenge -
decisionisfalseeither way - but the caller is now told what it will
have to discharge once approval is granted.docs/api/agent-api.yaml
described the OPPOSITE ("carried only on an executable decision") and is
corrected in the same change; the contract's own validator has always
permitted it, since a challenge is a permit with an approval outstanding.
Reachable on Enterprise builds only: the two producers of a
needs_approvalverdict are both gated on non-community mode, so a Community
deployment cannot reach the state at all and sees no change. -
The platform heartbeat can finally say which build, configured which way,
produced it (#3660, v10.4.0 lane 2). Two additive dimensions on the
anonymous startup ping:edition(community|enterprise, read from the
binary's own build tag, which is the one signalDEPLOYMENT_MODE, the licence
tier and the applied schema cannot supply - the Community-SaaS fleet runs the
enterprise build against the community-saas schema) and
platform_deployment_mode(the deployment's ownDEPLOYMENT_MODE, canonical
spelling, aliases folded soenterpriseandin-vpc-enterpriseare one
population rather than two rows). The pre-existingdeployment_modefield is
UNCHANGED: it stays the coarse topology (self_hosted|community_saas),
so no dashboard or classifier rule moves./healthgains the same two members on the agent AND the orchestrator,
besidetierandversion, from one implementation so the two planes
cannot drift. This is what lets an SDK relay them with no new request -
the heartbeat already fetches/health. Discoverable as the
platform_identity_discoverycapability. A member the platform cannot
determine is omitted, never emitted empty: an unsetDEPLOYMENT_MODE
drops the key rather than reporting thecommunityschema default, because
the runtime posture for an unset value is not community (#3128) and
publishing that default would be a claim the deployment itself disagrees
with.- The orchestrator now sends
org_id, at parity with the agent. It never
did, so the receiver's primary internal-vs-external classification rules
could not fire on an orchestrator row and AxonFlow-operated orchestrators
were held internal only by a legacy heuristic already documented as
retiring. On the day it retires our own fleet would have reclassified as
customer adoption. - A Community-SaaS stack now emits instead of skipping emission entirely.
The emitter-side skip made the platform table's deployment-mode column
single-valued by construction - the one value it could hold was the one
value it was never asked to report. Those stacks are classified internal at
the RECEIVER, from theiraxonflow-org_id. Suppressing at the emitter
destroyed the datum; classifying at the receiver keeps it and labels it. - One emitter, not three.
platform/shared/heartbeatis now the single
implementation the agent, the orchestrator and (next) the gateway adapters
share; the agent and orchestrator copies had already drifted, which is how
the missingorg_idsurvived. The CI auto-suppress, the 7-day per-binary
rate limit, the stamp-on-delivery contract, theAXONFLOW_TELEMETRY=off
opt-out and the payload-disclosure line are unchanged and now have one home. - No new data about a customer. The two fields are a build tag and an
operator-chosen mode string from a closed set. No hostnames, URLs, tenant
names, user identities, or hashes of any of those. Both are adoption
analytics only and must never gate entitlement: the value an SDK relays is
controlled entirely by whoever operates the endpoint it was pointed at. docs/TELEMETRY.mdanddocs/TELEMETRY_CONTRACT.mdare brought current.
They were materially stale: "All 4 SDKs" (there are five), a
deployment_modeexample the v1 schema had removed, a configuration opt-out
deleted in SDK v8.0, and a 90-day retention claim when the checkpoint
table's TTL has been 180 days since v6.2.0. The vocabulary tables are now
pinned to the Go source by a test, so this class of drift fails the build.- Operator action: none. Nothing new is required, no variable changes
meaning, and no existing field changes value - including the
orchestrator'slicense_tier, which stays OMITTED. That binding cannot
determine a tier at all, which is "not reported"; an interim revision of
the shared emitter folded an empty tier intounknown("reports the
dimension, had not resolved it"), which would have changed a shipped
emitter's field while this line claimed nothing did. heartbeat.Configgains anOrgIDfield, and the environment fallback
is unchanged: a caller that leaves it empty still readsORG_ID, so every
existing binding behaves exactly as before. It exists because a fallback
cannot guess the variable a caller happens to use - a binary whose
configuration surface is prefixed reads its own spelling, sends noorg_id
at all, and is then counted as an external deployment rather than an
internal one. If you embed this emitter and read your org from anywhere
other thanORG_ID, setConfig.OrgID; absence is still reported as
absent and is never defaulted.
-
core/170makes the application grants on every FORCE-RLS table it can name
explicit (#3636). Stated as what it is: defence in depth, not a closed
gap. Rendered from a freshin-vpc-enterprisechain by reading
has_table_privilegerather than reading the migration sources: 125 tables,
26 underFORCE ROW LEVEL SECURITY, and zero lacking
axonflow_app_role's SELECT or INSERT -core/098grantsON ALL TABLESfor
everything present when it runs andALTER DEFAULT PRIVILEGESfor everything
the same owner creates afterwards, so on the single-owner path every table is
already reachable. What it guards against is a deployment whose migration
credential changed between releases, leaving later tables owned by a role
those default privileges do not follow; that is an operations fact about a
live fleet, not a repository one, and it can be neither confirmed nor ruled
out from here. It is cheap insurance: the privilege set is exactly what
core/098declares as the default, so it restores the intended baseline where
it is missing and is a no-op everywhere else. The table list is rendered,
not grepped, which matters because the grep-derived list an earlier draft
used was wrong in both directions - it includedrole_assignments, which the
live chain does not force, and omittedcompliance_report_jobsand
sso_sessions, which it does. The core/Enterprise split is by what FORCES a
table, not by what creates it, which is the correction a mirror simulation
forced:agent_heartbeats,node_violationsandcustomersare created by
Enterprise migrations but forced bycore/107andcore/108, files the
community mirror carries - and the census that reads those files runs in that
tree too, wheremigrations/enterprisedoes not exist, so a grant written in
the Enterprise sibling could never satisfy it.to_regclassmakes each
statement inert where the table is absent. A new migration rather than edits,
because the runner skips an applied migration before it reads the file, so an
edit would reach fresh installs only. Its rollback is a deliberate no-op.
AGRANTis not reference-counted -core/098'sON ALL TABLESand the
backfill's per-table grant are the same privilege - so aREVOKEon rollback
would remove the accesscore/098created rather than the access the
migration added, leaving the application role unable to readorganizations,
tenants,connectorsand the rest on every healthy stack. Rolling back an
idempotent re-statement of an existing privilege correctly means doing
nothing, and the migration says so rather than being silently empty. -
The published AuthZEN contract now declares the bulk entry bound the
server has enforced since v10.3.0:maxItems: 64on the bulk envelope's
evaluationsarray, in the canonical JSON Schema (artifact regenerated)
and indocs/api/agent-api.yaml. Server behaviour is unchanged - v10.3.0
already refuses above 64 with a typed 413 - this closes the gap where the
contract omitted a bound the wire enforces. A cross-pin test now holds the
schema'smaxItemsand the server constant equal in both directions, and
the runtime-e2e suite asserts the declared bound beside the observed 413.
The SDKs' vendored surface copies pick this up on their next sync train;
their generators do not yet emit client-sidemax_itemsvalidation, so
client-side pre-flight of the bound lands with that train. -
The agentgateway/Envoy PEP adapters are now source-available under BSL 1.1
and carried by the community mirror (#3657 item 10, operator decision
2026-09-03). They move fromee/platform/agent/gateway_adaptersto
platform/gateway-adapters, and from theaxonflow/eeGo module into the
root module — so the import path isaxonflow/platform/gateway-adaptersand
noeereplace directive is involved. A community build compiles and tests
them; the Decide surface they call (POST /api/v1/decide) was already in the
community tree.- Tier entitlement is unchanged and stays platform-side at runtime. The
adapters authenticate to the PDP with a licence key; they do not validate
one and enforce no tier of their own. gatewayadapters.Editionis nowedition.Currentrather than a pinned
enterprise. It was pinned on the argument that the component shipped in
one edition only, which this move ended: a pinned constant would have made
every community deployment reportedition=enterpriseon its telemetry
ping - silently, with no error and nothing in a log, inflating the paid
share of the adoption split. runtime-e2e/2886 now asserts the reported
edition off the WIRE, from a captured ping, rather than from the constant.- One new dependency reaches the community module,
envoyproxy/go-control-plane/envoy. grpc, protobuf and prometheus were
already there at identical versions in both modules, so the move bumps
nothing for existing consumers. platform/gateway-adaptersrather thanplatform/agent/gateway_adapters
deliberately:platform/agent/**appears in eleven workflow path filters,
eight of them stack-booting suites, so nesting the adapters there would
have fired eight unrelated stack boots on every adapter change. Measured
before and after - an adapters change selected ten workflows and now
selects four, with both of the adapters' own suites still among them.- Runtime proof does not travel.
runtime-e2e/is excluded from the
mirror wholesale, so the community repository gets the component without
its suites. Stated in both READMEs; the community build is compiled and
unit-tested by the mirror simulation, which now carries a positive control
asserting these files actually reached the mirror and a negative control
asserting the enterprise edition half did not. A community-side executor is
a recorded post-train item, not a silent absence.
- Tier entitlement is unchanged and stays platform-side at runtime. The
-
The HITL approval-queue chokepoint guarded creation and left every state
transition unguarded (#3714, pre-cut audit #3709). #3408 made the count of
authoredINSERT INTO hitl_approval_queueexactly one, because two duplicated
CREATE writers had diverged unnoticed for seven months. It left the
TRANSITIONS alone, and they are the dangerous half: a creation that diverges
produces a malformed row you can see, while a transition that diverges
approves, rejects or expires on one path and not the other - the compliance
record and the enforcement decision disagree and nothing looks wrong.- The census in the code was short.
writer.gosaid SIX authored
UPDATE hitl_approval_queueand named five; there were EIGHT - Override,
ExpireStaleReturningandConsumeGrantduplicated across the
platform/↔ee/twin pair, plusUpdateStatusSQLand the orchestrator's
expireEvalApprovals. The comment was written beforeConsumeGrantarrived
(#3509) and nothing updated it, which is the argument for a guard derived
from the table rather than from a list, made by the guard's own file. - The twin pair is collapsed, not kept in sync. All three statements moved
intoplatform/agent/hitl/queue, which carries no build tag and is not
overlaid by the Dockerfile, so each exists ONCE for both copies. The six
bodies were verified byte-identical before the move. - The lint is now derived from the table name, matching
INSERT INTO,
MERGE INTO,COPY,UPDATE,DELETE FROMandTRUNCATEagainst
hitl_approval_queue. Its self-test grows to 44 assertions including a
planted UPDATE in a new file, a planted DELETE and TRUNCATE, and negative
pins that a plain SELECT, anUPDATE hitl_approval_history,
hitl_approval_queue_archiveandUPDATE other_table FROM hitl_approval_queueare NOT flagged. Measured: the previous INSERT-only
lint printed "choke point intact" and exited 0 against a tree containing all
eight UPDATE statements. - The widening surfaced two writers nobody had recorded, allow-listed by name
and count with a justification rather than by a directory exclusion: the body
ofexpire_hitl_requests()inmigrations/core/025(the schema's own expiry
function - it cannot live in a Go package and bypasses no gate), and one
inert decoy fixture that exists to be analysed by another guard. - The ConsumeGrant twin census is replaced by a clause census in the shared
package that reads the COMPILED CONSTANT rather than file text, carries no
build tag - so it now runs on the community mirror too - and gains six
one-sided fail-closed mutants.
- The census in the code was short.
-
The Evaluation-tier approval auto-expiry has done nothing on every app-role
deployment since v9 (#3520 item 2, fixed with #3714).expireEvalApprovals
ran a cross-tenantUPDATEon whatever pool it was handed; under
AXONFLOW_DB_USE_APP_ROLE=truethat isaxonflow_app_role, and with FORCE
RLS and no org GUC a cross-tenant UPDATE matches zero rows and reports
success - the #3048 shape. Measured against a real PostgreSQL: as
axonflow_app_rolethe old statement updated 0 of 6 expired approvals and
returned NO ERROR; on the cross-tenant pool the new one expires all 6.- OPERATOR NOTE, first run. Turning this on finds every approval that has
sat falselypendingsince the deployment moved to app_role, and each one
aborts its workflow. A tick is bounded at 200 rows (ORDER BY expires_at ASC,FOR UPDATE SKIP LOCKED), so a backlog drains over successive
five-minute ticks rather than in one transaction, and the log line says
BATCH FULLwhen a tick hits the bound so it reads as a backlog draining and
not as a steady-state rate. - The sweeper does not start without a cross-tenant pool and says so
loudly, rather than falling back to the app-role pool: a fallback there is a
sweeper that runs every five minutes, does nothing, and looks healthy, which
is the state being fixed. With app-role OFF the main pool is the owner and is
cross-tenant, so owner-pool deployments see no change. - #3520 stays open. Item 1 (
hitl.Servicefails OPEN on a nil tier
provider whilequeue.Enqueuerfails CLOSED) and item 3 (/hitl/statsand
hitl_pending_summarycountingwcp_step_gatemirrors, which needs a
migration) are untouched.
- OPERATOR NOTE, first run. Turning this on finds every approval that has
-
A
trapon INT or TERM whose handler does not exit returns to the script
body (#3715, pre-cut audit #3709), so on Ctrl-C the script carries on against
the state it just tore down and cleanup runs twice. Four sites fixed, the worst
beingscripts/operators/provision-app-role.sh- an operator script that
provisions a database role. Measured with a reduced case:
trap cleanup EXIT INT TERMexits 0 with the body CONTINUING after SIGINT;
trap cleanup EXITplustrap 'cleanup; exit 130' INTand
trap 'cleanup; exit 143' TERMexits 130 and never reaches the body.- New
scripts/lint-trap-handlers-exit.shflags the shape, distinguishing a
handler that exits from one that does not rather than matchingtrapat all;
it resolves a bare handler name to that function's own body, so a sibling
function'sexitcannot cover for it. The three already-correct sites in the
tree are its positive control on day one. Runs as its own job held to
successbyLint Summary. 21 self-test assertions, both directions,
including a pin on the one evasion left open. - The lint is re-included in the community mirror sync in the same change.
/scripts/*is excluded from the mirror wholesale with per-file exceptions,
andlint.ymlitself syncs - so a lint job whose script is not re-included
calls a 404 on the public repo and reddensLint Summary, which is a
REQUIRED check there.tests/regression-test-required/lint_scripts_sync_to_mirror_test.sh
enforces exactly that and was verified to exit 1 without the re-include.
Of the eight files this PR touches underscripts/,runtime-e2e/and
.gitattributes, exactly FOUR reach the mirror (provision-app-role.shvia
its own per-file include, the two mirrored lint guards, and.gitattributes)
and four do not (legacy-policy-capture.sh,probe-boot-log_test.sh,
lint-runtime-e2e-executors.sh, and the 3660 suite —runtime-e2e/is
excluded wholesale). Named here rather than left for the heading to imply.
- New
-
Two append-only files serialised every parallel lane, and the failure mode
was silent (#3722, pre-cut audit #3709). Every pair of concurrent branches
conflicts onscripts/e2e/runtime_e2e_suites.tsvby construction, and a
conflicted PR isDIRTY- it runs NO CI at all, so its check list is EMPTY
rather than red and is indistinguishable from a clean board under any
readiness check that scores "nothing failing and nothing pending".
.gitattributesnow declares that filemerge=union, which removes the class
permanently for the one file whose correct resolution is always "keep both
rows". Deliberately NOT applied toCHANGELOG.md, where union glues a
section's closing line onto the following bullet.- Union trades a loud conflict for a silent malformed row unless something
counts fields, so the ledger loader now asserts exactly four
tab-separated fields per row. Measured: appending a fifth field to a real row
left the previous loader at rc=0 with the note silently truncated, because
cut -f4returns the fourth of however many there are. The loader's existing
duplicate-suite check already covers the other union shape (two copies of one
row) and reds on it..gitattributesreaches the community mirror; the
ledger and its linter do not (/scripts/*andruntime-e2e/are both
excluded), so the union attribute is the only half of this that a community
checkout sees - which is correct, since it is the only half that is about
merging rather than about this repository's suite bookkeeping.
- Union trades a loud conflict for a silent malformed row unless something
-
Four readings of "is this deployment community", none in the package that
exists to own it (#3713, child of #3709). #3713's own title says "three of
them byte-identical copies"; it is TWO, and the correction matters because
the third is what makes this a test rather than a lint (see below).
platform/shared/deploymodeheld the SCHEMA half of whatDEPLOYMENT_MODE
means and said in its own doc that it did not decide the RUNTIME posture. So
the posture half had no home. Two sites wrote
os.Getenv("DEPLOYMENT_MODE") == "community"byte-identically
(platform/agent/run.go,platform/orchestrator/run.go); a third,
platform/shared/corspolicy, spelled it through local constants
(os.Getenv(deploymentModeEnv) == communityMode), which is precisely why
scripts/lint-deployment-mode.sh- which greps for the literal - never saw
it and never allow-listed it; a fourth,dev_token_handler.go,
normalises on purpose. The same comparison against"community-saas"
appeared a further three times, once inside
platform/orchestrator/llm/bootstrap.gorather than in either helper.
deploymodenow exportsIsCommunityPosture/IsCommunitySaasPostureand
theirCurrent*forms, carrying the contract those copies stated in prose:
the accepting set is EXACTLY the canonical token, not trimmed, not
case-folded, not resolved through an alias, because every widening of it
disables authentication (#3096). Behaviour is unchanged at all six sites.
The two axes now live in one package precisely BECAUSE they disagree.
Three inputs have the community SCHEMA without the Community POSTURE - unset,
evaluationandcommunity-saas- and only two of those are among the ten
recognised spellings, because unset is resolved rather than declared as a
mode. Until now no single file held both answers, so the only way to see it
was to have read both halves of the tree.deploymode_test.gocomputes
BOTH columns for every recognised spelling and requires the partition, and a
separate test fails if an alias is ever declared onto a posture-selecting
mode - the one way the two could come to disagree silently. Issue #3128, the
open divergence over what unset means, is unchanged and now stated where both
answers are.
A FIFTH reading exists that the issue did not name, and it is the only one
whose ANSWER differs:platform/shared/policy/dynamic_evaluator.go's
resolveConnectorLimitTierreadsmode != "community" && mode != "", putting
UNSET on the community side - the opposite of #3096 - andevaluationand
community-saason the enterprise side, where connectors are unlimited. It is
left EXACTLY as it is: folding it in would change entitlement behaviour on the
community-SaaS fleet, which is not a rider to attach to a de-duplication
change. Reported on #3709 with its evidence.
The guard against a sixth is a test, not the lint.
scripts/lint-deployment-mode.shgreps for the literal
os.Getenv("DEPLOYMENT_MODE"), and three live escapes from it are on record:
corspolicyspelled the read through a local constant and was never on the
allow-list at all; a value fromdeploymode.Current()is not an env read;
and the customer-portal reads the variable and compares it a frame away,
across a module whose import path is not its directory.
TestTheCommunityPostureIsDecidedInExactlyOnePlaceinstead walks the tree for
the shape that is actually wrong - aDEPLOYMENT_MODEvalue meeting a mode
name - through assignments, throughstrings.ToLower/TrimSpace, and across
function boundaries, with the set of mode names derived from
RecognisedModes()rather than listed. Measured: planting a fresh reading
spelleddeploymode.Current() == "community"leaves the lint reporting
success and exiting 0, while the new test fails and names the file, the
function and the line.
The eight sites that legitimately still derive an answer from the taxonomy are
an EXEMPTION INVENTORY, each with the reason it is there, and an exemption
that stops matching is itself a failure - which is what keeps the census from
going blind and reporting a clean tree. The lint's allow-list is narrowed
from 11 files to 7 - four entries removed. Three no longer read the
variable; the fourth,platform/shared/execution/event_hub.go, was STALE -
it did readDEPLOYMENT_MODEonce, added by #1172 and removed by #1177
TWELVE HOURS later on the same day. The allow-list entry then outlived the
read by roughly SEVEN MONTHS, permitting one nobody was making while looking
like a decision somebody had made.
Two paths in this change do not reach the community mirror, and are named
here rather than left to be inferred:scripts/lint-deployment-mode_test.sh
(the lint's own harness, repo tooling -/scripts/*is stripped, while
scripts/lint-deployment-mode.shitself is re-included by name) and
runtime-e2e/3660_platform_ping_edition_mode/test.sh(runtime-e2e/is
stripped wholesale). Both are test surface for behaviour that ships: the
predicate, its call sites and the lint all reach the mirror.
corspolicyis not one of the four: it was never on the list at all, which
is the point.
R3 round 1 broke the first version of this guard, and how is worth
recording. The exemptions were keyed by FILE, and five of those files are
also on the lint's allow-list - so a brand-new, compiling
os.Getenv("DEPLOYMENT_MODE") == "community"appended to any of them passed
BOTH instruments, leaving the headline property unenforced across five files.
Every exemption reason is about a FUNCTION, so the key is now
path#function, exactly as wide as the reason. R3 also found five ordinary
spellings the census could not see: four are now followed (a package-level
var, a map keyed by mode names, a range over a literal list of them, and a
call inside a taglessswitchcase), a prefix over the mode namespace
(HasPrefix(mode, "in-vpc-")) is now treated as the taxonomy derivation it
is, and the three that remain - a struct field, a method receiver, a channel -
are PLANTED and pinned as known blind spots, so widening the analysis has to
come past a failing test rather than arriving silently. Separately, the
community-SaaS guard inplatform/orchestrator/llm/bootstrap.goturned out
to have no test at all - a one-word mutant swapping it for the Community
predicate left the package green - and now fails in both directions. -
Every MCP-seam audit row lost its tool attribution to a string mismatch
(#3717, audit umbrella #3709). The ext_mcp gateway adapter built every decide
request withTarget.Type = "mcp_tool"; the platform's tool-attribution gate
compares against"tool". The comparison never matched, so on every request
through the MCP seam the branch that copies the tool identity onto the
decision never ran:audit_logs.policy_details.tool_serverand.tool_name
were written empty, and a HITL queue entry showed a human approver the literal
stringmcp_toolwhere the tool belonged. Nothing failed. The decision was
enforced; the compliance record simply did not say what it was about - on the
one seam whose entire job is tool governance."mcp_tool"appeared exactly
once in the tree, at the construction site, and nothing read it, so the
adapter was the outlier and the gate the contract: the adapter now sends
pep.TargetTypeTool. The gate was deliberately NOT widened to accept both
spellings - that fixes the row and forks the vocabulary permanently - and a
test pins the direction not taken by asserting the retired spelling attributes
nothing.
Restoring the audit row is not a record-only change, and the first version
of this fix shipped that claim before review disproved it. The same gate
also supplied the capability-scoping key (#2801), where a tool name that
positively classifies as a text-document tool makes the engine SKIP
execution-class detectors. Measured end to end through the real seam, same
payload, only the client-chosen tool name varying:run_sql_queryblocked,
editJiraIssueallowed. So attribution and scoping are now separated where
they are read. Two request shapes supply no scoping key: one whose target ALSO
NAMES A HOSTING SERVER, and one whose request DECLARES ITSELF an MCP-gateway
call. Capability scoping's stated premise is that the party naming the tool is
the party enforcing and could equally not have called the PEP at all, and that
is false for an in-path gateway whose tool name is a field of somebody else's
request body. It takes both conditions: the seam leaves the server empty
whenever the gateway reports no backend or several, so the first condition
alone let a fully client-chosen name back into the scoping path on three of
four request shapes - a control whose ABSENCE granted the relaxation. Both are
fail-safe: each can only withhold a relaxation, never grant one, which is why
neither has to be trusted. The AuthZEN plane is covered by the first, its tool
resource ids beingserver/toolby construction, so those requests now get
FULL evaluation. Attribution is untouched throughout - the audit row, the HITL
descriptor and the FinCrime scorer's agent context all still carry the tool
name, which took separating the two into distinct required parameters, since
collapsing them removed a feature from an ML input vector. The one telemetry
value that does follow the scoping key is the ADR-065 shadow comparison's tool
label, which is empty for these requests; that is recorded-only, and it is
stated rather than absorbed.
The seam also could not have sent a tool server whatever type it used:
pep.Target, the blessed PEP client's request type, had noServerfield at
all, while its platform-side mirrorDecisionTargethas had one since #2904.
The field is added, and the two declarations - kept independent on purpose,
since aliasing them would make the wire-contract tests tautological - are now
compared field for field by reflection. The seam fills it fromservice_names
only for a single-target method; a fanout call leaves it empty rather than
naming one of several backends, since an auditor can see an absence but cannot
see that a present name is the wrong one of three. The accepted values are
declared once inpep.TargetTypesand constructed from rather than spelled at
each site; a repo-wide guard fails on anyTarget.Typestring literal outside
that set, and a second guard pins the three parallel declarations of those
same strings (pep.TargetType*,DecisionStage*,gatewayadapters.Stage*)
against each other - without it a fork through the stage constants reproduces
this defect at a production site, green across everything else.
docs/api/agent-api.yamlstates whattoolcosts to get wrong, since its
typedescription previously enumerated a vocabulary two shipped seams
already did not use.
Migration
Five migrations, all additive, and the edition split is not v10.2.0's: a Community deployment applies one of them.
| Migration | Lane | What it does | Lock / scan |
|---|---|---|---|
core/170 |
core - every deployment, Community included | Makes the application grants on every FORCE-RLS table it can name explicit, as defence in depth. | GRANT only; no table scan, no data mutation |
enterprise/151 |
enterprise | The same grant backfill for the tables that exist only in an Enterprise deployment. | as above |
enterprise/152 |
enterprise | Gives reservations a retention horizon so settled rows older than 90 days are collectable and the table has a bound. |
no rewrite |
enterprise/153 |
enterprise | Adds the per-plane column behind narrowing the decision shadow to some planes for one organization. | ADD COLUMN, nullable, no backfill |
enterprise/154 |
enterprise | Adds the per-organization typed-authoring flag. | ADD COLUMN, nullable, no backfill |
None rewrites a table, none backfills row by row, and none takes a lock long enough to matter on a relation carrying rows. Rolling the binaries before applying any of them keeps 10.3.0 behaviour, so the two halves of an upgrade can be separated.
The Enterprise and CI / Testing sections of this release are in CHANGELOG.md at v10.4.0; this body carries the Security, Community and Migration sections verbatim because the full section exceeds the release-body limit.
Full documentation: https://docs.getaxonflow.com/docs/releases/v10-4-0
Upgrade guidance: additive migration surface; rolling binaries before applying migrations keeps prior behaviour.